Compare commits
1 Commits
fix/edit-c
...
fix/kan-24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb28a3429 |
@@ -17,7 +17,6 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||||
SMTP_SECURE= # set to "false" to use port 587
|
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)
|
# Switch email features off entirely (optional)
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||||
@@ -43,8 +42,6 @@ TRELLO_APP_SECRET=
|
|||||||
|
|
||||||
# OAuth providers (optional)
|
# OAuth providers (optional)
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
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_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
@@ -82,3 +79,4 @@ TWITCH_CLIENT_SECRET=
|
|||||||
APPLE_CLIENT_ID=
|
APPLE_CLIENT_ID=
|
||||||
APPLE_CLIENT_SECRET=
|
APPLE_CLIENT_SECRET=
|
||||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
13
README.md
@@ -34,7 +34,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
|
|
||||||
## Screenshot 👁️
|
## 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 🛠️
|
## Made With 🛠️
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ pnpm dev
|
|||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
| `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>"` |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
@@ -147,10 +147,8 @@ pnpm dev
|
|||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
| `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_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `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_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
| `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_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
@@ -166,17 +164,16 @@ pnpm dev
|
|||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | 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_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `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_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `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` |
|
| `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_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_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_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_ATTATCHMENTS_BUCKET_NAME` | S3 bucket name for attatchments | For file uploads | `attatchments` |
|
||||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
| `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` |
|
| `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` |
|
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ ARG PROJECT=@kan/web
|
|||||||
|
|
||||||
# 1. Alpine image
|
# 1. Alpine image
|
||||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update && \
|
RUN apk update
|
||||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true
|
|
||||||
|
|
||||||
# Setup pnpm and turbo on the alpine base
|
# Setup pnpm and turbo on the alpine base
|
||||||
FROM alpine AS base
|
FROM alpine AS base
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": 0,
|
"version": 0,
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"po": {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ checksums:
|
|||||||
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
|
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/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
||||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||||
Actions/singular: c46571856723b03262fd33f511116298
|
|
||||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||||
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
||||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||||
@@ -27,7 +26,6 @@ checksums:
|
|||||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
||||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
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/singular: 44304f4a5ef3a46378eea243b5d2df79
|
||||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
||||||
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
||||||
@@ -93,7 +91,6 @@ checksums:
|
|||||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||||
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
|
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
|
||||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
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%20out%20some%20of%20our%20favorite%20open%20source%20projects./singular: b0d0be4f63c16552b3aa795af29240d1
|
||||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||||
@@ -178,11 +175,6 @@ checksums:
|
|||||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||||
Download%20failed/singular: 142b391cca3e05f2af9617092f7358c5
|
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%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||||
@@ -239,7 +231,6 @@ checksums:
|
|||||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||||
General/singular: b891e8f15579fc5d97bcaf3637f5ae59
|
|
||||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||||
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
||||||
@@ -251,10 +242,6 @@ checksums:
|
|||||||
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||||
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
|
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
|
||||||
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
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
|
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||||
@@ -298,7 +285,6 @@ checksums:
|
|||||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||||
Keyboard%20Shortcuts/singular: ef00d7494b69def6841620bd6554d040
|
|
||||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||||
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
||||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||||
@@ -315,8 +301,6 @@ checksums:
|
|||||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||||
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
|
||||||
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
|
||||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||||
@@ -334,7 +318,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%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
|
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
||||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||||
Navigation/singular: 0373afd8238db1c49f4be4fa6cdf5cd3
|
|
||||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||||
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
||||||
@@ -355,23 +338,18 @@ checksums:
|
|||||||
No%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
|
No%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
|
||||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||||
No%20dates/singular: bd0ec82b3b1f4a5fa89d362b71f8141d
|
|
||||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||||
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
|
||||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||||
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||||
Open%20command%20menu/singular: cf00563ea1a994e7c36a761d56664acc
|
|
||||||
Open%20keyboard%20shortcuts/singular: 638ae0999fd03d2c611064274859422e
|
|
||||||
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
||||||
Open%20Source%20Friends/singular: ffe3d75e5d03b8469c9dcb019dceb164
|
Open%20Source%20Friends/singular: ffe3d75e5d03b8469c9dcb019dceb164
|
||||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||||
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
|
||||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||||
@@ -412,11 +390,9 @@ checksums:
|
|||||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
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%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
||||||
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
|
|
||||||
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
||||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
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
|
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||||
@@ -441,15 +417,12 @@ checksums:
|
|||||||
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
||||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||||
Set%20due%20date/singular: 3cb81c4829857556f5d117c9a23f7bcc
|
|
||||||
set%20the%20due%20date/singular: b5d9a1edd988f455041aee9fa7fb3d63
|
|
||||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||||
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
|
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
|
||||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
||||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||||
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
|
||||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||||
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
|
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
|
||||||
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
|
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
|
||||||
@@ -506,7 +479,6 @@ checksums:
|
|||||||
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
||||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||||
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
|
|
||||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||||
@@ -518,7 +490,6 @@ checksums:
|
|||||||
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
||||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||||
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
|
|
||||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||||
@@ -526,7 +497,6 @@ checksums:
|
|||||||
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
|
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
|
||||||
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
|
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
|
||||||
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
||||||
Unable%20to%20update%20due%20date/singular: af20d7482cb639a8c8264c681a18abed
|
|
||||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||||
@@ -545,7 +515,6 @@ checksums:
|
|||||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||||
updated%20the%20due%20date/singular: 7636c200d50fed58a4295ed1e9b5c1e0
|
|
||||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||||
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
||||||
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf";
|
import type { LinguiConfig } from "@lingui/conf";
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
const config: LinguiConfig = {
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,10 +26,8 @@ const config = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
typescript: { ignoreBuildErrors: true },
|
|
||||||
|
|
||||||
// temporarily ignore eslint errors during build until we fix all the errors sigh
|
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: (() => {
|
remotePatterns: (() => {
|
||||||
@@ -77,13 +75,12 @@ const config = {
|
|||||||
return patterns;
|
return patterns;
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
turbopack: {
|
webpack(config) {
|
||||||
rules: {
|
config.module.rules.push({
|
||||||
"*.svg": {
|
test: /\.svg$/,
|
||||||
loaders: ["@svgr/webpack"],
|
use: ["@svgr/webpack"],
|
||||||
as: "*.js",
|
});
|
||||||
},
|
return config;
|
||||||
},
|
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
// instrumentationHook: true,
|
// instrumentationHook: true,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build --turbo",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"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",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
@@ -44,13 +44,16 @@
|
|||||||
"@trpc/next": "^11.0.0-rc.660",
|
"@trpc/next": "^11.0.0-rc.660",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
|
"aws-sdk": "^2.1692.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"next": "15.5.9",
|
"next": "^15.3.4",
|
||||||
|
"next-logger": "^5.0.1",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-cors": "^2.2.0",
|
"nextjs-cors": "^2.2.0",
|
||||||
|
"pino": "^9.6.0",
|
||||||
"posthog-js": "^1.254.0",
|
"posthog-js": "^1.254.0",
|
||||||
"react": "catalog:react18",
|
"react": "catalog:react18",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
@@ -84,6 +87,7 @@
|
|||||||
"dotenv-cli": "^7.4.4",
|
"dotenv-cli": "^7.4.4",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^1.21.6",
|
"jiti": "^1.21.6",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
1
apps/web/public/logos/bitwarden.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -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;
|
|
||||||
@@ -14,7 +14,6 @@ export const env = createEnv({
|
|||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
KAN_ADMIN_API_KEY: z.string().optional(),
|
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { Locale as DateFnsLocale } from "date-fns";
|
|
||||||
import { useLingui } from "@lingui/react";
|
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 type { Locale } from "~/locales";
|
||||||
import { useLinguiContext } from "~/providers/lingui";
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
@@ -10,20 +8,6 @@ export function useLocalisation() {
|
|||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
const { locale, setLocale, availableLocales } = useLinguiContext();
|
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) => {
|
const handleSetLocale = async (newLocale: Locale) => {
|
||||||
await activateLocale(newLocale);
|
await activateLocale(newLocale);
|
||||||
setLocale(newLocale);
|
setLocale(newLocale);
|
||||||
@@ -31,9 +15,9 @@ export function useLocalisation() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
dateLocale: currentDateLocale,
|
|
||||||
setLocale: handleSetLocale,
|
setLocale: handleSetLocale,
|
||||||
availableLocales,
|
availableLocales,
|
||||||
|
formatDate: i18n.date,
|
||||||
formatNumber: i18n.number,
|
formatNumber: i18n.number,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
6
apps/web/src/instrumentation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await require("pino");
|
||||||
|
await require("next-logger");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ export const locales = [
|
|||||||
"nl",
|
"nl",
|
||||||
"ru",
|
"ru",
|
||||||
"pl",
|
"pl",
|
||||||
"ptbr"
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
@@ -23,5 +22,4 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
ptbr: "Português",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ export default async function handler(
|
|||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
payment_method_collection: "always",
|
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||||
|
|||||||
@@ -43,22 +43,18 @@ export default async function handler(
|
|||||||
return res.status(400).json({ error: "Invalid content type" });
|
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({
|
const client = new S3Client({
|
||||||
region: env.S3_REGION ?? "",
|
region: env.S3_REGION ?? "",
|
||||||
endpoint: env.S3_ENDPOINT ?? "",
|
endpoint: env.S3_ENDPOINT ?? "",
|
||||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
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(
|
const signedUrl = await getSignedUrl(
|
||||||
|
// @ts-ignore
|
||||||
client,
|
client,
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
|
|||||||
@@ -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 }),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -22,8 +22,6 @@ const loadMessages = async (locale: Locale) => {
|
|||||||
return (await import("~/locales/ru/messages")).messages;
|
return (await import("~/locales/ru/messages")).messages;
|
||||||
case "pl":
|
case "pl":
|
||||||
return (await import("~/locales/pl/messages")).messages;
|
return (await import("~/locales/pl/messages")).messages;
|
||||||
case "ptbr":
|
|
||||||
return (await import("~/locales/ptbr/messages")).messages;
|
|
||||||
default:
|
default:
|
||||||
return enMessages;
|
return enMessages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { format, isBefore, isSameYear, startOfDay } from "date-fns";
|
|
||||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||||
import {
|
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
|
||||||
HiBars3BottomLeft,
|
|
||||||
HiChatBubbleLeft,
|
|
||||||
HiOutlineClock,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Badge from "~/components/Badge";
|
import Badge from "~/components/Badge";
|
||||||
import CircularProgress from "~/components/CircularProgress";
|
import CircularProgress from "~/components/CircularProgress";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
const Card = ({
|
const Card = ({
|
||||||
@@ -22,7 +15,6 @@ const Card = ({
|
|||||||
description,
|
description,
|
||||||
comments,
|
comments,
|
||||||
attachments,
|
attachments,
|
||||||
dueDate,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
labels: { name: string; colourCode: string | null }[];
|
labels: { name: string; colourCode: string | null }[];
|
||||||
@@ -44,11 +36,7 @@ const Card = ({
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
comments: { publicId: string }[];
|
comments: { publicId: string }[];
|
||||||
attachments?: { 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) => {
|
const completedItems = checklists.reduce((acc, checklist) => {
|
||||||
return acc + checklist.items.filter((item) => item.completed).length;
|
return acc + checklist.items.filter((item) => item.completed).length;
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -63,7 +51,6 @@ const Card = ({
|
|||||||
const hasDescription =
|
const hasDescription =
|
||||||
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
|
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
|
||||||
const hasAttachments = attachments && attachments.length > 0;
|
const hasAttachments = attachments && attachments.length > 0;
|
||||||
const hasDueDate = !!dueDate;
|
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -73,7 +60,6 @@ const Card = ({
|
|||||||
checklists.length > 0 ||
|
checklists.length > 0 ||
|
||||||
hasDescription ||
|
hasDescription ||
|
||||||
comments.length > 0 ||
|
comments.length > 0 ||
|
||||||
hasDueDate ||
|
|
||||||
hasAttachments ? (
|
hasAttachments ? (
|
||||||
<div className="mt-2 flex flex-col justify-end">
|
<div className="mt-2 flex flex-col justify-end">
|
||||||
<div className="space-x-0.5">
|
<div className="space-x-0.5">
|
||||||
@@ -91,23 +77,6 @@ const Card = ({
|
|||||||
<HiBars3BottomLeft className="h-4 w-4" />
|
<HiBars3BottomLeft className="h-4 w-4" />
|
||||||
</div>
|
</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 && (
|
{comments.length > 0 && (
|
||||||
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
|
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
|
||||||
<HiChatBubbleLeft className="h-4 w-4" />
|
<HiChatBubbleLeft className="h-4 w-4" />
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useRouter } from "next/router";
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import {
|
import {
|
||||||
HiMiniXMark,
|
HiMiniXMark,
|
||||||
HiOutlineClock,
|
|
||||||
HiOutlineSquare3Stack3D,
|
HiOutlineSquare3Stack3D,
|
||||||
HiOutlineTag,
|
HiOutlineTag,
|
||||||
HiOutlineUserCircle,
|
HiOutlineUserCircle,
|
||||||
@@ -61,13 +60,7 @@ const Filters = ({
|
|||||||
try {
|
try {
|
||||||
await router.push({
|
await router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: {
|
query: { ...router.query, members: [], labels: [], lists: [] },
|
||||||
...router.query,
|
|
||||||
members: [],
|
|
||||||
labels: [],
|
|
||||||
lists: [],
|
|
||||||
dueDate: [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -106,39 +99,6 @@ const Filters = ({
|
|||||||
selected: !!router.query.lists?.includes(list.publicId),
|
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 = [
|
const groups = [
|
||||||
...(formattedMembers.length
|
...(formattedMembers.length
|
||||||
? [
|
? [
|
||||||
@@ -166,12 +126,6 @@ const Filters = ({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
|
||||||
key: "dueDate",
|
|
||||||
label: t`Due date`,
|
|
||||||
icon: <HiOutlineClock size={16} />,
|
|
||||||
items: dueDateItems,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSelect = async (
|
const handleSelect = async (
|
||||||
@@ -202,7 +156,6 @@ const Filters = ({
|
|||||||
...formatToArray(router.query.members),
|
...formatToArray(router.query.members),
|
||||||
...formatToArray(router.query.labels),
|
...formatToArray(router.query.labels),
|
||||||
...formatToArray(router.query.lists),
|
...formatToArray(router.query.lists),
|
||||||
...formatToArray(router.query.dueDate),
|
|
||||||
].length;
|
].length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { format } from "date-fns";
|
import { useEffect } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
HiOutlineBarsArrowDown,
|
HiOutlineBarsArrowDown,
|
||||||
@@ -16,7 +15,6 @@ import type { WorkspaceMember } from "~/components/Editor";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import DateSelector from "~/components/DateSelector";
|
|
||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
@@ -29,7 +27,6 @@ import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
|||||||
|
|
||||||
type NewCardFormInput = NewCardInput & {
|
type NewCardFormInput = NewCardInput & {
|
||||||
isCreateAnotherEnabled: boolean;
|
isCreateAnotherEnabled: boolean;
|
||||||
dueDate?: Date | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface QueryParams {
|
interface QueryParams {
|
||||||
@@ -68,7 +65,6 @@ export function NewCardForm({
|
|||||||
memberPublicIds: [],
|
memberPublicIds: [],
|
||||||
isCreateAnotherEnabled: false,
|
isCreateAnotherEnabled: false,
|
||||||
position: "start",
|
position: "start",
|
||||||
dueDate: null,
|
|
||||||
},
|
},
|
||||||
resetOnClose: true,
|
resetOnClose: true,
|
||||||
});
|
});
|
||||||
@@ -84,8 +80,6 @@ export function NewCardForm({
|
|||||||
const position = watch("position");
|
const position = watch("position");
|
||||||
const title = watch("title");
|
const title = watch("title");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
const dueDate = watch("dueDate");
|
|
||||||
const [isDateSelectorOpen, setIsDateSelectorOpen] = useState(false);
|
|
||||||
|
|
||||||
// saving form state whenever form values change
|
// saving form state whenever form values change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,7 +137,6 @@ export function NewCardForm({
|
|||||||
title: args.title,
|
title: args.title,
|
||||||
listId: 2,
|
listId: 2,
|
||||||
description: "",
|
description: "",
|
||||||
dueDate: args.dueDate ?? null,
|
|
||||||
labels: oldBoard.labels.filter((label) =>
|
labels: oldBoard.labels.filter((label) =>
|
||||||
args.labelPublicIds.includes(label.publicId),
|
args.labelPublicIds.includes(label.publicId),
|
||||||
),
|
),
|
||||||
@@ -200,7 +193,6 @@ export function NewCardForm({
|
|||||||
memberPublicIds: [],
|
memberPublicIds: [],
|
||||||
isCreateAnotherEnabled,
|
isCreateAnotherEnabled,
|
||||||
position,
|
position,
|
||||||
dueDate: null,
|
|
||||||
};
|
};
|
||||||
reset(newFormState);
|
reset(newFormState);
|
||||||
saveFormState(newFormState);
|
saveFormState(newFormState);
|
||||||
@@ -250,7 +242,7 @@ export function NewCardForm({
|
|||||||
),
|
),
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
const onSubmit = (data: NewCardFormInput) => {
|
const onSubmit = (data: NewCardInput) => {
|
||||||
createCard.mutate({
|
createCard.mutate({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -258,7 +250,6 @@ export function NewCardForm({
|
|||||||
labelPublicIds: data.labelPublicIds,
|
labelPublicIds: data.labelPublicIds,
|
||||||
memberPublicIds: data.memberPublicIds,
|
memberPublicIds: data.memberPublicIds,
|
||||||
position: data.position,
|
position: data.position,
|
||||||
dueDate: data.dueDate ?? null,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -457,44 +448,6 @@ export function NewCardForm({
|
|||||||
</div>
|
</div>
|
||||||
</CheckboxDropdown>
|
</CheckboxDropdown>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -87,26 +87,18 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const semanticFilters = formatToArray(router.query.dueDate) as (
|
const queryParams: {
|
||||||
| "overdue"
|
boardPublicId: string;
|
||||||
| "today"
|
members: string[];
|
||||||
| "tomorrow"
|
labels: string[];
|
||||||
| "next-week"
|
lists: string[];
|
||||||
| "next-month"
|
type: "regular" | "template";
|
||||||
| "no-due-date"
|
} = {
|
||||||
)[];
|
|
||||||
|
|
||||||
const boardType: "regular" | "template" = isTemplate ? "template" : "regular";
|
|
||||||
|
|
||||||
const queryParams = {
|
|
||||||
boardPublicId: boardId ?? "",
|
boardPublicId: boardId ?? "",
|
||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
lists: formatToArray(router.query.lists),
|
lists: formatToArray(router.query.lists),
|
||||||
...(semanticFilters.length > 0 && {
|
type: isTemplate ? "template" : "regular",
|
||||||
dueDateFilters: semanticFilters,
|
|
||||||
}),
|
|
||||||
type: boardType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -575,7 +567,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
}
|
}
|
||||||
comments={card.comments ?? []}
|
comments={card.comments ?? []}
|
||||||
attachments={card.attachments}
|
attachments={card.attachments}
|
||||||
dueDate={card.dueDate ?? null}
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { Locale as DateFnsLocale } from "date-fns";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { format, formatDistanceToNow, isSameYear } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||||
import {
|
import {
|
||||||
HiOutlineArrowLeft,
|
HiOutlineArrowLeft,
|
||||||
HiOutlineArrowRight,
|
HiOutlineArrowRight,
|
||||||
HiOutlineCheckCircle,
|
HiOutlineCheckCircle,
|
||||||
HiOutlineClock,
|
|
||||||
HiOutlinePencil,
|
HiOutlinePencil,
|
||||||
HiOutlinePlus,
|
HiOutlinePlus,
|
||||||
HiOutlineTag,
|
HiOutlineTag,
|
||||||
@@ -16,20 +14,25 @@ import {
|
|||||||
HiOutlineUserPlus,
|
HiOutlineUserPlus,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import type {
|
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||||
GetCardActivitiesOutput,
|
|
||||||
GetCardByIdOutput,
|
|
||||||
} from "@kan/api/types";
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import Comment from "./Comment";
|
import Comment from "./Comment";
|
||||||
|
|
||||||
type ActivityType =
|
type ActivityType =
|
||||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
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) => {
|
const truncate = (value: string | null, maxLength = 50) => {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||||
@@ -44,9 +47,6 @@ const getActivityText = ({
|
|||||||
isSelf,
|
isSelf,
|
||||||
label,
|
label,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
toDueDate,
|
|
||||||
dateLocale,
|
|
||||||
mergedLabels,
|
|
||||||
}: {
|
}: {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
toTitle: string | null;
|
toTitle: string | null;
|
||||||
@@ -56,45 +56,7 @@ const getActivityText = ({
|
|||||||
isSelf: boolean;
|
isSelf: boolean;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
fromTitle?: 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 = {
|
const ACTIVITY_TYPE_MAP = {
|
||||||
"card.created": t`created the card`,
|
"card.created": t`created the card`,
|
||||||
"card.updated.title": t`updated the title`,
|
"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.completed": t`completed a checklist item`,
|
||||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
"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;
|
} as const;
|
||||||
|
|
||||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||||
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
|
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) {
|
if (type === "card.updated.title" && toTitle) {
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<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;
|
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.completed": <HiOutlineCheckCircle />,
|
||||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
|
||||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
|
||||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const getActivityIcon = (
|
const getActivityIcon = (
|
||||||
@@ -316,151 +246,36 @@ const getActivityIcon = (
|
|||||||
return ACTIVITY_ICON_MAP[type] ?? null;
|
return ACTIVITY_ICON_MAP[type] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVITIES_PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
const ActivityList = ({
|
const ActivityList = ({
|
||||||
|
activities,
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
isLoading: cardIsLoading,
|
isLoading,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isViewOnly,
|
isViewOnly,
|
||||||
}: {
|
}: {
|
||||||
|
activities: NonNullable<GetCardByIdOutput>["activities"];
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
isViewOnly?: boolean;
|
isViewOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { dateLocale, locale } = useLocalisation();
|
const { data } = authClient.useSession();
|
||||||
const { data: sessionData } = authClient.useSession();
|
const { locale } = useLocalisation();
|
||||||
const utils = api.useUtils();
|
|
||||||
const [allActivities, setAllActivities] = useState<
|
|
||||||
GetCardActivitiesOutput["activities"]
|
|
||||||
>([]);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
||||||
|
|
||||||
const isFullyExpandedRef = useRef(false);
|
const currentDateLocale = dateLocaleMap[locale] || enGB;
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-4 pt-4">
|
<div className="flex flex-col space-y-4 pt-4">
|
||||||
{allActivities.map((activity, index) => {
|
{activities.map((activity, index) => {
|
||||||
const activityText = getActivityText({
|
const activityText = getActivityText({
|
||||||
type: activity.type,
|
type: activity.type,
|
||||||
toTitle: activity.toTitle,
|
toTitle: activity.toTitle,
|
||||||
fromList: activity.fromList?.name ?? null,
|
fromList: activity.fromList?.name ?? null,
|
||||||
toList: activity.toList?.name ?? null,
|
toList: activity.toList?.name ?? null,
|
||||||
memberName: activity.member?.user?.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,
|
label: activity.label?.name ?? null,
|
||||||
fromTitle: activity.fromTitle ?? 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")
|
if (activity.type === "card.updated.comment.added")
|
||||||
@@ -475,7 +290,7 @@ const ActivityList = ({
|
|||||||
createdAt={activity.createdAt.toISOString()}
|
createdAt={activity.createdAt.toISOString()}
|
||||||
comment={activity.comment?.comment}
|
comment={activity.comment?.comment}
|
||||||
isEdited={!!activity.comment?.updatedAt}
|
isEdited={!!activity.comment?.updatedAt}
|
||||||
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
|
isAuthor={activity.comment?.createdBy === data?.user.id}
|
||||||
isAdmin={isAdmin ?? false}
|
isAdmin={isAdmin ?? false}
|
||||||
isViewOnly={!!isViewOnly}
|
isViewOnly={!!isViewOnly}
|
||||||
/>
|
/>
|
||||||
@@ -500,7 +315,9 @@ const ActivityList = ({
|
|||||||
)}
|
)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
{index !== allActivities.length - 1 && (
|
{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 className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -513,24 +330,13 @@ const ActivityList = ({
|
|||||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||||
{formatDistanceToNow(new Date(activity.createdAt), {
|
{formatDistanceToNow(new Date(activity.createdAt), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: dateLocale,
|
locale: currentDateLocale,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
@@ -82,7 +81,7 @@ export function AttachmentThumbnails({
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Button from "~/components/Button";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
@@ -21,7 +20,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
||||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
const confirmAttachment = api.attachment.confirm.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Attachment uploaded`,
|
header: t`Attachment uploaded`,
|
||||||
message: t`Your file has been uploaded successfully.`,
|
message: t`Your file has been uploaded successfully.`,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import type { DraggableProvided } from "react-beautiful-dnd";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ContentEditable from "react-contenteditable";
|
import ContentEditable from "react-contenteditable";
|
||||||
import { HiXMark } from "react-icons/hi2";
|
import { HiXMark } from "react-icons/hi2";
|
||||||
import { RiDraggable } from "react-icons/ri";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface ChecklistItemRowProps {
|
interface ChecklistItemRowProps {
|
||||||
item: {
|
item: {
|
||||||
@@ -17,19 +14,13 @@ interface ChecklistItemRowProps {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
};
|
};
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
onCreateNewItem?: () => void;
|
|
||||||
viewOnly?: boolean;
|
viewOnly?: boolean;
|
||||||
dragHandleProps?: DraggableProvided["dragHandleProps"];
|
|
||||||
isDragging?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChecklistItemRow({
|
export default function ChecklistItemRow({
|
||||||
item,
|
item,
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
onCreateNewItem,
|
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
dragHandleProps,
|
|
||||||
isDragging = false,
|
|
||||||
}: ChecklistItemRowProps) {
|
}: ChecklistItemRowProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
@@ -71,7 +62,7 @@ export default function ChecklistItemRow({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +90,7 @@ export default function ChecklistItemRow({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,23 +138,7 @@ export default function ChecklistItemRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
|
||||||
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" />}
|
|
||||||
|
|
||||||
<label
|
<label
|
||||||
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
|
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}
|
disabled={viewOnly}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
// @ts-expect-error - valid event
|
// @ts-expect-error - valid event
|
||||||
onBlur={(e: Event) => {
|
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)}
|
||||||
const innerHTML = (e.target as HTMLElement).innerHTML;
|
|
||||||
commitTitle(innerHTML);
|
|
||||||
}}
|
|
||||||
className={twMerge(
|
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",
|
"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",
|
viewOnly && "cursor-default",
|
||||||
@@ -202,9 +174,7 @@ export default function ChecklistItemRow({
|
|||||||
if (viewOnly) return;
|
if (viewOnly) return;
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
|
commitTitle(title);
|
||||||
commitTitle(innerHTML);
|
|
||||||
onCreateNewItem?.();
|
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
export default function ChecklistNameInput({
|
export default function ChecklistNameInput({
|
||||||
checklistPublicId,
|
checklistPublicId,
|
||||||
@@ -49,7 +48,7 @@ export default function ChecklistNameInput({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { HiPlus, HiXMark } from "react-icons/hi2";
|
||||||
|
|
||||||
import CircularProgress from "~/components/CircularProgress";
|
import CircularProgress from "~/components/CircularProgress";
|
||||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import ChecklistItemRow from "./ChecklistItemRow";
|
import ChecklistItemRow from "./ChecklistItemRow";
|
||||||
import ChecklistNameInput from "./ChecklistNameInput";
|
import ChecklistNameInput from "./ChecklistNameInput";
|
||||||
import NewChecklistItemForm from "./NewChecklistItemForm";
|
import NewChecklistItemForm from "./NewChecklistItemForm";
|
||||||
@@ -40,72 +34,10 @@ export default function Checklists({
|
|||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
}: ChecklistsProps) {
|
}: ChecklistsProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
if (!checklists || checklists.length === 0) return null;
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
|
||||||
<div className="border-light-300 pb-4 dark:border-dark-300">
|
<div className="border-light-300 pb-4 dark:border-dark-300">
|
||||||
<div>
|
<div>
|
||||||
{checklists.map((checklist) => {
|
{checklists.map((checklist) => {
|
||||||
@@ -176,55 +108,21 @@ export default function Checklists({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Droppable
|
<div className="ml-1">
|
||||||
droppableId={checklist.publicId}
|
{checklist.items.map((item) => (
|
||||||
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
|
<ChecklistItemRow
|
||||||
|
key={item.publicId}
|
||||||
item={{
|
item={{
|
||||||
publicId: item.publicId,
|
publicId: item.publicId,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
completed: item.completed,
|
completed: item.completed,
|
||||||
}}
|
}}
|
||||||
cardPublicId={cardPublicId}
|
cardPublicId={cardPublicId}
|
||||||
onCreateNewItem={() =>
|
|
||||||
setActiveChecklistForm?.(checklist.publicId)
|
|
||||||
}
|
|
||||||
viewOnly={viewOnly}
|
viewOnly={viewOnly}
|
||||||
dragHandleProps={provided.dragHandleProps}
|
|
||||||
isDragging={snapshot.isDragging}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
))}
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
{activeChecklistForm === checklist.publicId && !viewOnly && (
|
{activeChecklistForm === checklist.publicId && !viewOnly && (
|
||||||
<div className="ml-1">
|
<div className="ml-1">
|
||||||
<NewChecklistItemForm
|
<NewChecklistItemForm
|
||||||
@@ -240,6 +138,5 @@ export default function Checklists({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DragDropContext>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import Dropdown from "~/components/Dropdown";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -56,7 +55,7 @@ const Comment = ({
|
|||||||
|
|
||||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.refetch();
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Button from "~/components/Button";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
export function DeleteChecklistConfirmation({
|
export function DeleteChecklistConfirmation({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
@@ -41,7 +40,7 @@ export function DeleteChecklistConfirmation({
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Button from "~/components/Button";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface DeleteCommentConfirmationProps {
|
interface DeleteCommentConfirmationProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -48,7 +47,7 @@ export function DeleteCommentConfirmation({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate(queryParams);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface LabelSelectorProps {
|
interface LabelSelectorProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -75,7 +74,7 @@ export default function LabelSelector({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { t } from "@lingui/core/macro";
|
|||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface ListSelectorProps {
|
interface ListSelectorProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -55,7 +54,7 @@ export default function ListSelector({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ export default function ListSelector({
|
|||||||
}}
|
}}
|
||||||
asChild
|
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}
|
{selectedList?.value}
|
||||||
</div>
|
</div>
|
||||||
</CheckboxDropdown>
|
</CheckboxDropdown>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface MemberSelectorProps {
|
interface MemberSelectorProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -82,7 +81,7 @@ export default function MemberSelector({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +111,7 @@ export default function MemberSelector({
|
|||||||
createNewItemLabel={t`Invite member`}
|
createNewItemLabel={t`Invite member`}
|
||||||
asChild
|
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 ? (
|
{selectedMembers.length ? (
|
||||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||||
{selectedMembers.map(({ value, imageUrl }) => (
|
{selectedMembers.map(({ value, imageUrl }) => (
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import Input from "~/components/Input";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface NewChecklistFormInput {
|
interface NewChecklistFormInput {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -73,7 +72,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async (_data, _error, vars) => {
|
onSettled: async (_data, _error, vars) => {
|
||||||
await invalidateCard(utils, vars.cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -92,7 +91,7 @@ const NewChecklistItemForm = ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { HiOutlineArrowUp } from "react-icons/hi2";
|
|||||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -35,7 +34,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
reset();
|
reset();
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate(queryParams);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.card.byId.refetch();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||||
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
||||||
import ActivityList from "./components/ActivityList";
|
import ActivityList from "./components/ActivityList";
|
||||||
@@ -28,7 +27,6 @@ import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
|||||||
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
||||||
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
||||||
import Dropdown from "./components/Dropdown";
|
import Dropdown from "./components/Dropdown";
|
||||||
import { DueDateSelector } from "./components/DueDateSelector";
|
|
||||||
import LabelSelector from "./components/LabelSelector";
|
import LabelSelector from "./components/LabelSelector";
|
||||||
import ListSelector from "./components/ListSelector";
|
import ListSelector from "./components/ListSelector";
|
||||||
import MemberSelector from "./components/MemberSelector";
|
import MemberSelector from "./components/MemberSelector";
|
||||||
@@ -126,7 +124,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isTemplate && (
|
{!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>
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||||
<MemberSelector
|
<MemberSelector
|
||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
@@ -135,14 +133,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,7 +147,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
getModalState,
|
getModalState,
|
||||||
clearModalState,
|
clearModalState,
|
||||||
isOpen,
|
isOpen,
|
||||||
modalStates,
|
|
||||||
} = useModal();
|
} = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
@@ -179,6 +168,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
|
|
||||||
const board = card?.list.board;
|
const board = card?.list.board;
|
||||||
const boardId = board?.publicId;
|
const boardId = board?.publicId;
|
||||||
|
const activities = card?.activities;
|
||||||
|
|
||||||
const updateCard = api.card.update.useMutation({
|
const updateCard = api.card.update.useMutation({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -189,22 +179,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
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 +199,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
|
// Open the new item form after creating a new checklist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
@@ -388,6 +345,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<div>
|
<div>
|
||||||
<ActivityList
|
<ActivityList
|
||||||
cardPublicId={cardId}
|
cardPublicId={cardId}
|
||||||
|
activities={activities ?? []}
|
||||||
isLoading={!card}
|
isLoading={!card}
|
||||||
isAdmin={workspace.role === "admin"}
|
isAdmin={workspace.role === "admin"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { FC, SVGProps } from "react";
|
import type { FC, SVGProps } from "react";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
import AirbusLogo from "~/assets/logos/airbus.svg";
|
import AirbusLogo from "/public/logos/airbus.svg";
|
||||||
import CouchbaseLogo from "~/assets/logos/couchbase.svg";
|
import BitwardenLogo from "/public/logos/bitwarden.svg";
|
||||||
import DeloitteLogo from "~/assets/logos/deloitte.svg";
|
import CouchbaseLogo from "/public/logos/couchbase.svg";
|
||||||
import FastCompanyLogo from "~/assets/logos/fast_company.svg";
|
import DeloitteLogo from "/public/logos/deloitte.svg";
|
||||||
import LegoLogo from "~/assets/logos/lego.svg";
|
import FastCompanyLogo from "/public/logos/fast_company.svg";
|
||||||
import LinkedinLogo from "~/assets/logos/linkedin.svg";
|
import LegoLogo from "/public/logos/lego.svg";
|
||||||
import SanaLogo from "~/assets/logos/sana.svg";
|
import LinkedinLogo from "/public/logos/linkedin.svg";
|
||||||
import WakamLogo from "~/assets/logos/wakam.svg";
|
import SanaLogo from "/public/logos/sana.svg";
|
||||||
|
import WakamLogo from "/public/logos/wakam.svg";
|
||||||
|
|
||||||
type LogoComponent = FC<SVGProps<SVGSVGElement>>;
|
type LogoComponent = FC<SVGProps<SVGSVGElement>>;
|
||||||
|
|
||||||
@@ -30,31 +31,36 @@ export default function Logos() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
|
component: BitwardenLogo as LogoComponent,
|
||||||
|
alt: "Bitwarden Logo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
component: CouchbaseLogo as LogoComponent,
|
component: CouchbaseLogo as LogoComponent,
|
||||||
alt: "Couchbase Logo",
|
alt: "Couchbase Logo",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 5,
|
||||||
component: LegoLogo as LogoComponent,
|
component: LegoLogo as LogoComponent,
|
||||||
alt: "Lego Logo",
|
alt: "Lego Logo",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 6,
|
||||||
component: AirbusLogo as LogoComponent,
|
component: AirbusLogo as LogoComponent,
|
||||||
alt: "Airbus Logo",
|
alt: "Airbus Logo",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 7,
|
||||||
component: DeloitteLogo as LogoComponent,
|
component: DeloitteLogo as LogoComponent,
|
||||||
alt: "Deloitte Logo",
|
alt: "Deloitte Logo",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 8,
|
||||||
component: WakamLogo as LogoComponent,
|
component: WakamLogo as LogoComponent,
|
||||||
alt: "Wakam Logo",
|
alt: "Wakam Logo",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 9,
|
||||||
component: LinkedinLogo as LogoComponent,
|
component: LinkedinLogo as LogoComponent,
|
||||||
alt: "Linked In Logo",
|
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 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="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">
|
<div className="flex flex-shrink-0 items-center space-x-12">
|
||||||
{logos.map((logo) => {
|
{logos.map((logo) => {
|
||||||
const LogoComponent: LogoComponent = logo.component;
|
const LogoComponent: LogoComponent = logo.component;
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export function CardModal({
|
|||||||
{cardPublicId && (
|
{cardPublicId && (
|
||||||
<ActivityList
|
<ActivityList
|
||||||
cardPublicId={cardPublicId}
|
cardPublicId={cardPublicId}
|
||||||
|
activities={data?.activities ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isViewOnly={true}
|
isViewOnly={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ export default function PublicBoardView() {
|
|||||||
? router.query.workspaceSlug[0]
|
? router.query.workspaceSlug[0]
|
||||||
: router.query.workspaceSlug;
|
: 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(
|
const { data, isLoading } = api.board.bySlug.useQuery(
|
||||||
{
|
{
|
||||||
boardSlug: boardSlug ?? "",
|
boardSlug: boardSlug ?? "",
|
||||||
@@ -54,9 +45,6 @@ export default function PublicBoardView() {
|
|||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
lists: formatToArray(router.query.lists),
|
lists: formatToArray(router.query.lists),
|
||||||
...(dueDateFilters.length > 0 && {
|
|
||||||
dueDateFilters: dueDateFilters,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: router.isReady && !!boardSlug,
|
enabled: router.isReady && !!boardSlug,
|
||||||
@@ -89,7 +77,7 @@ export default function PublicBoardView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pathWithoutQuery = router.asPath.split("?")[0];
|
const pathWithoutQuery = router.asPath.split("?")[0];
|
||||||
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
const splitPath = pathWithoutQuery.split("/");
|
||||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -211,7 +199,6 @@ export default function PublicBoardView() {
|
|||||||
description={card.description}
|
description={card.description}
|
||||||
comments={card.comments ?? []}
|
comments={card.comments ?? []}
|
||||||
attachments={card.attachments}
|
attachments={card.attachments}
|
||||||
dueDate={card.dueDate ?? null}
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
|
|
||||||
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
||||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||||
@@ -36,7 +35,6 @@ services:
|
|||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
- SMTP_SECURE=${SMTP_SECURE}
|
- SMTP_SECURE=${SMTP_SECURE}
|
||||||
- EMAIL_FROM=${EMAIL_FROM}
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
- SMTP_REJECT_UNAUTHORIZED=${SMTP_REJECT_UNAUTHORIZED}
|
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
- NOVU_API_KEY=${NOVU_API_KEY}
|
- NOVU_API_KEY=${NOVU_API_KEY}
|
||||||
@@ -66,7 +64,6 @@ services:
|
|||||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
- BETTER_AUTH_ALLOWED_DOMAINS=${BETTER_AUTH_ALLOWED_DOMAINS}
|
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||||
|
|||||||
@@ -18,16 +18,12 @@ services:
|
|||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||||
- POSTGRES_URL=${POSTGRES_URL}
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
|
|
||||||
# Admin API key (optional)
|
|
||||||
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
|
|
||||||
|
|
||||||
# SMTP (optional)
|
# SMTP (optional)
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
- SMTP_PORT=${SMTP_PORT}
|
- SMTP_PORT=${SMTP_PORT}
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
- EMAIL_FROM=${EMAIL_FROM}
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
- SMTP_REJECT_UNAUTHORIZED=${SMTP_REJECT_UNAUTHORIZED}
|
|
||||||
|
|
||||||
# Disable email features entirely (optional)
|
# Disable email features entirely (optional)
|
||||||
- NEXT_PUBLIC_DISABLE_EMAIL=${NEXT_PUBLIC_DISABLE_EMAIL}
|
- NEXT_PUBLIC_DISABLE_EMAIL=${NEXT_PUBLIC_DISABLE_EMAIL}
|
||||||
@@ -63,7 +59,6 @@ services:
|
|||||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
- BETTER_AUTH_ALLOWED_DOMAINS=${BETTER_AUTH_ALLOWED_DOMAINS}
|
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||||
|
|||||||
@@ -9,15 +9,5 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
|
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
|
||||||
docsUrl: "docs.kan.bn",
|
docsUrl: "docs.kan.bn",
|
||||||
tags: [
|
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
|
||||||
"Auth",
|
|
||||||
"Users",
|
|
||||||
"Boards",
|
|
||||||
"Lists",
|
|
||||||
"Cards",
|
|
||||||
"Labels",
|
|
||||||
"Imports",
|
|
||||||
"Integrations",
|
|
||||||
"Health",
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { boardRouter } from "./routers/board";
|
|||||||
import { cardRouter } from "./routers/card";
|
import { cardRouter } from "./routers/card";
|
||||||
import { checklistRouter } from "./routers/checklist";
|
import { checklistRouter } from "./routers/checklist";
|
||||||
import { feedbackRouter } from "./routers/feedback";
|
import { feedbackRouter } from "./routers/feedback";
|
||||||
import { healthRouter } from "./routers/health";
|
|
||||||
import { importRouter } from "./routers/import";
|
import { importRouter } from "./routers/import";
|
||||||
import { integrationRouter } from "./routers/integration";
|
import { integrationRouter } from "./routers/integration";
|
||||||
import { labelRouter } from "./routers/label";
|
import { labelRouter } from "./routers/label";
|
||||||
@@ -19,7 +18,6 @@ export const appRouter = createTRPCRouter({
|
|||||||
card: cardRouter,
|
card: cardRouter,
|
||||||
checklist: checklistRouter,
|
checklist: checklistRouter,
|
||||||
feedback: feedbackRouter,
|
feedback: feedbackRouter,
|
||||||
health: healthRouter,
|
|
||||||
label: labelRouter,
|
label: labelRouter,
|
||||||
list: listRouter,
|
list: listRouter,
|
||||||
member: memberRouter,
|
member: memberRouter,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
import { deleteObject, generateUploadUrl } from "../utils/s3";
|
import { generateUploadUrl } from "../utils/s3";
|
||||||
|
|
||||||
export const attachmentRouter = createTRPCRouter({
|
export const attachmentRouter = createTRPCRouter({
|
||||||
generateUploadUrl: protectedProcedure
|
generateUploadUrl: protectedProcedure
|
||||||
@@ -189,18 +189,6 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspaceId);
|
await assertUserInWorkspace(ctx.db, userId, workspaceId);
|
||||||
|
|
||||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
|
||||||
if (bucket) {
|
|
||||||
try {
|
|
||||||
await deleteObject(bucket, attachment.s3Key);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Failed to delete attachment from S3: ${attachment.s3Key}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await cardAttachmentRepo.softDelete(ctx.db, {
|
await cardAttachmentRepo.softDelete(ctx.db, {
|
||||||
attachmentId: attachment.id,
|
attachmentId: attachment.id,
|
||||||
deletedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import * as labelRepo from "@kan/db/repository/label.repo";
|
|||||||
import * as listRepo from "@kan/db/repository/list.repo";
|
import * as listRepo from "@kan/db/repository/list.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { colours } from "@kan/shared/constants";
|
import { colours } from "@kan/shared/constants";
|
||||||
import {
|
import { generateSlug, generateUID } from "@kan/shared/utils";
|
||||||
convertDueDateFiltersToRanges,
|
|
||||||
generateSlug,
|
|
||||||
generateUID,
|
|
||||||
} from "@kan/shared/utils";
|
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
@@ -83,18 +79,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: z.array(z.string().min(12)).optional(),
|
members: z.array(z.string().min(12)).optional(),
|
||||||
labels: z.array(z.string().min(12)).optional(),
|
labels: z.array(z.string().min(12)).optional(),
|
||||||
lists: z.array(z.string().min(12)).optional(),
|
lists: z.array(z.string().min(12)).optional(),
|
||||||
dueDateFilters: z
|
|
||||||
.array(
|
|
||||||
z.enum([
|
|
||||||
"overdue",
|
|
||||||
"today",
|
|
||||||
"tomorrow",
|
|
||||||
"next-week",
|
|
||||||
"next-month",
|
|
||||||
"no-due-date",
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
type: z.enum(["regular", "template"]).optional(),
|
type: z.enum(["regular", "template"]).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -121,11 +105,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||||
|
|
||||||
// Convert semantic string filters to date ranges expected by the repo
|
|
||||||
const dueDateFilters = input.dueDateFilters
|
|
||||||
? convertDueDateFiltersToRanges(input.dueDateFilters)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const result = await boardRepo.getByPublicId(
|
const result = await boardRepo.getByPublicId(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.boardPublicId,
|
input.boardPublicId,
|
||||||
@@ -133,7 +112,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
lists: input.lists ?? [],
|
lists: input.lists ?? [],
|
||||||
dueDate: dueDateFilters,
|
|
||||||
type: input.type,
|
type: input.type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -167,18 +145,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: z.array(z.string().min(12)).optional(),
|
members: z.array(z.string().min(12)).optional(),
|
||||||
labels: z.array(z.string().min(12)).optional(),
|
labels: z.array(z.string().min(12)).optional(),
|
||||||
lists: z.array(z.string().min(12)).optional(),
|
lists: z.array(z.string().min(12)).optional(),
|
||||||
dueDateFilters: z
|
|
||||||
.array(
|
|
||||||
z.enum([
|
|
||||||
"overdue",
|
|
||||||
"today",
|
|
||||||
"tomorrow",
|
|
||||||
"next-week",
|
|
||||||
"next-month",
|
|
||||||
"no-due-date",
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
||||||
@@ -194,11 +160,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert semantic string filters to date ranges expected by the repo
|
|
||||||
const dueDateFilters = input.dueDateFilters
|
|
||||||
? convertDueDateFiltersToRanges(input.dueDateFilters)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const result = await boardRepo.getBySlug(
|
const result = await boardRepo.getBySlug(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
input.boardSlug,
|
input.boardSlug,
|
||||||
@@ -207,7 +168,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
lists: input.lists ?? [],
|
lists: input.lists ?? [],
|
||||||
dueDate: dueDateFilters,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -279,7 +239,6 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: [],
|
members: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
lists: [],
|
lists: [],
|
||||||
dueDate: [],
|
|
||||||
type: sourceBoardInfo.type,
|
type: sourceBoardInfo.type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -322,6 +281,12 @@ export const boardRouter = createTRPCRouter({
|
|||||||
sourceBoardId: sourceBoardInfo.id,
|
sourceBoardId: sourceBoardInfo.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `Failed to create board from source`,
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import * as listRepo from "@kan/db/repository/list.repo";
|
|||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { mergeActivities } from "../utils/activities";
|
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
import { generateDownloadUrl } from "../utils/s3";
|
import { generateDownloadUrl } from "../utils/s3";
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
labelPublicIds: z.array(z.string().min(12)),
|
labelPublicIds: z.array(z.string().min(12)),
|
||||||
memberPublicIds: z.array(z.string().min(12)),
|
memberPublicIds: z.array(z.string().min(12)),
|
||||||
position: z.enum(["start", "end"]),
|
position: z.enum(["start", "end"]),
|
||||||
dueDate: z.date().nullable().optional(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
|
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
|
||||||
@@ -71,7 +69,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
listId: list.id,
|
listId: list.id,
|
||||||
position: input.position,
|
position: input.position,
|
||||||
dueDate: input.dueDate ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const newCardId = newCard.id;
|
const newCardId = newCard.id;
|
||||||
@@ -671,81 +668,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { ...result, attachments: [] };
|
return { ...result, attachments: [] };
|
||||||
}),
|
}),
|
||||||
getActivities: publicProcedure
|
|
||||||
.meta({
|
|
||||||
openapi: {
|
|
||||||
summary: "Get paginated card activities",
|
|
||||||
method: "GET",
|
|
||||||
path: "/cards/{cardPublicId}/activities",
|
|
||||||
description:
|
|
||||||
"Retrieves paginated activities for a card with merged frequent changes",
|
|
||||||
tags: ["Cards"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
cardPublicId: z.string().min(12),
|
|
||||||
limit: z.number().min(1).max(100).optional().default(10),
|
|
||||||
cursor: z.string().datetime().optional(), // ISO datetime string
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(
|
|
||||||
z.object({
|
|
||||||
activities: z.array(
|
|
||||||
z.custom<
|
|
||||||
NonNullable<
|
|
||||||
Awaited<
|
|
||||||
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
|
|
||||||
>
|
|
||||||
>["activities"][number]
|
|
||||||
>(),
|
|
||||||
),
|
|
||||||
hasMore: z.boolean(),
|
|
||||||
nextCursor: z.string().datetime().nullable(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
|
||||||
ctx.db,
|
|
||||||
input.cardPublicId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!card)
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (card.workspaceVisibility === "private") {
|
|
||||||
const userId = ctx.user?.id;
|
|
||||||
|
|
||||||
if (!userId)
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `User not authenticated`,
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
});
|
|
||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursor = input.cursor ? new Date(input.cursor) : undefined;
|
|
||||||
const result = await cardActivityRepo.getPaginatedActivities(
|
|
||||||
ctx.db,
|
|
||||||
card.id,
|
|
||||||
{
|
|
||||||
limit: input.limit,
|
|
||||||
cursor,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mergedActivities = mergeActivities(result.activities);
|
|
||||||
|
|
||||||
return {
|
|
||||||
activities: mergedActivities,
|
|
||||||
hasMore: result.hasMore,
|
|
||||||
nextCursor: result.nextCursor?.toISOString() ?? null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
@@ -764,7 +686,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
index: z.number().optional(),
|
index: z.number().optional(),
|
||||||
listPublicId: z.string().min(12).optional(),
|
listPublicId: z.string().min(12).optional(),
|
||||||
dueDate: z.date().nullable().optional(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
|
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
|
||||||
@@ -825,19 +746,15 @@ export const cardRouter = createTRPCRouter({
|
|||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
publicId: string;
|
publicId: string;
|
||||||
dueDate: Date | null;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const previousDueDate = existingCard.dueDate;
|
if (input.title || input.description) {
|
||||||
|
|
||||||
if (input.title || input.description || input.dueDate !== undefined) {
|
|
||||||
result = await cardRepo.update(
|
result = await cardRepo.update(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
{
|
{
|
||||||
...(input.title && { title: input.title }),
|
...(input.title && { title: input.title }),
|
||||||
...(input.description && { description: input.description }),
|
...(input.description && { description: input.description }),
|
||||||
...(input.dueDate !== undefined && { dueDate: input.dueDate }),
|
|
||||||
},
|
},
|
||||||
{ cardPublicId: input.cardPublicId },
|
{ cardPublicId: input.cardPublicId },
|
||||||
);
|
);
|
||||||
@@ -879,32 +796,6 @@ export const cardRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
input.dueDate !== undefined &&
|
|
||||||
previousDueDate?.getTime() !== input.dueDate?.getTime()
|
|
||||||
) {
|
|
||||||
let activityType:
|
|
||||||
| "card.updated.dueDate.added"
|
|
||||||
| "card.updated.dueDate.updated"
|
|
||||||
| "card.updated.dueDate.removed";
|
|
||||||
|
|
||||||
if (!previousDueDate) {
|
|
||||||
activityType = "card.updated.dueDate.added";
|
|
||||||
} else if (!input.dueDate) {
|
|
||||||
activityType = "card.updated.dueDate.removed";
|
|
||||||
} else {
|
|
||||||
activityType = "card.updated.dueDate.updated";
|
|
||||||
}
|
|
||||||
|
|
||||||
activities.push({
|
|
||||||
type: activityType,
|
|
||||||
cardId: result.id,
|
|
||||||
createdBy: userId,
|
|
||||||
fromDueDate: previousDueDate ?? undefined,
|
|
||||||
toDueDate: input.dueDate ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newListId && existingCard.listId !== newListId) {
|
if (newListId && existingCard.listId !== newListId) {
|
||||||
activities.push({
|
activities.push({
|
||||||
type: "card.updated.list" as const,
|
type: "card.updated.list" as const,
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
summary: "Update a checklist item",
|
summary: "Update a checklist item",
|
||||||
method: "PATCH",
|
method: "PUT",
|
||||||
path: "/checklists/items/{checklistItemPublicId}",
|
path: "/checklists/items/{checklistItemPublicId}",
|
||||||
description: "Updates a checklist item (title/completed)",
|
description: "Updates a checklist item (title/completed)",
|
||||||
tags: ["Cards"],
|
tags: ["Cards"],
|
||||||
@@ -281,7 +281,6 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
checklistItemPublicId: z.string().length(12),
|
checklistItemPublicId: z.string().length(12),
|
||||||
title: z.string().min(1).max(500).optional(),
|
title: z.string().min(1).max(500).optional(),
|
||||||
completed: z.boolean().optional(),
|
completed: z.boolean().optional(),
|
||||||
index: z.number().int().min(0).optional(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(checklistItemSchema)
|
.output(checklistItemSchema)
|
||||||
@@ -313,30 +312,17 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const previousTitle = item.title;
|
const previousTitle = item.title;
|
||||||
|
|
||||||
let updatedItem;
|
const updated = await checklistRepo.updateItemById(ctx.db, {
|
||||||
|
|
||||||
if (input.title !== undefined || input.completed !== undefined) {
|
|
||||||
updatedItem = await checklistRepo.updateItemById(ctx.db, {
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
completed: input.completed,
|
completed: input.completed,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (input.index !== undefined) {
|
if (!updated)
|
||||||
updatedItem = await checklistRepo.reorderItem(ctx.db, {
|
|
||||||
itemId: item.id,
|
|
||||||
newIndex: input.index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updatedItem) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
message: `Failed to update checklist item`,
|
message: `Failed to update checklist item`,
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Log completion toggle
|
// Log completion toggle
|
||||||
if (input.completed !== undefined) {
|
if (input.completed !== undefined) {
|
||||||
@@ -345,7 +331,7 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
? "card.updated.checklist.item.completed"
|
? "card.updated.checklist.item.completed"
|
||||||
: "card.updated.checklist.item.uncompleted",
|
: "card.updated.checklist.item.uncompleted",
|
||||||
cardId: item.checklist.cardId,
|
cardId: item.checklist.cardId,
|
||||||
toTitle: updatedItem.title,
|
toTitle: updated.title,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -356,13 +342,12 @@ export const checklistRouter = createTRPCRouter({
|
|||||||
type: "card.updated.checklist.item.updated",
|
type: "card.updated.checklist.item.updated",
|
||||||
cardId: item.checklist.cardId,
|
cardId: item.checklist.cardId,
|
||||||
fromTitle: previousTitle,
|
fromTitle: previousTitle,
|
||||||
toTitle: updatedItem.title,
|
toTitle: updated.title,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedItem;
|
return updated;
|
||||||
|
|
||||||
}),
|
}),
|
||||||
deleteItem: protectedProcedure
|
deleteItem: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
import { HeadBucketCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
|
||||||
import * as boardRepo from "@kan/db/repository/board.repo";
|
|
||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
|
||||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
|
||||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
|
||||||
import * as cardCommentRepo from "@kan/db/repository/cardComment.repo";
|
|
||||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
|
||||||
import * as importRepo from "@kan/db/repository/import.repo";
|
|
||||||
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
|
||||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
|
||||||
import * as listRepo from "@kan/db/repository/list.repo";
|
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|
||||||
|
|
||||||
import {
|
|
||||||
adminProtectedProcedure,
|
|
||||||
createTRPCRouter,
|
|
||||||
publicProcedure,
|
|
||||||
} from "../trpc";
|
|
||||||
import { createS3Client } from "../utils/s3";
|
|
||||||
|
|
||||||
const checkDatabaseConnection = async (db: dbClient) => {
|
|
||||||
try {
|
|
||||||
await db.execute(sql`SELECT 1`);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkS3Connection = async () => {
|
|
||||||
try {
|
|
||||||
// Check if S3 is configured
|
|
||||||
if (
|
|
||||||
!process.env.S3_ENDPOINT ||
|
|
||||||
!process.env.S3_ACCESS_KEY_ID ||
|
|
||||||
!process.env.S3_SECRET_ACCESS_KEY
|
|
||||||
) {
|
|
||||||
// S3 is optional, so return true if not configured
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = createS3Client();
|
|
||||||
const avatarBucketName = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
|
||||||
const attachmentsBucketName = env("NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME");
|
|
||||||
|
|
||||||
await client.send(new HeadBucketCommand({ Bucket: avatarBucketName }));
|
|
||||||
await client.send(new HeadBucketCommand({ Bucket: attachmentsBucketName }));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const healthRouter = createTRPCRouter({
|
|
||||||
health: publicProcedure
|
|
||||||
.meta({
|
|
||||||
openapi: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/health",
|
|
||||||
summary: "Health check",
|
|
||||||
description:
|
|
||||||
"Returns the health status of the application and its dependencies",
|
|
||||||
tags: ["Health"],
|
|
||||||
protect: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(z.void())
|
|
||||||
.output(
|
|
||||||
z.object({
|
|
||||||
status: z.enum(["ok", "error"]),
|
|
||||||
database: z.enum(["ok", "error"]).optional(),
|
|
||||||
storage: z.enum(["ok", "error", "not_configured"]).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
const dbHealthy = await checkDatabaseConnection(ctx.db);
|
|
||||||
const s3Healthy = await checkS3Connection();
|
|
||||||
const s3Configured = !!(
|
|
||||||
process.env.S3_ENDPOINT &&
|
|
||||||
process.env.S3_ACCESS_KEY_ID &&
|
|
||||||
process.env.S3_SECRET_ACCESS_KEY
|
|
||||||
);
|
|
||||||
|
|
||||||
const database = dbHealthy ? "ok" : "error";
|
|
||||||
const storage = !s3Configured
|
|
||||||
? "not_configured"
|
|
||||||
: s3Healthy
|
|
||||||
? "ok"
|
|
||||||
: "error";
|
|
||||||
|
|
||||||
// Overall status is "ok" only if database is healthy and (storage is not configured or storage is healthy)
|
|
||||||
const status = dbHealthy && (!s3Configured || s3Healthy) ? "ok" : "error";
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
database,
|
|
||||||
storage,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
stats: adminProtectedProcedure
|
|
||||||
.meta({
|
|
||||||
openapi: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/stats",
|
|
||||||
summary: "Get statistics",
|
|
||||||
description:
|
|
||||||
"Returns statistics about the application (workspaces, users, cards, etc.)",
|
|
||||||
tags: ["Health"],
|
|
||||||
protect: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(z.void())
|
|
||||||
.output(
|
|
||||||
z.object({
|
|
||||||
users: z.number(),
|
|
||||||
workspaces: z.number(),
|
|
||||||
boards: z.number(),
|
|
||||||
lists: z.number(),
|
|
||||||
cards: z.number(),
|
|
||||||
cardComments: z.number(),
|
|
||||||
cardAttachments: z.number(),
|
|
||||||
cardActivityLogs: z.number(),
|
|
||||||
labels: z.number(),
|
|
||||||
checklists: z.number(),
|
|
||||||
checklistItems: z.number(),
|
|
||||||
activeMembers: z.number(),
|
|
||||||
activeInviteLinks: z.number(),
|
|
||||||
imports: z.number(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
try {
|
|
||||||
// Execute all count queries in parallel
|
|
||||||
const [
|
|
||||||
usersCount,
|
|
||||||
workspacesCount,
|
|
||||||
boardsCount,
|
|
||||||
listsCount,
|
|
||||||
cardsCount,
|
|
||||||
cardCommentsCount,
|
|
||||||
checklistItemsCount,
|
|
||||||
checklistsCount,
|
|
||||||
labelsCount,
|
|
||||||
cardAttachmentsCount,
|
|
||||||
cardActivitiesCount,
|
|
||||||
activeMembersCount,
|
|
||||||
activeInviteLinksCount,
|
|
||||||
importsCount,
|
|
||||||
cardActivityLogsCount,
|
|
||||||
] = await Promise.all([
|
|
||||||
userRepo.getCount(ctx.db),
|
|
||||||
workspaceRepo.getCount(ctx.db),
|
|
||||||
boardRepo.getCount(ctx.db),
|
|
||||||
listRepo.getCount(ctx.db),
|
|
||||||
cardRepo.getCount(ctx.db),
|
|
||||||
cardCommentRepo.getCount(ctx.db),
|
|
||||||
checklistRepo.getCountItems(ctx.db),
|
|
||||||
checklistRepo.getCount(ctx.db),
|
|
||||||
labelRepo.getCount(ctx.db),
|
|
||||||
cardAttachmentRepo.getCount(ctx.db),
|
|
||||||
cardActivityRepo.getCount(ctx.db),
|
|
||||||
memberRepo.getActiveCount(ctx.db),
|
|
||||||
inviteLinkRepo.getActiveCount(ctx.db),
|
|
||||||
importRepo.getCount(ctx.db),
|
|
||||||
memberRepo.getActiveCount(ctx.db),
|
|
||||||
cardActivityRepo.getCount(ctx.db),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: usersCount,
|
|
||||||
workspaces: workspacesCount,
|
|
||||||
boards: boardsCount,
|
|
||||||
lists: listsCount,
|
|
||||||
cards: cardsCount,
|
|
||||||
cardComments: cardCommentsCount,
|
|
||||||
checklistItems: checklistItemsCount,
|
|
||||||
checklists: checklistsCount,
|
|
||||||
labels: labelsCount,
|
|
||||||
cardAttachments: cardAttachmentsCount,
|
|
||||||
cardActivities: cardActivitiesCount,
|
|
||||||
activeMembers: activeMembersCount,
|
|
||||||
activeInviteLinks: activeInviteLinksCount,
|
|
||||||
imports: importsCount,
|
|
||||||
cardActivityLogs: cardActivityLogsCount,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new TRPCError({
|
|
||||||
message: `Failed to retrieve statistics: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,6 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import type { OpenApiMeta } from "trpc-to-openapi";
|
import type { OpenApiMeta } from "trpc-to-openapi";
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
@@ -46,7 +45,6 @@ interface CreateContextOptions {
|
|||||||
user: User | null | undefined;
|
user: User | null | undefined;
|
||||||
db: dbClient;
|
db: dbClient;
|
||||||
auth: ReturnType<typeof createAuthWithHeaders>;
|
auth: ReturnType<typeof createAuthWithHeaders>;
|
||||||
headers: Headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||||
@@ -54,7 +52,6 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
|||||||
user: opts.user,
|
user: opts.user,
|
||||||
db: opts.db,
|
db: opts.db,
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
headers: opts.headers,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,7 +63,7 @@ export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
|
|||||||
|
|
||||||
const session = await auth.api.getSession();
|
const session = await auth.api.getSession();
|
||||||
|
|
||||||
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
|
return createInnerTRPCContext({ db, user: session?.user, auth });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createNextApiContext = async (req: NextApiRequest) => {
|
export const createNextApiContext = async (req: NextApiRequest) => {
|
||||||
@@ -77,7 +74,7 @@ export const createNextApiContext = async (req: NextApiRequest) => {
|
|||||||
|
|
||||||
const session = await auth.api.getSession();
|
const session = await auth.api.getSession();
|
||||||
|
|
||||||
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
|
return createInnerTRPCContext({ db, user: session?.user, auth });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||||
@@ -94,7 +91,7 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
|
return createInnerTRPCContext({ db, user: session?.user, auth });
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = initTRPC
|
const t = initTRPC
|
||||||
@@ -132,28 +129,9 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
|
|
||||||
if (ctx.headers.get("x-admin-api-key") !== env("KAN_ADMIN_API_KEY")) {
|
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return next({
|
|
||||||
ctx,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
|
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/protected",
|
path: "/protected",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const adminProtectedProcedure = t.procedure
|
|
||||||
.use(enforceUserIsAdmin)
|
|
||||||
.meta({
|
|
||||||
openapi: {
|
|
||||||
method: "GET",
|
|
||||||
path: "/admin/protected",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { RouterInputs, RouterOutputs } from "../index";
|
|||||||
|
|
||||||
export type GetBoardByIdOutput = RouterOutputs["board"]["byId"];
|
export type GetBoardByIdOutput = RouterOutputs["board"]["byId"];
|
||||||
export type GetCardByIdOutput = RouterOutputs["card"]["byId"];
|
export type GetCardByIdOutput = RouterOutputs["card"]["byId"];
|
||||||
export type GetCardActivitiesOutput = RouterOutputs["card"]["getActivities"];
|
|
||||||
export type UpdateBoardInput = RouterInputs["board"]["update"];
|
export type UpdateBoardInput = RouterInputs["board"]["update"];
|
||||||
export type NewLabelInput = RouterInputs["label"]["create"];
|
export type NewLabelInput = RouterInputs["label"]["create"];
|
||||||
export type NewListInput = RouterInputs["list"]["create"];
|
export type NewListInput = RouterInputs["list"]["create"];
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import type { PaginatedActivitiesResult } from "@kan/db/repository/cardActivity.repo";
|
|
||||||
import type { ActivityType } from "@kan/db/schema";
|
|
||||||
|
|
||||||
type ActivityBase = PaginatedActivitiesResult["activities"][number];
|
|
||||||
|
|
||||||
interface Activity extends ActivityBase {
|
|
||||||
mergeCount?: number; // number of activities merged into this one
|
|
||||||
mergedLabels?: string[]; // list of label names when merging label activities
|
|
||||||
}
|
|
||||||
|
|
||||||
// types that can be merged with simple count
|
|
||||||
const MERGEABLE_COUNT_TYPES: readonly ActivityType[] = [
|
|
||||||
"card.updated.description",
|
|
||||||
];
|
|
||||||
|
|
||||||
// types that merge with a list of items
|
|
||||||
const MERGEABLE_LIST_TYPES: readonly ActivityType[] = [
|
|
||||||
"card.updated.label.added",
|
|
||||||
"card.updated.label.removed",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MERGE_TIME_WINDOW_MS = 5 * 60 * 1000; // 5 minutes window for merging activities
|
|
||||||
|
|
||||||
export function mergeActivities(activities: Activity[]): Activity[] {
|
|
||||||
if (activities.length === 0) return [];
|
|
||||||
|
|
||||||
const merged: Activity[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < activities.length) {
|
|
||||||
const current = activities[i];
|
|
||||||
if (!current) {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentType = current.type;
|
|
||||||
|
|
||||||
const isCountMergeable = MERGEABLE_COUNT_TYPES.includes(currentType);
|
|
||||||
const isListMergeable = MERGEABLE_LIST_TYPES.includes(currentType);
|
|
||||||
const canMerge = (isCountMergeable || isListMergeable) && current.user?.id;
|
|
||||||
|
|
||||||
if (!canMerge) {
|
|
||||||
merged.push(current);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const group: Activity[] = [current];
|
|
||||||
let j = i + 1;
|
|
||||||
|
|
||||||
while (j < activities.length) {
|
|
||||||
const next = activities[j];
|
|
||||||
if (!next) break;
|
|
||||||
|
|
||||||
const timeDiff =
|
|
||||||
new Date(next.createdAt).getTime() -
|
|
||||||
new Date(current.createdAt).getTime();
|
|
||||||
|
|
||||||
if (
|
|
||||||
next.type === current.type &&
|
|
||||||
next.user?.id === current.user?.id &&
|
|
||||||
timeDiff >= 0 && // next is newer or same time
|
|
||||||
timeDiff <= MERGE_TIME_WINDOW_MS
|
|
||||||
) {
|
|
||||||
group.push(next);
|
|
||||||
j++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (group.length > 1) {
|
|
||||||
const oldest = group[0];
|
|
||||||
const latest = group[group.length - 1];
|
|
||||||
|
|
||||||
if (oldest && latest) {
|
|
||||||
if (isListMergeable) {
|
|
||||||
const labelNames = group
|
|
||||||
.map((a) => a.label?.name)
|
|
||||||
.filter((name): name is string => !!name);
|
|
||||||
|
|
||||||
merged.push({
|
|
||||||
...oldest,
|
|
||||||
createdAt: oldest.createdAt,
|
|
||||||
mergeCount: group.length,
|
|
||||||
mergedLabels: labelNames,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
merged.push({
|
|
||||||
...oldest,
|
|
||||||
createdAt: oldest.createdAt,
|
|
||||||
toDescription: latest.toDescription,
|
|
||||||
fromDescription: oldest.fromDescription,
|
|
||||||
mergeCount: group.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
merged.push(current);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
merged.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
DeleteObjectCommand,
|
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
@@ -7,19 +6,14 @@ import {
|
|||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
|
||||||
export function createS3Client() {
|
export function createS3Client() {
|
||||||
const credentials =
|
|
||||||
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
|
|
||||||
? {
|
|
||||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return new S3Client({
|
return new S3Client({
|
||||||
region: process.env.S3_REGION ?? "",
|
region: process.env.S3_REGION ?? "",
|
||||||
endpoint: process.env.S3_ENDPOINT ?? "",
|
endpoint: process.env.S3_ENDPOINT ?? "",
|
||||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||||
credentials,
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
|
||||||
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +51,3 @@ export async function generateDownloadUrl(
|
|||||||
{ expiresIn },
|
{ expiresIn },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteObject(bucket: string, key: string) {
|
|
||||||
const client = createS3Client();
|
|
||||||
await client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: key,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config",
|
"prettier": "@kan/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/stripe": "^1.4.6",
|
"@better-auth/stripe": "^1.3.7",
|
||||||
"better-auth": "^1.4.6"
|
"better-auth": "^1.3.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ export const configuredProviders = socialProviderList.reduce<
|
|||||||
requireSelectAccount?: boolean;
|
requireSelectAccount?: boolean;
|
||||||
clientKey?: string;
|
clientKey?: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
// Google-specific optional hints
|
|
||||||
hostedDomain?: string;
|
|
||||||
hd?: string;
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
>((acc, provider) => {
|
>((acc, provider) => {
|
||||||
@@ -72,21 +69,6 @@ export const configuredProviders = socialProviderList.reduce<
|
|||||||
acc[provider].tenantId = "common";
|
acc[provider].tenantId = "common";
|
||||||
acc[provider].requireSelectAccount = true;
|
acc[provider].requireSelectAccount = true;
|
||||||
}
|
}
|
||||||
// Add Google domain hint if allowed domains is configured
|
|
||||||
if (
|
|
||||||
provider === "google" &&
|
|
||||||
Object.keys(acc).includes("google") &&
|
|
||||||
acc[provider]
|
|
||||||
) {
|
|
||||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
|
||||||
.map((d) => d.trim().toLowerCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (allowed && allowed.length > 0) {
|
|
||||||
// Use the first domain as an authorization hint
|
|
||||||
acc[provider].hostedDomain = allowed[0];
|
|
||||||
acc[provider].hd = allowed[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
provider === "tiktok" &&
|
provider === "tiktok" &&
|
||||||
Object.keys(acc).includes("tiktok") &&
|
Object.keys(acc).includes("tiktok") &&
|
||||||
@@ -298,8 +280,9 @@ export const initAuth = (db: dbClient) => {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||||
apiKey({
|
apiKey({
|
||||||
enableSessionForAPIKeys: true,
|
disableKeyHashing: true,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
timeWindow: 1000 * 60, // 1 minute
|
timeWindow: 1000 * 60, // 1 minute
|
||||||
@@ -360,17 +343,7 @@ export const initAuth = (db: dbClient) => {
|
|||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall through to any additional checks below
|
return Promise.resolve(true);
|
||||||
}
|
|
||||||
// Enforce allowed domains (OIDC/social) if configured
|
|
||||||
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
|
|
||||||
.map((d) => d.trim().toLowerCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (allowed && allowed.length > 0) {
|
|
||||||
const domain = user.email.split("@")[1]?.toLowerCase();
|
|
||||||
if (!domain || !allowed.includes(domain)) {
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
},
|
},
|
||||||
@@ -381,19 +354,14 @@ export const initAuth = (db: dbClient) => {
|
|||||||
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
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({
|
const client = new S3Client({
|
||||||
region: env("S3_REGION") ?? "",
|
region: env("S3_REGION") ?? "",
|
||||||
endpoint: env("S3_ENDPOINT") ?? "",
|
endpoint: env("S3_ENDPOINT") ?? "",
|
||||||
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true",
|
||||||
credentials,
|
credentials: {
|
||||||
|
accessKeyId: env("S3_ACCESS_KEY_ID") ?? "",
|
||||||
|
secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.dueDate.added' BEFORE 'card.archived';--> statement-breakpoint
|
|
||||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.dueDate.updated' BEFORE 'card.archived';--> statement-breakpoint
|
|
||||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.dueDate.removed' BEFORE 'card.archived';--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_activity" ADD COLUMN "fromDueDate" timestamp;--> statement-breakpoint
|
|
||||||
ALTER TABLE "card_activity" ADD COLUMN "toDueDate" timestamp;--> statement-breakpoint
|
|
||||||
ALTER TABLE "card" ADD COLUMN "dueDate" timestamp;
|
|
||||||
@@ -141,13 +141,6 @@
|
|||||||
"when": 1762807463569,
|
"when": 1762807463569,
|
||||||
"tag": "20251110204423_AddCardAttachments",
|
"tag": "20251110204423_AddCardAttachments",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 20,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1764621815672,
|
|
||||||
"tag": "20251201204335_AddCardDueDates",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,4 @@
|
|||||||
import {
|
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
and,
|
|
||||||
asc,
|
|
||||||
count,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
gte,
|
|
||||||
inArray,
|
|
||||||
isNotNull,
|
|
||||||
isNull,
|
|
||||||
lt,
|
|
||||||
or,
|
|
||||||
} from "drizzle-orm";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||||
@@ -30,15 +18,6 @@ import {
|
|||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(boards)
|
|
||||||
.where(isNull(boards.deletedAt));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllByWorkspaceId = (
|
export const getAllByWorkspaceId = (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
@@ -86,39 +65,6 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
|||||||
return board;
|
return board;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DueDateFilter {
|
|
||||||
startDate?: Date;
|
|
||||||
endDate?: Date;
|
|
||||||
hasNoDueDate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildDueDateWhere = (filters: DueDateFilter[]) => {
|
|
||||||
if (!filters.length) return undefined;
|
|
||||||
|
|
||||||
const clauses = filters
|
|
||||||
.map((filter) => {
|
|
||||||
const conditions: ReturnType<typeof and>[] = [];
|
|
||||||
|
|
||||||
if (filter.hasNoDueDate) {
|
|
||||||
conditions.push(isNull(cards.dueDate));
|
|
||||||
} else {
|
|
||||||
conditions.push(isNotNull(cards.dueDate));
|
|
||||||
|
|
||||||
if (filter.startDate)
|
|
||||||
conditions.push(gte(cards.dueDate, filter.startDate));
|
|
||||||
|
|
||||||
if (filter.endDate) conditions.push(lt(cards.dueDate, filter.endDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
})
|
|
||||||
.filter((clause): clause is NonNullable<typeof clause> => !!clause);
|
|
||||||
|
|
||||||
if (!clauses.length) return undefined;
|
|
||||||
|
|
||||||
return or(...clauses);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getByPublicId = async (
|
export const getByPublicId = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
boardPublicId: string,
|
boardPublicId: string,
|
||||||
@@ -126,7 +72,6 @@ export const getByPublicId = async (
|
|||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
lists: string[];
|
lists: string[];
|
||||||
dueDate: DueDateFilter[];
|
|
||||||
type: "regular" | "template" | undefined;
|
type: "regular" | "template" | undefined;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -219,7 +164,6 @@ export const getByPublicId = async (
|
|||||||
description: true,
|
description: true,
|
||||||
listId: true,
|
listId: true,
|
||||||
index: true,
|
index: true,
|
||||||
dueDate: true,
|
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
@@ -292,7 +236,6 @@ export const getByPublicId = async (
|
|||||||
where: and(
|
where: and(
|
||||||
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
||||||
isNull(cards.deletedAt),
|
isNull(cards.deletedAt),
|
||||||
buildDueDateWhere(filters.dueDate),
|
|
||||||
),
|
),
|
||||||
orderBy: [asc(cards.index)],
|
orderBy: [asc(cards.index)],
|
||||||
},
|
},
|
||||||
@@ -348,7 +291,6 @@ export const getBySlug = async (
|
|||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
lists: string[];
|
lists: string[];
|
||||||
dueDate: DueDateFilter[];
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
let cardIds: string[] = [];
|
let cardIds: string[] = [];
|
||||||
@@ -411,7 +353,6 @@ export const getBySlug = async (
|
|||||||
description: true,
|
description: true,
|
||||||
listId: true,
|
listId: true,
|
||||||
index: true,
|
index: true,
|
||||||
dueDate: true,
|
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
@@ -464,7 +405,6 @@ export const getBySlug = async (
|
|||||||
where: and(
|
where: and(
|
||||||
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
||||||
isNull(cards.deletedAt),
|
isNull(cards.deletedAt),
|
||||||
buildDueDateWhere(filters.dueDate),
|
|
||||||
),
|
),
|
||||||
orderBy: [asc(cards.index)],
|
orderBy: [asc(cards.index)],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||||
and,
|
|
||||||
asc,
|
|
||||||
count,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
gt,
|
|
||||||
inArray,
|
|
||||||
isNull,
|
|
||||||
sql,
|
|
||||||
} from "drizzle-orm";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import {
|
import {
|
||||||
@@ -25,15 +15,6 @@ import {
|
|||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(cards)
|
|
||||||
.where(isNull(cards.deletedAt));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
cardInput: {
|
cardInput: {
|
||||||
@@ -42,7 +23,6 @@ export const create = async (
|
|||||||
createdBy: string;
|
createdBy: string;
|
||||||
listId: number;
|
listId: number;
|
||||||
position: "start" | "end";
|
position: "start" | "end";
|
||||||
dueDate?: Date | null;
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
@@ -91,7 +71,6 @@ export const create = async (
|
|||||||
createdBy: cardInput.createdBy,
|
createdBy: cardInput.createdBy,
|
||||||
listId: cardInput.listId,
|
listId: cardInput.listId,
|
||||||
index: index,
|
index: index,
|
||||||
dueDate: cardInput.dueDate ?? null,
|
|
||||||
})
|
})
|
||||||
.returning({ id: cards.id, listId: cards.listId });
|
.returning({ id: cards.id, listId: cards.listId });
|
||||||
|
|
||||||
@@ -184,7 +163,6 @@ export const update = async (
|
|||||||
cardInput: {
|
cardInput: {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
dueDate?: Date | null;
|
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -195,8 +173,6 @@ export const update = async (
|
|||||||
.set({
|
.set({
|
||||||
title: cardInput.title,
|
title: cardInput.title,
|
||||||
description: cardInput.description,
|
description: cardInput.description,
|
||||||
dueDate: cardInput.dueDate !== undefined ? cardInput.dueDate : undefined,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.where(and(eq(cards.publicId, args.cardPublicId), isNull(cards.deletedAt)))
|
.where(and(eq(cards.publicId, args.cardPublicId), isNull(cards.deletedAt)))
|
||||||
.returning({
|
.returning({
|
||||||
@@ -204,7 +180,6 @@ export const update = async (
|
|||||||
publicId: cards.publicId,
|
publicId: cards.publicId,
|
||||||
title: cards.title,
|
title: cards.title,
|
||||||
description: cards.description,
|
description: cards.description,
|
||||||
dueDate: cards.dueDate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -239,7 +214,6 @@ export const getByPublicId = (db: dbClient, cardPublicId: string) => {
|
|||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
listId: true,
|
listId: true,
|
||||||
dueDate: true,
|
|
||||||
},
|
},
|
||||||
where: eq(cards.publicId, cardPublicId),
|
where: eq(cards.publicId, cardPublicId),
|
||||||
});
|
});
|
||||||
@@ -423,7 +397,6 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
publicId: true,
|
publicId: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
dueDate: true,
|
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
labels: {
|
labels: {
|
||||||
@@ -558,8 +531,6 @@ export const getWithListAndMembersByPublicId = async (
|
|||||||
toTitle: true,
|
toTitle: true,
|
||||||
fromDescription: true,
|
fromDescription: true,
|
||||||
toDescription: true,
|
toDescription: true,
|
||||||
fromDueDate: true,
|
|
||||||
toDueDate: true,
|
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
fromList: {
|
fromList: {
|
||||||
@@ -809,7 +780,6 @@ export const reorder = async (
|
|||||||
publicId: true,
|
publicId: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
dueDate: true,
|
|
||||||
},
|
},
|
||||||
where: eq(cards.id, card.id),
|
where: eq(cards.id, card.id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { and, asc, count, eq, gt, inArray, isNull, or } from "drizzle-orm";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import type { ActivityType } from "@kan/db/schema";
|
import type { ActivityType } from "@kan/db/schema";
|
||||||
import { cardActivities, comments } from "@kan/db/schema";
|
import { cardActivities } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db.select({ count: count() }).from(cardActivities);
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
activityInput: {
|
activityInput: {
|
||||||
@@ -30,8 +22,6 @@ export const create = async (
|
|||||||
commentId?: number;
|
commentId?: number;
|
||||||
fromComment?: string;
|
fromComment?: string;
|
||||||
toComment?: string;
|
toComment?: string;
|
||||||
fromDueDate?: Date;
|
|
||||||
toDueDate?: Date;
|
|
||||||
sourceBoardId?: number;
|
sourceBoardId?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -55,8 +45,6 @@ export const create = async (
|
|||||||
commentId: activityInput.commentId,
|
commentId: activityInput.commentId,
|
||||||
fromComment: activityInput.fromComment,
|
fromComment: activityInput.fromComment,
|
||||||
toComment: activityInput.toComment,
|
toComment: activityInput.toComment,
|
||||||
fromDueDate: activityInput.fromDueDate,
|
|
||||||
toDueDate: activityInput.toDueDate,
|
|
||||||
sourceBoardId: activityInput.sourceBoardId,
|
sourceBoardId: activityInput.sourceBoardId,
|
||||||
})
|
})
|
||||||
.returning({ id: cardActivities.id });
|
.returning({ id: cardActivities.id });
|
||||||
@@ -80,8 +68,6 @@ export const bulkCreate = async (
|
|||||||
fromDescription?: string;
|
fromDescription?: string;
|
||||||
toDescription?: string;
|
toDescription?: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
fromDueDate?: Date;
|
|
||||||
toDueDate?: Date;
|
|
||||||
sourceBoardId?: number;
|
sourceBoardId?: number;
|
||||||
}[],
|
}[],
|
||||||
) => {
|
) => {
|
||||||
@@ -97,114 +83,3 @@ export const bulkCreate = async (
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPaginatedActivities = async (
|
|
||||||
db: dbClient,
|
|
||||||
cardId: number,
|
|
||||||
options?: {
|
|
||||||
limit?: number;
|
|
||||||
cursor?: Date; // createdAt cursor for pagination
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const limit = options?.limit ?? 20;
|
|
||||||
const cursor = options?.cursor;
|
|
||||||
|
|
||||||
const validComments = await db
|
|
||||||
.select({ id: comments.id })
|
|
||||||
.from(comments)
|
|
||||||
.where(and(eq(comments.cardId, cardId), isNull(comments.deletedAt)));
|
|
||||||
|
|
||||||
const validCommentIds = validComments.map((comment) => comment.id);
|
|
||||||
|
|
||||||
const activities = await db.query.cardActivities.findMany({
|
|
||||||
columns: {
|
|
||||||
publicId: true,
|
|
||||||
type: true,
|
|
||||||
createdAt: true,
|
|
||||||
fromIndex: true,
|
|
||||||
toIndex: true,
|
|
||||||
fromTitle: true,
|
|
||||||
toTitle: true,
|
|
||||||
fromDescription: true,
|
|
||||||
toDescription: true,
|
|
||||||
fromDueDate: true,
|
|
||||||
toDueDate: true,
|
|
||||||
},
|
|
||||||
where: and(
|
|
||||||
eq(cardActivities.cardId, cardId),
|
|
||||||
cursor ? gt(cardActivities.createdAt, cursor) : undefined,
|
|
||||||
or(
|
|
||||||
isNull(cardActivities.commentId),
|
|
||||||
inArray(cardActivities.commentId, validCommentIds),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
with: {
|
|
||||||
fromList: {
|
|
||||||
columns: {
|
|
||||||
publicId: true,
|
|
||||||
name: true,
|
|
||||||
index: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toList: {
|
|
||||||
columns: {
|
|
||||||
publicId: true,
|
|
||||||
name: true,
|
|
||||||
index: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
columns: {
|
|
||||||
publicId: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
member: {
|
|
||||||
columns: {
|
|
||||||
publicId: true,
|
|
||||||
},
|
|
||||||
with: {
|
|
||||||
user: {
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
comment: {
|
|
||||||
columns: {
|
|
||||||
publicId: true,
|
|
||||||
comment: true,
|
|
||||||
createdBy: true,
|
|
||||||
updatedAt: true,
|
|
||||||
deletedAt: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: asc(cardActivities.createdAt), // required for merging and pagination
|
|
||||||
limit: limit + 1, // fetch one extra to check if there are more
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasMore = activities.length > limit;
|
|
||||||
const items = activities.slice(0, limit);
|
|
||||||
const nextCursor = hasMore ? items[items.length - 1]?.createdAt : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
activities: items,
|
|
||||||
hasMore,
|
|
||||||
nextCursor,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PaginatedActivitiesResult = Awaited<
|
|
||||||
ReturnType<typeof getPaginatedActivities>
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { and, count, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { cardAttachments } from "@kan/db/schema";
|
import { cardAttachments } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(cardAttachments)
|
|
||||||
.where(isNull(cardAttachments.deletedAt));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
attachmentInput: {
|
attachmentInput: {
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { count, eq, isNull } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { comments } from "@kan/db/schema";
|
import { comments } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(comments)
|
|
||||||
.where(isNull(comments.deletedAt));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
commentInput: {
|
commentInput: {
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import { and, count, desc, eq, isNull, sql } from "drizzle-orm";
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { checklistItems, checklists } from "@kan/db/schema";
|
import { checklistItems, checklists } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(checklists)
|
|
||||||
.where(isNull(checklists.deletedAt));
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCountItems = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(checklistItems)
|
|
||||||
.where(isNull(checklistItems.deletedAt));
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
checklistInput: {
|
checklistInput: {
|
||||||
@@ -357,88 +341,3 @@ const groupByKey = <T extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
return grouped;
|
return grouped;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const reorderItem = async (
|
|
||||||
db: dbClient,
|
|
||||||
args: {
|
|
||||||
itemId: number;
|
|
||||||
newIndex: number;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
return db.transaction(async (tx) => {
|
|
||||||
const item = await tx.query.checklistItems.findFirst({
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
index: true,
|
|
||||||
checklistId: true,
|
|
||||||
},
|
|
||||||
where: and(
|
|
||||||
eq(checklistItems.id, args.itemId),
|
|
||||||
isNull(checklistItems.deletedAt),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
throw new Error(`Checklist item not found for ID ${args.itemId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIndex = item.index;
|
|
||||||
const newIndex = args.newIndex;
|
|
||||||
|
|
||||||
if (currentIndex === newIndex) {
|
|
||||||
const unchanged = await tx.query.checklistItems.findFirst({
|
|
||||||
columns: {
|
|
||||||
publicId: true,
|
|
||||||
title: true,
|
|
||||||
completed: true,
|
|
||||||
},
|
|
||||||
where: and(
|
|
||||||
eq(checklistItems.id, args.itemId),
|
|
||||||
isNull(checklistItems.deletedAt),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!unchanged) {
|
|
||||||
throw new Error(`Checklist item not found for ID ${args.itemId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return unchanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentIndex < newIndex) {
|
|
||||||
await tx.execute(sql`
|
|
||||||
UPDATE card_checklist_item
|
|
||||||
SET index = index - 1
|
|
||||||
WHERE "checklistId" = ${item.checklistId}
|
|
||||||
AND index > ${currentIndex}
|
|
||||||
AND index <= ${newIndex}
|
|
||||||
AND "deletedAt" IS NULL
|
|
||||||
`);
|
|
||||||
} else {
|
|
||||||
await tx.execute(sql`
|
|
||||||
UPDATE card_checklist_item
|
|
||||||
SET index = index + 1
|
|
||||||
WHERE "checklistId" = ${item.checklistId}
|
|
||||||
AND index >= ${newIndex}
|
|
||||||
AND index < ${currentIndex}
|
|
||||||
AND "deletedAt" IS NULL
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await tx
|
|
||||||
.update(checklistItems)
|
|
||||||
.set({ index: newIndex })
|
|
||||||
.where(eq(checklistItems.id, args.itemId))
|
|
||||||
.returning({
|
|
||||||
publicId: checklistItems.publicId,
|
|
||||||
title: checklistItems.title,
|
|
||||||
completed: checklistItems.completed,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new Error(`Failed to update checklist item with ID ${args.itemId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { count, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { imports } from "@kan/db/schema";
|
import { imports } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db.select({ count: count() }).from(imports);
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
importInput: { source: string; createdBy: string },
|
importInput: { source: string; createdBy: string },
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { and, count, eq } from "drizzle-orm";
|
import { and, eq, gt } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { workspaceInviteLinks } from "@kan/db/schema";
|
import { workspaceInviteLinks } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getActiveCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(workspaceInviteLinks)
|
|
||||||
.where(eq(workspaceInviteLinks.status, "active"));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createInviteLink = async (
|
export const createInviteLink = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { and, count, eq, inArray, isNull } from "drizzle-orm";
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { cardsToLabels, labels } from "@kan/db/schema";
|
import { cardsToLabels, labels } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(labels)
|
|
||||||
.where(isNull(labels.deletedAt));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
labelInput: {
|
labelInput: {
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { and, count, desc, eq, gt, isNull, sql } from "drizzle-orm";
|
import { and, desc, eq, gt, isNull, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { lists } from "@kan/db/schema";
|
import { lists } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(lists)
|
|
||||||
.where(isNull(lists.deletedAt));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
listInput: {
|
listInput: {
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
import { and, count, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import type { MemberRole, MemberStatus } from "@kan/db/schema";
|
import type { MemberRole, MemberStatus } from "@kan/db/schema";
|
||||||
import { workspaceMembers } from "@kan/db/schema";
|
import { workspaceMembers } from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getActiveCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(workspaceMembers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNull(workspaceMembers.deletedAt),
|
|
||||||
eq(workspaceMembers.status, "active"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
memberInput: {
|
memberInput: {
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { apikey, users } from "@kan/db/schema";
|
import { apikey, users } from "@kan/db/schema";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db.select({ count: count() }).from(users);
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getById = async (db: dbClient, userId: string) => {
|
export const getById = async (db: dbClient, userId: string) => {
|
||||||
return await db.query.users.findFirst({
|
return await db.query.users.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { and, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
and,
|
|
||||||
count,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
ilike,
|
|
||||||
inArray,
|
|
||||||
isNull,
|
|
||||||
or,
|
|
||||||
sql,
|
|
||||||
} from "drizzle-orm";
|
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import {
|
import {
|
||||||
@@ -20,15 +10,6 @@ import {
|
|||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
|
||||||
const result = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(workspaces)
|
|
||||||
.where(isNull(workspaces.deletedAt));
|
|
||||||
|
|
||||||
return result[0]?.count ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const create = async (
|
export const create = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
workspaceInput: {
|
workspaceInput: {
|
||||||
|
|||||||