Compare commits

..

33 Commits

Author SHA1 Message Date
Henry
ea92dcd2ed feat: add basic white labelling (#146) 2025-08-15 15:20:07 +01:00
Henry
d51600aba8 refactor: improve card layout and scrollbar styling (#143) 2025-08-14 22:10:45 +01:00
Henry
c3ed2e5e7f fix: missing translation for editor placeholder (#142) 2025-08-14 21:36:43 +01:00
Henry
13b10471ec feat: card checklists (#141)
* feat: setup checklist schema

* feat: scaffold checklist router

* feat: add create new checklist modal

* feat: create new checklist item

* feat: toggle checklist item completed state

* feat: update checklist name

* feat: delete checklist

* feat: show checklists progress on cards

* feat: tweak light mode styling

* feat: focus item form on creation of new checklist

* feat: tweak new checklist item form styling

* feat: add checklist activity

* feat: show checklist progress on public board page

* feat: display checklist items on public card modal

* chore: add translations
2025-08-14 19:59:36 +01:00
Cyber
80d1c5f805 fix: check if POSTGRES_URL env variable is set before starting the migration script (if external db) (#139) 2025-08-14 19:58:26 +01:00
Ridham Khandar
8079345546 feat: tagging members in description (#140)
* feat: tagging members in description

* chore: tiptap mention package

* feat: avatar before names in members dropdown
2025-08-14 19:57:35 +01:00
Ridham Khandar
4a67afdd3a fix: add new created label to selected label (#136) 2025-08-09 20:35:29 +01:00
Cyber
d555546782 feat: add pglite support (#138) 2025-08-09 20:32:13 +01:00
Henry
e53ec868a5 fix: only use cloud mailer client if creds exist 2025-08-04 11:29:17 +01:00
Henry
872a5fad28 feat: public board enhancements (#134)
* feat: hide dropdown on comments for public boards

* feat: fix height of modal and add scrollable content area

* feat: add view only badge to public boards

* feat: prevent card title from overlapping close button

* chore: gen translations
2025-08-01 22:03:38 +01:00
Henry
400b94a5f2 feat: enhance member invite error handling and add invite more toggle (#133) 2025-07-30 22:24:05 +01:00
Henry
638b8b6b79 fix: update public board lookup to filter by workspace slug and show not found message (#131)
* fix: update public board lookup to filter by workspace slug

* feat: show not found message for public boards

* chore: update lang
2025-07-28 22:59:28 +01:00
Ridham Khandar
ca1dd92ab4 fix: disable create list and label button when input is empty (#130) 2025-07-28 21:49:24 +01:00
Ridham Khandar
f8827da73a fix: creating a new label when adding a new card resets all fields (#119)
* fix: creating a new label when adding a new card resets all fields

* fix: persist form data and modal stacking without breaking the animations
2025-07-28 21:49:13 +01:00
Henry
babaa9f1b1 feat: setup plunk for transactional/marketing emails 2025-07-28 07:09:30 +01:00
Henry
0cefd17484 fix: prevent member dropdown from being cutoff (#127) 2025-07-26 21:34:05 +01:00
Henry
974a5ff0f3 fix: update board slug availability check to be unique to workspace (#126) 2025-07-26 21:18:26 +01:00
Henry
0ec71b589d feat: dashboard 2.0 redesign (#124)
* feat: add patterned background to boards

* feat: setup dashboard 2.0

* fix: prevent patterned background from overlaying buttons

* feat: set max width for main card view

* feat: adjust colour scheme for card view

* feat: adjust dashboard border colour scheme and radius

* fix: use correct colour for description placeholder

* fix: use getLayout to persist layout state across pages

* feat: reduce max width for settings and members pages

* feat: add feedback button and modal

* feat: adjust page max width

* feat: extend mobile layout support

* fix: hide pro badge when collapsed

* feat: tweak nav styling for light mode

* feat: tweak skeleton loaders for board list

* feat: extend list max height

* fix: hide side nav right border on desktop

* chore: update hero images

* chore: update hero images for docs

* chore: update translations
2025-07-24 12:14:46 +01:00
Henry
0cb69e2087 fix: allow member invites when signup is disabled (#121) 2025-07-20 11:18:19 +01:00
Henry
1305c55100 fix: affix avatar fileName with UID to invalidate next image cache (#117) 2025-07-14 22:39:05 +01:00
Henry
425c91865b feat: redirect from root to login for self hosted instances (#118) 2025-07-14 22:38:57 +01:00
Henry
a0096a9eb0 fix: use server-side signInMagicLink for member invites (#116) 2025-07-14 22:34:10 +01:00
Connor Goldberg
4058169079 fix: auth /social-providers endpoint ids (#115)
The `/social-providers` endpoint should return a list of the provider ids, not their names.
This was found when trying to use the microsoft endpoint and the endpoint was returning `"microsoft entraid"`, when the `AuthForm` expects it to just be `"microsoft"`.
2025-07-14 22:14:20 +01:00
Henry
ecc42f988f chore: update changelog 2025-07-11 22:28:21 +01:00
Henry
1ded560b75 feat: redirect from root to login for self hosted instances (#111) 2025-07-10 21:49:48 +01:00
Henry
f8dc062903 chore: add verbose error logging to magic link invitation (#110) 2025-07-10 21:27:25 +01:00
Henry
2169a379f2 feat: add button to view/update board public URL from board page (#107)
* feat: add update board slug button

* feat: add edit workspace url button

* feat: add loading state to update board slug button

* chore: add translations
2025-07-09 21:59:33 +01:00
Henry
03816b192f chore: hide hacker news button 2025-07-08 21:34:54 +01:00
Henry
f4ef1217dc feat: enhance label router with consistent output (#104) 2025-07-07 22:33:01 +01:00
Henry
1c920e3983 fix: update trpc dependencies and add openapi meta to providers endpoint (#103) 2025-07-07 22:32:49 +01:00
Henry
9daf657db2 chore: update changelog 2025-07-07 22:32:07 +01:00
Rees Morris
fdfd1f3d03 feat: add S3_FORCE_PATH_STYLE configuration for MinIO compatibility (#100)
* feat: add S3_FORCE_PATH_STYLE configuration for MinIO compatibility

* docs: add S3_FORCE_PATH_STYLE to env example
2025-07-07 21:06:51 +01:00
Henry
392be38f10 feat: improve mobile support on ios (#99)
* fix: add min width to dashboard

* fix: display theme background and set dynamic viewport height

* fix: prevent zooming on ios

* fix: enable scroll on card page
2025-07-03 09:12:06 +01:00
117 changed files with 11125 additions and 2133 deletions

View File

@@ -3,6 +3,8 @@
# Required environment variables # Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32) BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
# Fill if you want to use an external database
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
# Only required if deploying from compose # Only required if deploying from compose
@@ -21,6 +23,7 @@ S3_REGION=
S3_ENDPOINT= S3_ENDPOINT=
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=
NEXT_PUBLIC_STORAGE_URL= NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME= NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN= NEXT_PUBLIC_STORAGE_DOMAIN=

3
.gitignore vendored
View File

@@ -50,3 +50,6 @@ docker-compose.override.yml
# runtime env # runtime env
__ENV.js __ENV.js
i18n.cache i18n.cache
# pgdata
/apps/web/pgdata

4
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"type": "node-terminal", "type": "node-terminal",
"request": "launch", "request": "launch",
"command": "pnpm dev", "command": "pnpm dev",
"cwd": "${workspaceFolder}/apps/nextjs", "cwd": "${workspaceFolder}/apps/web",
"skipFiles": ["<node_internals>/**"], "skipFiles": ["<node_internals>/**"],
"sourceMaps": true, "sourceMaps": true,
"sourceMapPathOverrides": { "sourceMapPathOverrides": {
@@ -14,4 +14,4 @@
} }
} }
] ]
} }

View File

@@ -5,7 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.2...HEAD) ## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.4...HEAD)
## [0.2.4](https://github.com/kanbn/kan/compare/v0.2.3...v0.2.4) - 2025-01-14
### Added
- Button to view and update board public URL directly from board page
- Redirect from root to login page for self-hosted instances
### Changed
- Updated all label router endpoints to return consistent structure
- Added verbose error logging to magic link invitation process
### Fixed
- Updated tRPC dependencies and added OpenAPI meta to providers endpoint
## [0.2.3](https://github.com/kanbn/kan/compare/v0.2.2...v0.2.3) - 2025-07-07
### Added
- Mobile support with responsive navigation and layout
- Template selection when creating new boards with predefined lists and labels
- S3_FORCE_PATH_STYLE configuration for MinIO compatibility
### Changed
- Improved mobile UI styling and layout containers
- Enhanced iOS mobile support with viewport fixes and zoom prevention
- Improved card view layout for mobile devices
### Fixed
- Mobile dashboard min-width issues
- Dynamic viewport height on mobile devices
- Scroll functionality on card pages
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28 ## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28

View File

@@ -138,35 +138,37 @@ pnpm dev
## Environment Variables 🔐 ## Environment Variables 🔐
| Variable | Description | Required | Example | | Variable | Description | Required | Example |
| -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- | | ----------------------------------------- | -------------------------------------------------------- | ------------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `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` |
| `SMTP_PORT` | SMTP server port | For Email | `465` | | `SMTP_PORT` | SMTP server port | For Email | `465` |
| `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` |
| `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_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` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` | | `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` | | `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` | | `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` | | `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` | | `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `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 | `xxx` | | `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` | | `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` | | `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` | | `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` | | `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` | | `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` | | `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
See `.env.example` for a complete list of supported environment variables. See `.env.example` for a complete list of supported environment variables.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -81,5 +81,4 @@ ARG PORT=3000
ENV PORT=${PORT} ENV PORT=${PORT}
EXPOSE ${PORT} EXPOSE ${PORT}
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"] CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]

View File

@@ -6,7 +6,6 @@ checksums:
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b "%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18 "%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef "%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
"%231%20Hacker%20News/singular": de271b412c6c022d5c427d29fc70e0ba
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8 1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
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%20deleted/singular: a25da96a1579c4491be0a95669ef18a4 Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
@@ -14,12 +13,19 @@ checksums:
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168 Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028 Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
Add%20description...%20(type%20'%2F'%20to%20open%20commands)/singular: df99ea599521682e9ad0a90716955dc6 Add%20an%20item.../singular: f8e7993980420698700b07f56b3a38af
Add%20checklist/singular: 05d73200b56cfea75fb3a0f94c9bfb74
Add%20description...%20(type%20'%2F'%20to%20open%20commands%20or%20'%40'%20to%20mention)/singular: c6eb35a796dd7ac0e3dbc66244b71248
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
Add%20label/singular: 0be732d46df263265935fda342097a08 Add%20label/singular: 0be732d46df263265935fda342097a08
Add%20member/singular: 11979625770516ca287e929381778e02 Add%20member/singular: 11979625770516ca287e929381778e02
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235 added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2 added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2
added%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1caba6b6bca4c6fa1ab173d241059dc2 added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
@@ -32,9 +38,11 @@ checksums:
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961 Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946 Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0 Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3 Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
assigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20to%20the%20card/singular: 4fb1c419a78a0f010a3e5790801551fc assigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457 Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7 Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
@@ -61,6 +69,7 @@ checksums:
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3 Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6 Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379 Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667 Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8 Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
@@ -70,6 +79,8 @@ checksums:
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39 Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213 Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
@@ -83,6 +94,7 @@ checksums:
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3 Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
Create%20card/singular: 32792935dd5837a9433909b04021c22b Create%20card/singular: 32792935dd5837a9433909b04021c22b
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
Create%20list/singular: 858c3d514aa95765ec82114393199144 Create%20list/singular: 858c3d514aa95765ec82114393199144
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
@@ -103,6 +115,10 @@ checksums:
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69 Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
deleted%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 60f9107197e756487d3f95a1083846b5
deleted%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 8b7e270f612f9e91bda7f5cfb853d843
Design/singular: 991b75727b6784c1a063a7462b76186d Design/singular: 991b75727b6784c1a063a7462b76186d
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3 Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457 Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
@@ -119,6 +135,7 @@ checksums:
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
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1 Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
email/singular: f31eb214738e037d58e26149797739df email/singular: f31eb214738e037d58e26149797739df
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5 Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
@@ -127,8 +144,10 @@ checksums:
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643 Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26 Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
Error%20inviting%20member/singular: 91553b606a6ebed3534cd2e5635ce004
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381 Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
@@ -185,7 +204,9 @@ checksums:
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
Integrations/singular: 0ccce343287704cd90150c32e2fcad36 Integrations/singular: 0ccce343287704cd90150c32e2fcad36
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95 Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
Invite/singular: 181884cea804cbde665f160811ee7ad0 Invite/singular: 181884cea804cbde665f160811ee7ad0
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7 Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
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
@@ -206,17 +227,20 @@ checksums:
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8 Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3 magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51 Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32 Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88 Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
Monthly/singular: 818f1192e32bb855597f930d3e78806e Monthly/singular: 818f1192e32bb855597f930d3e78806e
moved%20the%20card%20from%20%3C0%3E%7BfromList%7D%3C%2F0%3E%20to%3C1%3E%7BtoList%7D%3C%2F1%3E/singular: c2d0d72ad97510344b51154ef15060e0 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
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8 Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
New/singular: 126d036fae5fb6b629728ecb97e6195b New/singular: 126d036fae5fb6b629728ecb97e6195b
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966 New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
New%20card/singular: a33f6219a756127f91c2523cfe845a19 New%20card/singular: a33f6219a756127f91c2523cfe845a19
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
New%20import/singular: e8fddfe15dd170277df919e5d37efd47 New%20import/singular: e8fddfe15dd170277df919e5d37efd47
New%20label/singular: 890040da52a88c16c249a4ac78980f81 New%20label/singular: 890040da52a88c16c249a4ac78980f81
New%20list/singular: 146c2e642b11056a0b07ed65733e4178 New%20list/singular: 146c2e642b11056a0b07ed65733e4178
@@ -263,7 +287,10 @@ checksums:
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9 Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
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%7Blabel%7D%3C%2F0%3E/singular: 1e7ebc1866136352bdc3cd90afc083f0 removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
Research/singular: 3368e9638d1619babd6df9fad592274f Research/singular: 3368e9638d1619babd6df9fad592274f
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
Resources/singular: ec7fb05ed963bb6781a35782b3475502 Resources/singular: ec7fb05ed963bb6781a35782b3475502
@@ -280,7 +307,7 @@ checksums:
Self%20Host/singular: 43fbce2027548b712002e489a20a621a Self%20Host/singular: 43fbce2027548b712002e489a20a621a
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774 Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698 self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
Send/singular: 76a8497d7b546628b03bb81d5c1ce995 Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
Senior/singular: 3fff865dc00435f82896fc302ea45630 Senior/singular: 3fff865dc00435f82896fc302ea45630
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3 Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5 Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
@@ -295,6 +322,7 @@ checksums:
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875 Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
Success/singular: c43827becada6750f7a25890905f38b9 Success/singular: c43827becada6750f7a25890905f38b9
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278 Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
System/singular: 803281c327a02a2feba182f4a2b38311 System/singular: 803281c327a02a2feba182f4a2b38311
Teams/singular: b63448c05270497973ac4407047dae02 Teams/singular: b63448c05270497973ac4407047dae02
@@ -307,6 +335,7 @@ checksums:
Theme/singular: 21fe00b7a518089576fb83c08631107a Theme/singular: 21fe00b7a518089576fb83c08631107a
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9 They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499 This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081 This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
@@ -317,22 +346,28 @@ checksums:
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9 Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6 Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4 Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05 Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874 Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9 Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
Unable%20to%20delete%20checklist%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%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
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731 Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
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
unassigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20from%20the%20card/singular: 105d4eeb32044126b8dde243bad11d56 unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30 unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5 Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042 Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
@@ -342,9 +377,10 @@ checksums:
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362 Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
Update/singular: 079fc039262fd31b10532929685c2d1b Update/singular: 079fc039262fd31b10532929685c2d1b
Update%20label/singular: 97880b503dfe956941c5f23025a1c102 Update%20label/singular: 97880b503dfe956941c5f23025a1c102
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21 updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
updated%20the%20title%20to%20%3C0%3E%7BtoTitle%7D%3C%2F0%3E/singular: 70cdf681ec2b6ef8e14397bc1f93f945 updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
Upgrade/singular: 63c3b52882e0d779859307d672c178c2 Upgrade/singular: 63c3b52882e0d779859307d672c178c2
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
@@ -352,11 +388,14 @@ checksums:
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1 URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
User/singular: 61073457a5c3901084b557d065f876be User/singular: 61073457a5c3901084b557d065f876be
User%20is%20already%20a%20member%20of%20this%20workspace/singular: bc246386f0975f750f2133222bb4735e
Video/singular: 8050c90e4289b105a0780f0fdda6ff66 Video/singular: 8050c90e4289b105a0780f0fdda6ff66
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549 View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38 View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871 View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
View%20only/singular: 21f5f7ba3b49050c4f406113479d6a7a
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
Visibility/singular: 1ff412f8fcde0f460acbf11153649646 Visibility/singular: 1ff412f8fcde0f460acbf11153649646
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0 We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0

View File

@@ -31,9 +31,12 @@ const config = {
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`, hostname: process.env.S3_FORCE_PATH_STYLE === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
}, },
{ {
protocol: "http", protocol: "http",

View File

@@ -33,6 +33,7 @@
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tiptap/extension-link": "^2.22.2", "@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/pm": "^2.14.0", "@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0", "@tiptap/react": "^2.14.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -259,6 +259,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
} }
return ( return (
<Button <Button
key={key}
onClick={() => handleLoginWithProvider(key as SocialProvider)} onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key} isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />} iconLeft={<provider.icon />}

View File

@@ -5,7 +5,7 @@ import LoadingSpinner from "./LoadingSpinner";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost"; variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg"; size?: "xs" | "sm" | "md" | "lg";
isLoading?: boolean; isLoading?: boolean;
iconLeft?: React.ReactNode; iconLeft?: React.ReactNode;
iconRight?: React.ReactNode; iconRight?: React.ReactNode;
@@ -28,6 +28,7 @@ const Button = ({
}: ButtonProps) => { }: ButtonProps) => {
const classes = twMerge( const classes = twMerge(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none", "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
size === "xs" && "text-xs px-2 py-1",
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "lg" && "py-[0.65rem]", size === "lg" && "py-[0.65rem]",
fullWidth && "w-full", fullWidth && "w-full",

View File

@@ -86,8 +86,10 @@ export default function CheckboxDropdown({
</label> </label>
{handleEdit && ( {handleEdit && (
<button <button
type="button"
className="invisible ml-auto group-hover:visible" className="invisible ml-auto group-hover:visible"
onClick={(event) => { onClick={(event) => {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleEdit(item.key); handleEdit(item.key);
}} }}
@@ -100,8 +102,12 @@ export default function CheckboxDropdown({
))} ))}
{handleCreate && ( {handleCreate && (
<button <button
type="button"
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300" className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => handleCreate()} onClick={(e) => {
e.preventDefault();
handleCreate();
}}
> >
<HiMiniPlus size={20} className="pr-1.5" /> <HiMiniPlus size={20} className="pr-1.5" />
{createNewItemLabel} {createNewItemLabel}

View File

@@ -0,0 +1,58 @@
import { twMerge } from "tailwind-merge";
interface CircularProgressProps {
progress: number; // 0-100
size?: "sm" | "md" | "lg";
className?: string;
}
const CircularProgress = ({
progress,
size = "md",
className,
}: CircularProgressProps) => {
const radius = 40;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<div className={twMerge("relative", className)}>
<svg
className={twMerge(
"-rotate-90 transform",
size === "sm" && "h-4 w-4",
size === "md" && "h-5 w-5",
size === "lg" && "h-8 w-8",
)}
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="14"
className="text-light-300 dark:text-dark-400"
/>
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="14"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={twMerge(
"transition-all duration-300 ease-in-out",
progress === 100 ? "text-green-500" : "text-blue-500",
)}
/>
</svg>
</div>
);
};
export default CircularProgress;

View File

@@ -1,4 +1,3 @@
import Link from "next/link";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { import {
TbLayoutSidebarLeftCollapse, TbLayoutSidebarLeftCollapse,
@@ -10,7 +9,11 @@ import {
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import FeedbackButton from "./FeedbackButton"; import { useModal } from "~/providers/modal";
import { useTheme } from "~/providers/theme";
import { WorkspaceProvider } from "~/providers/workspace";
import FeedbackModal from "./FeedbackModal";
import Modal from "./modal";
import SideNavigation from "./SideNavigation"; import SideNavigation from "./SideNavigation";
interface DashboardProps { interface DashboardProps {
@@ -19,12 +22,30 @@ interface DashboardProps {
hasRightPanel?: boolean; hasRightPanel?: boolean;
} }
export function getDashboardLayout(
page: React.ReactElement,
rightPanel?: React.ReactNode,
hasRightPanel = false,
) {
return (
<WorkspaceProvider>
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
{page}
</Dashboard>
</WorkspaceProvider>
);
}
export default function Dashboard({ export default function Dashboard({
children, children,
rightPanel, rightPanel,
hasRightPanel = false, hasRightPanel = false,
}: DashboardProps) { }: DashboardProps) {
const theme = useTheme();
const { modalContentType } = useModal();
const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: session, isPending: sessionLoading } = authClient.useSession();
const [isSideNavOpen, setIsSideNavOpen] = useState(false); const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -40,6 +61,10 @@ export default function Dashboard({
} }
}; };
const closeSideNav = () => {
setIsSideNavOpen(false);
};
const toggleRightPanel = () => { const toggleRightPanel = () => {
setIsRightPanelOpen(!isRightPanelOpen); setIsRightPanelOpen(!isRightPanelOpen);
if (!isRightPanelOpen) { if (!isRightPanelOpen) {
@@ -65,98 +90,93 @@ export default function Dashboard({
} }
}); });
const isDarkMode = theme.activeTheme === "dark";
return ( return (
<> <>
<style jsx global>{` <style jsx global>{`
html { html {
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
min-width: 320px;
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
} }
`}</style> `}</style>
<div className="h-screen flex-col items-center bg-light-100 dark:bg-dark-50 md:flex md:min-w-[800px]"> <div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
<div className="m-auto flex h-12 min-h-12 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400 md:h-16 md:min-h-16"> {/* Mobile Header */}
<div className="my-auto flex w-full items-center justify-between"> <div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
<button
ref={sideNavButtonRef}
onClick={toggleSideNav}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
>
{isSideNavOpen ? (
<TbLayoutSidebarLeftCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarLeftExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
{hasRightPanel && (
<button <button
ref={sideNavButtonRef} ref={rightPanelButtonRef}
onClick={toggleSideNav} onClick={toggleRightPanel}
className="text-neutral-900 dark:text-dark-1000 md:hidden" className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
> >
{isSideNavOpen ? ( {isRightPanelOpen ? (
<TbLayoutSidebarLeftCollapse <TbLayoutSidebarRightCollapse
size={20} size={20}
className="text-light-900 dark:text-dark-900" className="text-light-900 dark:text-dark-900"
/> />
) : ( ) : (
<TbLayoutSidebarLeftExpand <TbLayoutSidebarRightExpand
size={20} size={20}
className="text-light-900 dark:text-dark-900" className="text-light-900 dark:text-dark-900"
/> />
)} )}
</button> </button>
)}
<Link href="/" className="hidden md:block">
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<div className="flex items-center gap-2">
{hasRightPanel && (
<button
ref={rightPanelButtonRef}
onClick={toggleRightPanel}
className="text-neutral-900 dark:text-dark-1000 md:hidden"
>
{isRightPanelOpen ? (
<TbLayoutSidebarRightCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarRightExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
)}
<div className="hidden md:block">
<FeedbackButton />
</div>
</div>
</div>
</div> </div>
<div className="flex h-full w-full"> <div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
<div <div
ref={sideNavRef} ref={sideNavRef}
className={`fixed left-0 z-50 h-[calc(100vh-3rem)] transform transition-transform duration-300 ease-in-out md:relative md:h-[calc(100vh-4rem)] md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `} className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
> >
<SideNavigation <SideNavigation
user={{ email: session?.user.email, image: session?.user.image }} user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading} isLoading={sessionLoading}
onCloseSideNav={closeSideNav}
/> />
</div> </div>
<div className="relative flex w-full overflow-hidden"> <div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
<div className="w-full overflow-hidden">{children}</div> <div className="relative flex h-full min-h-0 w-full overflow-hidden">
<div className="h-full w-full overflow-y-auto">{children}</div>
{/* Mobile Right Panel */} {/* Mobile Right Panel */}
{hasRightPanel && rightPanel && ( {hasRightPanel && rightPanel && (
<div <div
ref={rightPanelRef} ref={rightPanelRef}
className={`fixed right-0 top-12 z-50 h-[calc(100vh-3rem)] w-80 transform bg-light-200 transition-transform duration-300 ease-in-out dark:bg-dark-100 md:hidden ${ className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
isRightPanelOpen ? "translate-x-0" : "translate-x-full" isRightPanelOpen ? "translate-x-0" : "translate-x-full"
}`} }`}
> >
<div className="h-full overflow-y-auto">{rightPanel}</div> <div className="h-full">{rightPanel}</div>
</div> </div>
)} )}
{/* Desktop Right Panel */} {/* Desktop Right Panel */}
{hasRightPanel && rightPanel && ( {hasRightPanel && rightPanel && (
<div className="hidden md:block">{rightPanel}</div> <div className="hidden md:block">{rightPanel}</div>
)} )}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -10,21 +12,23 @@ export function DeleteLabelConfirmation({
labelPublicId: string; labelPublicId: string;
refetch: () => void; refetch: () => void;
}) { }) {
const { closeModal } = useModal(); const { closeModal, closeModals } = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const deleteLabelMutation = api.label.delete.useMutation({ const deleteLabelMutation = api.label.delete.useMutation({
onSuccess: () => refetch(), onSuccess: () => {
refetch();
closeModals(2);
},
onError: () => onError: () =>
showPopup({ showPopup({
header: "Error deleting label", header: t`Error deleting label`,
message: "Please try again later, or contact customer support.", message: t`Please try again later, or contact customer support.`,
icon: "error", icon: "error",
}), }),
}); });
const handleDeleteLabel = () => { const handleDeleteLabel = () => {
closeModal();
deleteLabelMutation.mutate({ deleteLabelMutation.mutate({
labelPublicId, labelPublicId,
}); });
@@ -34,17 +38,17 @@ export function DeleteLabelConfirmation({
<div className="p-5"> <div className="p-5">
<div className="flex w-full flex-col justify-between pb-4"> <div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000"> <h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this label? {t`Are you sure you want to delete this label?`}
</h2> </h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900"> <p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."} {t`This action can't be undone.`}
</p> </p>
</div> </div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6"> <div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}> <Button variant="secondary" onClick={() => closeModal()}>
Cancel {t`Cancel`}
</Button> </Button>
<Button onClick={handleDeleteLabel}>Delete</Button> <Button onClick={handleDeleteLabel}>{t`Delete`}</Button>
</div> </div>
</div> </div>
); );

View File

@@ -17,7 +17,13 @@ import {
} from "@tiptap/react"; } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { import {
HiH1, HiH1,
HiH2, HiH2,
@@ -33,6 +39,9 @@ import {
} from "react-icons/hi2"; } from "react-icons/hi2";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import tippy from "tippy.js"; import tippy from "tippy.js";
import Mention from "@tiptap/extension-mention";
import Avatar from "./Avatar";
import { getAvatarUrl } from "~/utils/helpers";
declare module "@tiptap/core" { declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
@@ -68,6 +77,16 @@ export interface RenderSuggestionsProps {
command: (item: SlashCommandItem) => void; command: (item: SlashCommandItem) => void;
} }
export type WorkspaceMember = {
publicId: string;
user: {
id: string;
name: string | null;
image: string | null;
} | null;
email: string;
};
const CommandsList = forwardRef< const CommandsList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean }, { onKeyDown: (props: SuggestionKeyDownProps) => boolean },
{ {
@@ -181,6 +200,125 @@ const RenderSuggestions = () => {
}; };
}; };
type MentionItem = { id: string; label: string; image: string | null };
const MentionList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
{
items: MentionItem[];
command: (item: MentionItem) => void;
}
>(({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
if (event.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (event.key === "Enter") {
const item = items[selectedIndex];
if (item) {
command(item);
}
return true;
}
return false;
},
}));
return (
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
{items.length > 0 ? items.map((item, index) => (
<button
key={item.id}
onClick={() => command(item)}
className={twMerge(
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
)}
>
<Avatar
size="xs"
name={item.label}
imageUrl={
item.image ? getAvatarUrl(item.image) : undefined
}
email={item.label}
/>
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
{item.label}
</span>
</button>
)) : (
<div className="flex items-center justify-start p-2">
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
</div>
)}
</div>
</div>
);
});
MentionList.displayName = "MentionList";
const renderMentionSuggestions = () => {
let reactRenderer: ReactRenderer;
let popup: TippyInstance[];
return {
onStart: (props: any) => {
reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: any) {
reactRenderer.updateProps(props);
if (!props.clientRect) return;
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
popup[0]?.hide();
return true;
}
return (
(
reactRenderer.ref as {
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
}
).onKeyDown?.(props) ?? false
);
},
onExit() {
popup[0]?.destroy();
reactRenderer.destroy();
},
};
};
const SlashCommands = Extension.create<SlashCommandsOptions>({ const SlashCommands = Extension.create<SlashCommandsOptions>({
name: "slash-commands", name: "slash-commands",
addOptions() { addOptions() {
@@ -284,11 +422,13 @@ export default function Editor({
onChange, onChange,
onBlur, onBlur,
readOnly = false, readOnly = false,
workspaceMembers,
}: { }: {
content: string | null; content: string | null;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onBlur?: () => void; onBlur?: () => void;
readOnly?: boolean; readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -299,7 +439,7 @@ export default function Editor({
Placeholder.configure({ Placeholder.configure({
placeholder: readOnly placeholder: readOnly
? "" ? ""
: t`Add description... (type '/' to open commands)`, : t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,
@@ -320,6 +460,38 @@ export default function Editor({
char: "/", char: "/",
}, },
}), }),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: {
char: "@",
items: ({ query }: { query: string }) => {
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
id: member.publicId,
label: member?.user?.name ?? member.email,
image: member?.user?.image ?? null,
}));
const q = query.toLowerCase();
return all.filter((u) => u.label.toLowerCase().includes(q));
},
command: ({ editor, range, props }: any) => {
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span>&nbsp;`;
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(mentionHTML)
.focus()
.run();
},
render: renderMentionSuggestions,
},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
}),
], ],
content, content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()), onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
@@ -343,14 +515,23 @@ export default function Editor({
editable: !readOnly, editable: !readOnly,
injectCSS: false, injectCSS: false,
}, },
[content], [], // creating the editor only once
); );
// this will sync external content changes without re-creating the editor instance
useEffect(() => {
if (!editor) return;
const currentHTML = editor.getHTML();
const safeContent = content ?? "";
if (safeContent !== currentHTML) {
editor.commands.setContent(safeContent, false);
}
}, [content, editor]);
return ( return (
<div ref={containerRef}> <div ref={containerRef}>
<style jsx global>{` <style jsx global>{`
.tiptap p.is-empty::before { .tiptap p.is-empty::before {
color: #adb5bd;
content: attr(data-placeholder); content: attr(data-placeholder);
float: left; float: left;
height: 0; height: 0;
@@ -359,11 +540,19 @@ export default function Editor({
.tiptap p { .tiptap p {
margin: 0 0 1rem 0 !important; margin: 0 0 1rem 0 !important;
} }
.tiptap .mention {
background-color: rgba(59, 130, 246, 0.1);
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
color: rgb(59, 130, 246);
text-decoration: none;
font-weight: 500;
}
`}</style> `}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />} {!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent <EditorContent
editor={editor} editor={editor}
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs" className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-light-950 [&_p]:dark:text-dark-950 [&_ul]:!text-xs"
/> />
</div> </div>
); );

View File

@@ -1,149 +0,0 @@
import Link from "next/link";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import chatIconDark from "~/assets/chat-dark.json";
import chatIconLight from "~/assets/chat-light.json";
import Button from "~/components/Button";
import Input from "~/components/Input";
import LottieIcon from "~/components/LottieIcon";
import { usePopup } from "~/providers/popup";
import { useTheme } from "~/providers/theme";
import { api } from "~/utils/api";
interface NewFeedbackFormInput {
feedback: string;
}
const FeedbackButton: React.FC = () => {
const outsideRef = useRef<HTMLDivElement>(null);
const { activeTheme } = useTheme();
const { showPopup } = usePopup();
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const { handleSubmit, setValue, watch, reset } =
useForm<NewFeedbackFormInput>({
defaultValues: {
feedback: "",
},
});
const createFeedback = api.feedback.create.useMutation({
onSuccess: async () => {
reset();
showPopup({
header: t`Feedback sent`,
message: t`Thank you for your feedback!`,
icon: "success",
});
},
onError: async () => {
showPopup({
header: t`Unable to send feedback`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
const onSubmit = (values: NewFeedbackFormInput) => {
createFeedback.mutate({
feedback: values.feedback,
url: window.location.href,
});
};
return (
<>
<div ref={outsideRef} />
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button
onMouseEnter={handleMouseEnter}
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
>
<LottieIcon
index={index}
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
isPlaying={isHovered}
/>
<span className="ml-1">{t`Feedback`}</span>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
as="div"
static
className="absolute right-0 z-30 mt-2 w-[350px] origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300"
>
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
<Input
placeholder={t`Ideas to improve this page...`}
onChange={(e) => {
setValue("feedback", e.target.value);
}}
value={watch("feedback")}
contentEditable
className="max-h-[300px] min-h-[100px]"
onKeyDown={async (e) => {
e.stopPropagation();
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<div className="flex flex-row items-center justify-between pt-2">
<div>
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
{t`Need help?`}{" "}
<Link
href="mailto:support@kan.bn"
className="text-blue-600 underline dark:text-blue-300"
>
{t`Contact us`}
</Link>
{t`, or see our`}{" "}
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline dark:text-blue-300"
>
{t`docs`}
</Link>
.
</p>
</div>
<div className="justify-end">
<Button size="sm" isLoading={createFeedback.isPending}>
{t`Send`}
</Button>
</div>
</div>
</form>
</Menu.Items>
</Transition>
</Menu>
</>
);
};
export default FeedbackButton;

View File

@@ -0,0 +1,122 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface NewFeedbackFormInput {
feedback: string;
}
export default function FeedbackModal() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { handleSubmit, setValue, watch, reset } =
useForm<NewFeedbackFormInput>({
defaultValues: {
feedback: "",
},
});
const createFeedback = api.feedback.create.useMutation({
onSuccess: async () => {
reset();
closeModal();
showPopup({
header: t`Feedback sent`,
message: t`Thank you for your feedback!`,
icon: "success",
});
},
onError: async () => {
showPopup({
header: t`Unable to send feedback`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const onSubmit = (values: NewFeedbackFormInput) => {
createFeedback.mutate({
feedback: values.feedback,
url: window.location.href,
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
{t`Feedback`}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Input
id="feedback"
placeholder={t`Ideas to improve this page...`}
onChange={(e) => setValue("feedback", e.target.value)}
contentEditable
value={watch("feedback")}
className="min-h-[120px]"
onKeyDown={async (e) => {
e.stopPropagation();
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
if (e.key === "Escape") {
closeModal();
}
}}
/>
</div>
<div className="mt-6 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="text-xs text-neutral-600 dark:text-dark-800">
<p>
{t`Need help?`}{" "}
<Link
href="mailto:support@kan.bn"
className="text-blue-600 underline dark:text-blue-300"
>
{t`Contact us`}
</Link>
{t`, or see our`}{" "}
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline dark:text-blue-300"
>
{t`docs`}
</Link>
.
</p>
</div>
<div>
<Button type="submit" isLoading={createFeedback.isPending}>
{t`Send feedback`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -32,7 +32,7 @@ export function LabelForm({
refetch: () => void; refetch: () => void;
isEdit?: boolean; isEdit?: boolean;
}) { }) {
const { closeModal, entityId, openModal } = useModal(); const { closeModal, entityId, openModal, setModalState } = useModal();
const label = api.label.byPublicId.useQuery( const label = api.label.byPublicId.useQuery(
{ {
@@ -57,18 +57,23 @@ export function LabelForm({
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const createLabel = api.label.create.useMutation({ const createLabel = api.label.create.useMutation({
onSuccess: () => { onSuccess: (newLabel) => {
const currentColourIndex = colours.findIndex( const currentColourIndex = colours.findIndex(
(c) => c.code === watch("colour").code, (c) => c.code === watch("colour").code,
); );
try { try {
refetch(); refetch();
if (!isCreateAnotherEnabled) closeModal(); setModalState("NEW_LABEL_CREATED", newLabel.publicId);
reset({ if (!isCreateAnotherEnabled) {
name: "", closeModal();
colour: colours[(currentColourIndex + 1) % colours.length], } else {
isCreateAnotherEnabled, const newFormState = {
}); name: "",
colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
};
reset(newFormState);
}
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
@@ -79,10 +84,6 @@ export function LabelForm({
onSuccess: () => { onSuccess: () => {
refetch(); refetch();
closeModal(); closeModal();
reset({
name: "",
colour: colours[0],
});
}, },
}); });
@@ -220,6 +221,7 @@ export function LabelForm({
<div className="space-x-2"> <div className="space-x-2">
{isEdit && ( {isEdit && (
<Button <Button
type="button"
variant="secondary" variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)} onClick={() => openModal("DELETE_LABEL", entityId)}
> >
@@ -229,6 +231,9 @@ export function LabelForm({
<Button <Button
type="submit" type="submit"
isLoading={updateLabel.isPending || createLabel.isPending} isLoading={updateLabel.isPending || createLabel.isPending}
disabled={
!watch("name")
}
> >
{isEdit ? t`Update label` : t`Create label`} {isEdit ? t`Update label` : t`Create label`}
</Button> </Button>

View File

@@ -2,11 +2,11 @@ import dynamic from "next/dynamic";
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false }); const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
type IconProps = { interface IconProps {
isPlaying: boolean; isPlaying: boolean;
index: number; index: number;
json: object; json: object;
}; }
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => { const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
return ( return (
@@ -15,7 +15,7 @@ const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
animationData={json} animationData={json}
play={isPlaying} play={isPlaying}
loop={false} loop={false}
style={{ width: 20, height: 20, fill: "white" }} style={{ width: 18, height: 18, fill: "white" }}
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }} rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
/> />
); );

View File

@@ -4,6 +4,10 @@ export const PageHead = ({ title }: { title: string }) => {
return ( return (
<Head> <Head>
<title>{title}</title> <title>{title}</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
</Head> </Head>
); );
}; };

View File

@@ -3,8 +3,8 @@ const PatternedBackground = () => (
<svg className="h-full w-full"> <svg className="h-full w-full">
<pattern <pattern
id="pattern" id="pattern"
x="0.034759358288862785" x="10"
y="3.335370511841166" y="10"
width="14.423223834988539" width="14.423223834988539"
height="14.423223834988539" height="14.423223834988539"
patternUnits="userSpaceOnUse" patternUnits="userSpaceOnUse"

View File

@@ -1,37 +1,51 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { twMerge } from "tailwind-merge";
import LottieIcon from "~/components/LottieIcon"; import LottieIcon from "~/components/LottieIcon";
import { useIsMobile } from "~/hooks/useMediaQuery";
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
const Button: React.FC<{ const Button: React.FC<{
href: string; href: string;
current: boolean; current: boolean;
name: string; name: string;
json: object; json: object;
}> = ({ href, current, name, json }) => { isCollapsed?: boolean;
onCloseSideNav?: () => void;
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const isMobile = useIsMobile();
const handleMouseEnter = () => { const handleMouseEnter = () => {
setIsHovered(true); setIsHovered(true);
setIndex((index) => index + 1); setIndex((index) => index + 1);
}; };
const handleClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
return ( return (
<Link <Link
href={href} href={href}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
className={classNames( onClick={handleClick}
current ? "bg-light-200 dark:bg-dark-200" : "dark:bg-dark-50", className={twMerge(
"group flex items-center gap-x-3 rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-200", "group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
current
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
isCollapsed
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
: "gap-x-3",
)} )}
title={isCollapsed ? name : undefined}
> >
<LottieIcon index={index} json={json} isPlaying={isHovered} /> <LottieIcon index={index} json={json} isPlaying={isHovered} />
{name} <span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
</Link> </Link>
); );
}; };

View File

@@ -1,5 +1,13 @@
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import boardsIconDark from "~/assets/boards-dark.json"; import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json"; import boardsIconLight from "~/assets/boards-light.json";
@@ -15,6 +23,7 @@ import { useTheme } from "~/providers/theme";
interface SideNavigationProps { interface SideNavigationProps {
user: UserType; user: UserType;
isLoading: boolean; isLoading: boolean;
onCloseSideNav?: () => void;
} }
interface UserType { interface UserType {
@@ -25,8 +34,28 @@ interface UserType {
export default function SideNavigation({ export default function SideNavigation({
user, user,
isLoading, isLoading,
onCloseSideNav,
}: SideNavigationProps) { }: SideNavigationProps) {
const router = useRouter(); const router = useRouter();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
useEffect(() => {
const savedState = localStorage.getItem("kan_sidebar-collapsed");
if (savedState !== null) {
setIsCollapsed(JSON.parse(savedState));
}
setIsInitialised(true);
}, []);
useEffect(() => {
if (isInitialised) {
localStorage.setItem(
"kan_sidebar-collapsed",
JSON.stringify(isCollapsed),
);
}
}, [isCollapsed, isInitialised]);
const { pathname } = router; const { pathname } = router;
@@ -52,11 +81,50 @@ export default function SideNavigation({
}, },
]; ];
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
return ( return (
<> <>
<nav className="flex h-full w-64 flex-col justify-between border-r border-light-600 bg-light-50 px-3 pb-3 pt-5 dark:border-dark-400 dark:bg-dark-50"> <nav
className={twMerge(
"flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:border-r-0 md:py-0 md:pl-0",
isCollapsed && "md:w-auto",
)}
>
<div> <div>
<WorkspaceMenu /> <div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
{!isCollapsed && (
<Link href="/" className="block">
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
)}
<Button
onClick={toggleCollapse}
className={twMerge(
"flex h-8 items-center justify-center rounded-md hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed ? "w-full" : "w-8",
)}
>
{isCollapsed ? (
<TbLayoutSidebarLeftExpand
size={18}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarLeftCollapse
size={18}
className="text-light-900 dark:text-dark-900"
/>
)}
</Button>
</div>
<div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
<WorkspaceMenu isCollapsed={isCollapsed} />
<ul role="list" className="space-y-1"> <ul role="list" className="space-y-1">
{navigation.map((item) => ( {navigation.map((item) => (
<li key={item.name}> <li key={item.name}>
@@ -65,16 +133,23 @@ export default function SideNavigation({
current={pathname.includes(item.href)} current={pathname.includes(item.href)}
name={item.name} name={item.name}
json={item.icon} json={item.icon}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/> />
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
<UserMenu
email={user.email ?? ""} <div className="space-y-3">
imageUrl={user.image ?? undefined} <UserMenu
isLoading={isLoading} email={user.email ?? ""}
/> imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
</div>
</nav> </nav>
</> </>
); );

View File

@@ -1,11 +1,15 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Fragment } from "react"; import { Fragment } from "react";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useModal } from "~/providers/modal";
import { useTheme } from "~/providers/theme"; import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
@@ -13,38 +17,63 @@ interface UserMenuProps {
imageUrl: string | undefined; imageUrl: string | undefined;
email: string; email: string;
isLoading: boolean; isLoading: boolean;
} isCollapsed?: boolean;
onCloseSideNav?: () => void;
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
} }
export default function UserMenu({ export default function UserMenu({
imageUrl, imageUrl,
email, email,
isLoading, isLoading,
isCollapsed = false,
onCloseSideNav,
}: UserMenuProps) { }: UserMenuProps) {
const router = useRouter(); const router = useRouter();
const { themePreference, switchTheme } = useTheme(); const { themePreference, switchTheme } = useTheme();
const { openModal } = useModal();
const isMobile = useIsMobile();
const handleLogout = async () => { const handleLogout = async () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
await authClient.signOut(); await authClient.signOut();
router.push("/login"); router.push("/login");
}; };
const handleLinkClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
const handleModalOpen = (modalType: string) => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
openModal(modalType);
};
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null; const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
return ( return (
<Menu as="div" className="relative inline-block w-full text-left"> <Menu as="div" className="relative inline-block w-full text-left">
<div> <div>
{isLoading ? ( {isLoading ? (
<div className="flex"> <div className={twMerge(!isCollapsed && "flex")}>
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" /> <div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" /> <div
className={twMerge(
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
</div> </div>
) : ( ) : (
<Menu.Button className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"> <Menu.Button
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
title={isCollapsed ? email : undefined}
>
{avatarUrl ? ( {avatarUrl ? (
<Image <Image
src={avatarUrl} src={avatarUrl}
@@ -64,7 +93,14 @@ export default function UserMenu({
</svg> </svg>
</span> </span>
)} )}
<span className="mx-2 truncate text-sm">{email}</span> <span
className={twMerge(
"mx-2 truncate text-sm",
isCollapsed && "md:hidden",
)}
>
{email}
</span>
</Menu.Button> </Menu.Button>
)} )}
</div> </div>
@@ -78,7 +114,12 @@ export default function UserMenu({
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="absolute bottom-[40px] left-0 z-10 mt-2 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300"> <Menu.Items
className={twMerge(
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "left-0 w-48" : "left-0 w-full",
)}
>
<div className="flex flex-col text-neutral-900 dark:text-dark-1000"> <div className="flex flex-col text-neutral-900 dark:text-dark-1000">
<div className="p-1"> <div className="p-1">
<div className="flex w-full items-center px-3 py-2 text-left text-xs"> <div className="flex w-full items-center px-3 py-2 text-left text-xs">
@@ -90,7 +131,7 @@ export default function UserMenu({
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={classNames( className={twMerge(
themePreference === "system" ? "visible" : "invisible", themePreference === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
@@ -104,7 +145,7 @@ export default function UserMenu({
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={classNames( className={twMerge(
themePreference === "dark" ? "visible" : "invisible", themePreference === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
@@ -118,7 +159,7 @@ export default function UserMenu({
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400" className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
> >
<span <span
className={classNames( className={twMerge(
themePreference === "light" ? "visible" : "invisible", themePreference === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900", "mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)} )}
@@ -127,6 +168,38 @@ export default function UserMenu({
</button> </button>
</Menu.Item> </Menu.Item>
</div> </div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<Link
href="mailto:support@kan.bn"
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Support`}
</Link>
</Menu.Item>
<Menu.Item>
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Documentation`}
</Link>
</Menu.Item>
<Menu.Item>
<button
onClick={() => handleModalOpen("NEW_FEEDBACK")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Feedback`}
</button>
</Menu.Item>
</div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600"> <div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item> <Menu.Item>
<button <button

View File

@@ -2,11 +2,16 @@ import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Fragment } from "react"; import { Fragment } from "react";
import { HiCheck } from "react-icons/hi2"; import { HiCheck } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
export default function WorkspaceMenu() { export default function WorkspaceMenu({
isCollapsed = false,
}: {
isCollapsed?: boolean;
}) {
const { workspace, isLoading, availableWorkspaces, switchWorkspace } = const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace(); useWorkspace();
const { openModal } = useModal(); const { openModal } = useModal();
@@ -15,22 +20,43 @@ export default function WorkspaceMenu() {
<Menu as="div" className="relative inline-block w-full pb-3 text-left"> <Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div> <div>
{isLoading ? ( {isLoading ? (
<div className="mb-1 flex p-1.5"> <div className={twMerge("mb-1", isCollapsed && "md:flex md:p-1.5")}>
<div className="h-6 w-6 animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" /> <div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" /> <div
className={twMerge(
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "hidden md:block",
)}
/>
</div> </div>
) : ( ) : (
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200"> <Menu.Button
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700"> className={twMerge(
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
)}
title={isCollapsed ? workspace.name : undefined}
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
<span className="text-xs font-bold leading-none text-white"> <span className="text-xs font-bold leading-none text-white">
{workspace.name.charAt(0).toUpperCase()} {workspace.name.charAt(0).toUpperCase()}
</span> </span>
</span> </span>
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000"> <span
className={twMerge(
"ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
isCollapsed && "md:hidden",
)}
>
{workspace.name} {workspace.name}
</span> </span>
{workspace.plan === "pro" && ( {workspace.plan === "pro" && (
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700"> <span
className={twMerge(
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
isCollapsed && "md:hidden",
)}
>
Pro Pro
</span> </span>
)} )}
@@ -47,7 +73,12 @@ export default function WorkspaceMenu() {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items className="absolute left-0 z-10 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300"> <Menu.Items
className={twMerge(
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "w-48" : "w-full",
)}
>
<div className="p-1"> <div className="p-1">
{availableWorkspaces.map((availableWorkspace) => ( {availableWorkspaces.map((availableWorkspace) => (
<div key={availableWorkspace.publicId} className="flex"> <div key={availableWorkspace.publicId} className="flex">

View File

@@ -7,15 +7,19 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
modalSize?: "sm" | "md" | "lg"; modalSize?: "sm" | "md" | "lg";
positionFromTop?: "sm" | "md" | "lg"; positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
} }
const Modal: React.FC<Props> = ({ const Modal: React.FC<Props> = ({
children, children,
modalSize = "sm", modalSize = "sm",
positionFromTop = "md", positionFromTop = "md",
isVisible,
}) => { }) => {
const { isOpen, closeModal } = useModal(); const { isOpen, closeModal } = useModal();
const shouldShow = isVisible !== undefined ? isVisible : isOpen;
const modalSizeMap = { const modalSizeMap = {
sm: "max-w-[400px]", sm: "max-w-[400px]",
md: "max-w-[550px]", md: "max-w-[550px]",
@@ -29,7 +33,7 @@ const Modal: React.FC<Props> = ({
}; };
return ( return (
<Transition.Root show={isOpen} as={Fragment}> <Transition.Root show={shouldShow} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}

View File

@@ -24,7 +24,7 @@ export const env = createEnv({
s.split(",").every((l) => z.string().url().safeParse(l).success), s.split(",").every((l) => z.string().url().safeParse(l).success),
) )
.optional(), .optional(),
POSTGRES_URL: z.string().url(), POSTGRES_URL: z.string().url().optional().or(z.literal("")),
TRELLO_APP_API_KEY: z.string().optional(), TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(), TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
@@ -69,6 +69,7 @@ export const env = createEnv({
S3_SECRET_ACCESS_KEY: z.string().optional(), S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(), S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(), S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(), EMAIL_FROM: z.string().optional(),
}, },
@@ -100,6 +101,13 @@ export const env = createEnv({
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false", (s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
) )
.optional(), .optional(),
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
}, },
/** /**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away. * Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
@@ -118,6 +126,8 @@ export const env = createEnv({
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP, NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT, process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
}, },
skipValidation: skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint", !!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query]);
return matches;
}
export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
}

View File

@@ -0,0 +1,48 @@
import { useEffect } from "react";
import { useModal } from "~/providers/modal";
interface UseModalFormStateOptions<T> {
modalType: string;
initialValues: T;
resetOnClose?: boolean;
}
export function useModalFormState<T extends Record<string, any>>({
modalType,
initialValues,
resetOnClose = false,
}: UseModalFormStateOptions<T>) {
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
const isCurrentModal = modalContentType === modalType;
const savedState = getModalState(modalType) as T | undefined;
// get current form state (using the saved values if available, otherwise the initial values)
const formState = savedState || initialValues;
const saveFormState = (state: Partial<T>) => {
if (!isCurrentModal) return;
const currentState = getModalState(modalType) || initialValues;
const newState = { ...currentState, ...state };
setModalState(modalType, newState);
};
const clearFormState = () => {
clearModalState(modalType);
};
useEffect(() => {
if (resetOnClose && !isOpen && savedState) {
clearModalState(modalType);
}
}, [isOpen, resetOnClose, savedState, modalType, clearModalState]);
return {
formState,
saveFormState,
clearFormState,
isCurrentModal,
hasSavedState: !!savedState,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { env } from "next-runtime-env";
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === "/") {
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/"],
};

View File

@@ -1,8 +1,9 @@
import "~/styles/globals.css"; import "~/styles/globals.css";
import "~/utils/i18n"; import "~/utils/i18n";
import type { Viewport } from "next"; import type { NextPage, Viewport } from "next";
import type { AppType } from "next/app"; import type { AppProps, AppType } from "next/app";
import type { ReactElement, ReactNode } from "react";
import { Plus_Jakarta_Sans } from "next/font/google"; import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script"; import Script from "next/script";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
@@ -34,7 +35,15 @@ export const viewport: Viewport = {
userScalable: false, userScalable: false,
}; };
const MyApp: AppType = ({ Component, pageProps }) => { export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY"); const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
useEffect(() => { useEffect(() => {
@@ -50,6 +59,8 @@ const MyApp: AppType = ({ Component, pageProps }) => {
} }
}, [posthogKey]); }, [posthogKey]);
const getLayout = Component.getLayout ?? ((page) => page);
return ( return (
<> <>
<style jsx global>{` <style jsx global>{`
@@ -75,10 +86,10 @@ const MyApp: AppType = ({ Component, pageProps }) => {
<PopupProvider> <PopupProvider>
{posthogKey ? ( {posthogKey ? (
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<Component {...pageProps} /> {getLayout(<Component {...pageProps} />)}
</PostHogProvider> </PostHogProvider>
) : ( ) : (
<Component {...pageProps} /> getLayout(<Component {...pageProps} />)
)} )}
</PopupProvider> </PopupProvider>
</ModalProvider> </ModalProvider>

View File

@@ -46,6 +46,7 @@ export default async function handler(
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",
credentials: { credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID ?? "", accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "", secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
@@ -58,6 +59,7 @@ export default async function handler(
new PutObjectCommand({ new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename, Key: filename,
ACL: "public-read",
}), }),
); );

View File

@@ -1,15 +1,17 @@
import { WorkspaceProvider } from "~/providers/workspace"; import type { NextPageWithLayout } from "~/pages/_app";
import Dashboard from "~/components/Dashboard"; import { getDashboardLayout } from "~/components/Dashboard";
import Popup from "~/components/Popup"; import Popup from "~/components/Popup";
import BoardView from "~/views/board"; import BoardView from "~/views/board";
export default function BoardPage() { const BoardPage: NextPageWithLayout = () => {
return ( return (
<WorkspaceProvider> <>
<Dashboard> <BoardView />
<BoardView />
</Dashboard>
<Popup /> <Popup />
</WorkspaceProvider> </>
); );
} };
BoardPage.getLayout = (page) => getDashboardLayout(page);
export default BoardPage;

View File

@@ -1,15 +1,17 @@
import { WorkspaceProvider } from "~/providers/workspace"; import type { NextPageWithLayout } from "../_app";
import Dashboard from "~/components/Dashboard"; import { getDashboardLayout } from "~/components/Dashboard";
import Popup from "~/components/Popup"; import Popup from "~/components/Popup";
import BoardsView from "~/views/boards"; import BoardsView from "~/views/boards";
export default function BoardsPage() { const BoardsPage: NextPageWithLayout = () => {
return ( return (
<WorkspaceProvider> <>
<Dashboard> <BoardsView />
<BoardsView />
</Dashboard>
<Popup /> <Popup />
</WorkspaceProvider> </>
); );
} };
BoardsPage.getLayout = (page) => getDashboardLayout(page);
export default BoardsPage;

View File

@@ -1,15 +1,18 @@
import Dashboard from "~/components/Dashboard"; import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import Popup from "~/components/Popup"; import Popup from "~/components/Popup";
import { WorkspaceProvider } from "~/providers/workspace";
import CardView, { CardRightPanel } from "~/views/card"; import CardView, { CardRightPanel } from "~/views/card";
export default function CardPage() { const CardPage: NextPageWithLayout = () => {
return ( return (
<WorkspaceProvider> <>
<Dashboard hasRightPanel rightPanel={<CardRightPanel />}> <CardView />
<CardView />
</Dashboard>
<Popup /> <Popup />
</WorkspaceProvider> </>
); );
} };
CardPage.getLayout = (page) =>
getDashboardLayout(page, <CardRightPanel />, true);
export default CardPage;

View File

@@ -1,15 +1,17 @@
import { WorkspaceProvider } from "~/providers/workspace"; import type { NextPageWithLayout } from "~/pages/_app";
import Dashboard from "~/components/Dashboard"; import { getDashboardLayout } from "~/components/Dashboard";
import Popup from "~/components/Popup"; import Popup from "~/components/Popup";
import MembersView from "~/views/members"; import MembersView from "~/views/members";
export default function MembersPage() { const MembersPage: NextPageWithLayout = () => {
return ( return (
<WorkspaceProvider> <>
<Dashboard> <MembersView />
<MembersView />
</Dashboard>
<Popup /> <Popup />
</WorkspaceProvider> </>
); );
} };
MembersPage.getLayout = (page) => getDashboardLayout(page);
export default MembersPage;

View File

@@ -1,15 +1,17 @@
import { WorkspaceProvider } from "~/providers/workspace"; import type { NextPageWithLayout } from "~/pages/_app";
import Dashboard from "~/components/Dashboard"; import { getDashboardLayout } from "~/components/Dashboard";
import SettingsView from "~/views/settings";
import Popup from "~/components/Popup"; import Popup from "~/components/Popup";
import SettingsView from "~/views/settings";
export default function SettingsPage() { const SettingsPage: NextPageWithLayout = () => {
return ( return (
<WorkspaceProvider> <>
<Dashboard> <SettingsView />
<SettingsView />
</Dashboard>
<Popup /> <Popup />
</WorkspaceProvider> </>
); );
} };
SettingsPage.getLayout = (page) => getDashboardLayout(page);
export default SettingsPage;

View File

@@ -1,6 +1,16 @@
import { createContext, useContext, useState } from "react"; import { createContext, useContext, useState } from "react";
type ModalContextType = { interface ModalState {
contentType: string;
entityId?: string;
entityLabel?: string;
}
interface Props {
children: React.ReactNode;
}
interface ModalContextType {
isOpen: boolean; isOpen: boolean;
openModal: ( openModal: (
contentType: string, contentType: string,
@@ -8,36 +18,80 @@ type ModalContextType = {
entityLabel?: string, entityLabel?: string,
) => void; ) => void;
closeModal: () => void; closeModal: () => void;
closeModals: (count: number) => void;
clearAllModals: () => void;
modalContentType: string; modalContentType: string;
entityId: string; entityId: string;
entityLabel: string; entityLabel: string;
}; modalStates: Record<string, any>;
setModalState: (modalType: string, state: any) => void;
interface Props { getModalState: (modalType: string) => any;
children: React.ReactNode; clearModalState: (modalType: string) => void;
clearAllModalStates: () => void;
} }
const ModalContext = createContext<ModalContextType | undefined>(undefined); const ModalContext = createContext<ModalContextType | undefined>(undefined);
export const ModalProvider: React.FC<Props> = ({ children }) => { export const ModalProvider: React.FC<Props> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false); const [modalStack, setModalStack] = useState<ModalState[]>([]);
const [entityId, setEntityId] = useState(""); const [modalStates, setModalStates] = useState<Record<string, any>>({});
const [entityLabel, setEntityLabel] = useState("");
const [modalContentType, setModalContentType] = useState(""); const isOpen = modalStack.length > 0;
const currentModal = modalStack[modalStack.length - 1];
const modalContentType = currentModal?.contentType || "";
const entityId = currentModal?.entityId || "";
const entityLabel = currentModal?.entityLabel || "";
const openModal = ( const openModal = (
contentType: string, contentType: string,
entityId?: string, entityId?: string,
entityLabel?: string, entityLabel?: string,
) => { ) => {
setIsOpen(true); const newModal: ModalState = { contentType, entityId, entityLabel };
setModalContentType(contentType); setModalStack((prev) => [...prev, newModal]);
if (entityId) setEntityId(entityId);
if (entityLabel) setEntityLabel(entityLabel);
}; };
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setModalStack((prev) => {
if (prev.length <= 1) {
return [];
}
return prev.slice(0, -1);
});
};
const closeModals = (count: number) => {
setModalStack(prev => {
const newLength = Math.max(0, prev.length - count);
return prev.slice(0, newLength);
});
};
const clearAllModals = () => {
setModalStack([]);
};
const setModalState = (modalType: string, state: any) => {
setModalStates((prev) => ({
...prev,
[modalType]: state,
}));
};
const getModalState = (modalType: string) => {
return modalStates[modalType];
};
const clearModalState = (modalType: string) => {
setModalStates((prev) => {
const newStates = { ...prev };
delete newStates[modalType];
return newStates;
});
};
const clearAllModalStates = () => {
setModalStates({});
}; };
return ( return (
@@ -46,9 +100,16 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
isOpen, isOpen,
openModal, openModal,
closeModal, closeModal,
closeModals,
clearAllModals,
modalContentType, modalContentType,
entityId, entityId,
entityLabel, entityLabel,
modalStates,
setModalState,
getModalState,
clearModalState,
clearAllModalStates,
}} }}
> >
{children} {children}

View File

@@ -45,10 +45,12 @@ export const formatMemberDisplayName = (
return localPart.replace(/[_-]/g, "."); return localPart.replace(/[_-]/g, ".");
}; };
export const getAvatarUrl = (imageOrKey: string) => { export const getAvatarUrl = (imageOrKey: string | null) => {
if (!imageOrKey) return "";
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) { if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
return imageOrKey; return imageOrKey;
} }
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`; return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
}; };

View File

@@ -1,5 +1,6 @@
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 LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
@@ -7,6 +8,7 @@ const Card = ({
title, title,
labels, labels,
members, members,
checklists,
}: { }: {
title: string; title: string;
labels: { name: string; colourCode: string | null }[]; labels: { name: string; colourCode: string | null }[];
@@ -15,7 +17,28 @@ const Card = ({
email: string; email: string;
user: { name: string | null; email: string; image: string | null } | null; user: { name: string | null; email: string; image: string | null } | null;
}[]; }[];
checklists: {
publicId: string;
name: string;
items: {
publicId: string;
title: string;
completed: boolean;
index: number;
}[];
}[];
}) => { }) => {
const completedItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.filter((item) => item.completed).length;
}, 0);
const totalItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.length;
}, 0);
const progress =
totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
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">
<span>{title}</span> <span>{title}</span>
@@ -29,21 +52,37 @@ const Card = ({
/> />
))} ))}
</div> </div>
<div className="isolate flex justify-end -space-x-1 overflow-hidden"> <div className="flex items-center justify-end gap-1">
{members.map(({ user, email }) => { {checklists.length > 0 && (
const avatarUrl = user?.image <div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-600">
? getAvatarUrl(user.image) <CircularProgress
: undefined; progress={progress || 2}
return (
<Avatar
name={user?.name ?? ""}
email={user?.email ?? email}
imageUrl={avatarUrl}
size="sm" size="sm"
className="flex-shrink-0"
/> />
); <span className="text-[10px] text-light-900 dark:text-dark-950">
})} {completedItems}/{totalItems}
</span>
</div>
)}
{members.length > 0 && (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{members.map(({ user, email }) => {
const avatarUrl = user?.image
? getAvatarUrl(user.image)
: undefined;
return (
<Avatar
name={user?.name ?? ""}
email={user?.email ?? email}
imageUrl={avatarUrl}
size="sm"
/>
);
})}
</div>
)}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -14,10 +14,11 @@ import { generateUID } from "@kan/shared/utils";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown"; import CheckboxDropdown from "~/components/CheckboxDropdown";
import Editor from "~/components/Editor"; import Editor, { WorkspaceMember } from "~/components/Editor";
import Input from "~/components/Input"; import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle"; import Toggle from "~/components/Toggle";
import { useModalFormState } from "~/hooks/useModalFormState";
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";
@@ -45,21 +46,28 @@ export function NewCardForm({
queryParams, queryParams,
}: NewCardFormProps) { }: NewCardFormProps) {
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { closeModal, openModal } = useModal(); const { closeModal, openModal, modalStates, clearModalState } = useModal();
const utils = api.useUtils(); const utils = api.useUtils();
// persists the form values
const { formState, saveFormState } = useModalFormState<NewCardFormInput>({
modalType: "NEW_CARD",
initialValues: {
title: "",
description: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
},
resetOnClose: true,
});
const { register, handleSubmit, reset, setValue, watch } = const { register, handleSubmit, reset, setValue, watch } =
useForm<NewCardFormInput>({ useForm<NewCardFormInput>({
defaultValues: { values: formState,
title: "",
description: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
},
}); });
const labelPublicIds = watch("labelPublicIds") || []; const labelPublicIds = watch("labelPublicIds") || [];
@@ -67,11 +75,49 @@ export function NewCardForm({
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position"); const position = watch("position");
const title = watch("title"); const title = watch("title");
const description = watch("description");
// saving form state whenever form values change
useEffect(() => {
const subscription = watch((data) => {
saveFormState(data as NewCardFormInput);
});
return () => subscription.unsubscribe();
}, [watch, saveFormState]);
const { data: boardData } = api.board.byId.useQuery(queryParams, { const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId, enabled: !!boardPublicId,
}); });
// this adds the new created label to selected labels
useEffect(() => {
const newLabelId = modalStates["NEW_LABEL_CREATED"];
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
}
}, [modalStates, labelPublicIds]);
// this removes the deleted label from selected labels if it is selected
useEffect(() => {
if (boardData?.labels) {
const availableLabelIds = boardData.labels.map(label => label.publicId);
const newLabelId = modalStates["NEW_LABEL_CREATED"];
if (newLabelId && availableLabelIds.includes(newLabelId)) {
clearModalState("NEW_LABEL_CREATED");
}
const validLabelIds = labelPublicIds.filter(id =>
availableLabelIds.includes(id) || id === newLabelId
);
if (validLabelIds.length !== labelPublicIds.length) {
setValue("labelPublicIds", validLabelIds);
}
}
}, [boardData?.labels, labelPublicIds, modalStates["NEW_LABEL_CREATED"]]);
const createCard = api.card.create.useMutation({ const createCard = api.card.create.useMutation({
onMutate: async (args) => { onMutate: async (args) => {
await utils.board.byId.cancel(); await utils.board.byId.cancel();
@@ -131,17 +177,24 @@ export function NewCardForm({
}, },
onSuccess: async () => { onSuccess: async () => {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal(); if (!isCreateAnotherEnabled) {
// close modal (state will auto-clear due to resetOnClose: true)
closeModal();
} else {
// reset form for creating another card
const newFormState = {
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
};
reset(newFormState);
saveFormState(newFormState);
}
await utils.board.byId.invalidate(queryParams); await utils.board.byId.invalidate(queryParams);
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
}, },
}); });
@@ -264,8 +317,22 @@ export function NewCardForm({
<div className="mt-2"> <div className="mt-2">
<div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6"> <div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6">
<Editor <Editor
content="" content={description}
onChange={(value) => setValue("description", value)} onChange={(value) => {
setValue("description", value);
saveFormState({ ...formState, description: value });
}}
workspaceMembers={
boardData?.workspace.members?.map((member): WorkspaceMember => ({
publicId: member.publicId,
email: member.email,
user: member.user ? {
id: member.publicId,
name: member.user.name,
image: member.user.image ?? null,
} : null,
})) ?? []
}
/> />
</div> </div>
</div> </div>

View File

@@ -145,7 +145,12 @@ export function NewListForm({
/> />
<div> <div>
<Button type="submit">{t`Create list`}</Button> <Button
type="submit"
disabled={createList.isPending || !watch("name")}
>
{t`Create list`}
</Button>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,53 @@
import Link from "next/link";
import { env } from "next-runtime-env";
import { HiLink } from "react-icons/hi";
const UpdateBoardSlugButton = ({
handleOnClick,
workspaceSlug,
boardSlug,
isLoading,
}: {
handleOnClick: () => void;
workspaceSlug: string;
boardSlug: string;
isLoading: boolean;
}) => {
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
if (isLoading) {
return (
<div className="hidden h-[36px] w-[225px] animate-pulse rounded-full bg-light-200 dark:bg-dark-100 xl:flex" />
);
}
return (
<button
onClick={handleOnClick}
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
>
<div className="flex items-center">
<span>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn"
: env("NEXT_PUBLIC_BASE_URL")}
</span>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
<span>{workspaceSlug}</span>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
<span>{boardSlug}</span>
</div>
<Link
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
>
<HiLink className="h-[13px] w-[13px]" />
</Link>
</button>
);
};
export default UpdateBoardSlugButton;

View File

@@ -135,7 +135,7 @@ export function UpdateBoardSlugForm({
? t`This board URL has already been taken` ? t`This board URL has already been taken`
: undefined) : undefined)
} }
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`} prefix={`${env("NEXT_PUBLIC_KAN_ENV") === "cloud" ? "kan.bn" : env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@@ -152,7 +152,14 @@ export function UpdateBoardSlugForm({
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div> <div className="flex items-center gap-2">
<Button
variant="secondary"
href="/settings?edit=workspace_url"
onClick={closeModal}
>
{t`Edit workspace URL`}
</Button>
<Button <Button
type="submit" type="submit"
isLoading={updateBoardSlug.isPending} isLoading={updateBoardSlug.isPending}
@@ -160,7 +167,8 @@ export function UpdateBoardSlugForm({
!isDirty || !isDirty ||
updateBoardSlug.isPending || updateBoardSlug.isPending ||
errors.slug?.message !== undefined || errors.slug?.message !== undefined ||
isBoardSlugAvailable?.isReserved isBoardSlugAvailable?.isReserved ||
checkBoardSlugAvailability.isLoading
} }
> >
{t`Update`} {t`Update`}

View File

@@ -32,6 +32,7 @@ import Filters from "./components/Filters";
import List from "./components/List"; import List from "./components/List";
import { NewCardForm } from "./components/NewCardForm"; import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm"; import { NewListForm } from "./components/NewListForm";
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm"; import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
import VisibilityButton from "./components/VisibilityButton"; import VisibilityButton from "./components/VisibilityButton";
@@ -43,7 +44,7 @@ export default function BoardPage() {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { openModal, modalContentType, entityId } = useModal(); const { openModal, modalContentType, entityId, isOpen } = useModal();
const [selectedPublicListId, setSelectedPublicListId] = const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>(""); useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
@@ -233,6 +234,97 @@ export default function BoardPage() {
} }
}; };
const renderModalContent = () => {
return (
<>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
>
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LIST"}
>
<DeleteListConfirmation
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
</Modal>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_CARD"}
>
<NewCardForm
boardPublicId={boardId ?? ""}
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LIST"}
>
<NewListForm
boardPublicId={boardId ?? ""}
queryParams={queryParams}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LABEL"}
>
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
>
<LabelForm
boardPublicId={boardId ?? ""}
refetch={refetchBoard}
isEdit
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
>
<DeleteLabelConfirmation
refetch={refetchBoard}
labelPublicId={entityId}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPDATE_BOARD_SLUG"}
>
<UpdateBoardSlugForm
boardPublicId={boardId ?? ""}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
/>
</Modal>
</>
);
};
return ( return (
<> <>
<PageHead <PageHead
@@ -267,6 +359,12 @@ export default function BoardPage() {
)} )}
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0"> <div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
<UpdateBoardSlugButton
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
/>
<VisibilityButton <VisibilityButton
visibility={boardData?.visibility ?? "private"} visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
@@ -358,7 +456,7 @@ export default function BoardPage() {
<div <div
ref={provided.innerRef} ref={provided.innerRef}
{...provided.droppableProps} {...provided.droppableProps}
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600" className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-225px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600"
> >
{list.cards.map((card, index) => ( {list.cards.map((card, index) => (
<Draggable <Draggable
@@ -393,6 +491,7 @@ export default function BoardPage() {
title={card.title} title={card.title}
labels={card.labels} labels={card.labels}
members={card.members} members={card.members}
checklists={card.checklists ?? []}
/> />
</Link> </Link>
)} )}
@@ -414,55 +513,7 @@ export default function BoardPage() {
</> </>
) : null} ) : null}
</div> </div>
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}> {renderModalContent()}
{modalContentType === "DELETE_BOARD" && (
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
)}
{modalContentType === "DELETE_LIST" && (
<DeleteListConfirmation
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
)}
{modalContentType === "NEW_CARD" && (
<NewCardForm
boardPublicId={boardId ?? ""}
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
)}
{modalContentType === "NEW_LIST" && (
<NewListForm
boardPublicId={boardId ?? ""}
queryParams={queryParams}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "NEW_LABEL" && (
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
)}
{modalContentType === "EDIT_LABEL" && (
<LabelForm
boardPublicId={boardId ?? ""}
refetch={refetchBoard}
isEdit
/>
)}
{modalContentType === "DELETE_LABEL" && (
<DeleteLabelConfirmation
refetch={refetchBoard}
labelPublicId={entityId}
/>
)}
{modalContentType === "UPDATE_BOARD_SLUG" && (
<UpdateBoardSlugForm
boardPublicId={boardId ?? ""}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
/>
)}
</Modal>
</div> </div>
</> </>
); );

View File

@@ -19,7 +19,7 @@ export function BoardsList() {
if (isLoading) if (isLoading)
return ( return (
<div className="xxl:grid-cols-7 grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-7">
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
@@ -45,12 +45,12 @@ export function BoardsList() {
); );
return ( return (
<div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-7"> <div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-7">
{data?.map((board) => ( {data?.map((board) => (
<Link key={board.publicId} href={`boards/${board.publicId}`}> <Link key={board.publicId} href={`boards/${board.publicId}`}>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100"> <div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground /> <PatternedBackground />
<p className="text-md px-4 font-bold text-neutral-900 dark:text-dark-1000"> <p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name} {board.name}
</p> </p>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2"; import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
@@ -20,8 +21,8 @@ export default function BoardsPage() {
return ( return (
<> <>
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} /> <PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto h-full max-w-[1600px] px-5 py-6 md:px-8 md:py-8"> <div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="mb-8 flex w-full items-center justify-between"> <div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Boards`} {t`Boards`}
</h1> </h1>
@@ -49,7 +50,8 @@ export default function BoardsPage() {
</div> </div>
</div> </div>
<Modal> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
{modalContentType === "NEW_BOARD" && <NewBoardForm />} {modalContentType === "NEW_BOARD" && <NewBoardForm />}
{modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />} {modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />} {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}

View File

@@ -5,9 +5,11 @@ import { de, enGB, es, fr, it, nl } from "date-fns/locale";
import { import {
HiOutlineArrowLeft, HiOutlineArrowLeft,
HiOutlineArrowRight, HiOutlineArrowRight,
HiOutlineCheckCircle,
HiOutlinePencil, HiOutlinePencil,
HiOutlinePlus, HiOutlinePlus,
HiOutlineTag, HiOutlineTag,
HiOutlineTrash,
HiOutlineUserMinus, HiOutlineUserMinus,
HiOutlineUserPlus, HiOutlineUserPlus,
} from "react-icons/hi2"; } from "react-icons/hi2";
@@ -31,6 +33,11 @@ const dateLocaleMap = {
nl: nl, nl: nl,
} as const; } as const;
const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
};
const getActivityText = ({ const getActivityText = ({
type, type,
toTitle, toTitle,
@@ -39,6 +46,7 @@ const getActivityText = ({
memberName, memberName,
isSelf, isSelf,
label, label,
fromTitle,
}: { }: {
type: ActivityType; type: ActivityType;
toTitle: string | null; toTitle: string | null;
@@ -47,6 +55,7 @@ const getActivityText = ({
memberName: string | null; memberName: string | null;
isSelf: boolean; isSelf: boolean;
label: string | null; label: string | null;
fromTitle?: string | null;
}) => { }) => {
const ACTIVITY_TYPE_MAP = { const ACTIVITY_TYPE_MAP = {
"card.created": t`created the card`, "card.created": t`created the card`,
@@ -57,6 +66,14 @@ const getActivityText = ({
"card.updated.label.removed": t`removed a label from the card`, "card.updated.label.removed": t`removed a label from the card`,
"card.updated.member.added": t`added a member to the card`, "card.updated.member.added": t`added a member to the card`,
"card.updated.member.removed": t`removed a member from the card`, "card.updated.member.removed": t`removed a member from the card`,
"card.updated.checklist.added": t`added a checklist`,
"card.updated.checklist.renamed": t`renamed a checklist`,
"card.updated.checklist.deleted": t`deleted a checklist`,
"card.updated.checklist.item.added": t`added a checklist item`,
"card.updated.checklist.item.updated": t`updated 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.deleted": t`deleted a checklist item`,
} as const; } as const;
if (!(type in ACTIVITY_TYPE_MAP)) return null; if (!(type in ACTIVITY_TYPE_MAP)) return null;
@@ -71,7 +88,7 @@ const getActivityText = ({
if (type === "card.updated.title" && toTitle) { if (type === "card.updated.title" && toTitle) {
return ( return (
<Trans> <Trans>
updated the title to <TextHighlight>{toTitle}</TextHighlight> updated the title to <TextHighlight>{truncate(toTitle)}</TextHighlight>
</Trans> </Trans>
); );
} }
@@ -79,8 +96,9 @@ const getActivityText = ({
if (type === "card.updated.list" && fromList && toList) { if (type === "card.updated.list" && fromList && toList) {
return ( return (
<Trans> <Trans>
moved the card from <TextHighlight>{fromList}</TextHighlight> to moved the card from <TextHighlight>{truncate(fromList)}</TextHighlight>{" "}
<TextHighlight>{toList}</TextHighlight> to
<TextHighlight>{truncate(toList)}</TextHighlight>
</Trans> </Trans>
); );
} }
@@ -90,7 +108,8 @@ const getActivityText = ({
return ( return (
<Trans> <Trans>
assigned <TextHighlight>{memberName}</TextHighlight> to the card assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
card
</Trans> </Trans>
); );
} }
@@ -100,7 +119,8 @@ const getActivityText = ({
return ( return (
<Trans> <Trans>
unassigned <TextHighlight>{memberName}</TextHighlight> from the card unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
the card
</Trans> </Trans>
); );
} }
@@ -108,7 +128,7 @@ const getActivityText = ({
if (type === "card.updated.label.added" && label) { if (type === "card.updated.label.added" && label) {
return ( return (
<Trans> <Trans>
added label <TextHighlight>{label}</TextHighlight> added label <TextHighlight>{truncate(label)}</TextHighlight>
</Trans> </Trans>
); );
} }
@@ -116,7 +136,75 @@ const getActivityText = ({
if (type === "card.updated.label.removed" && label) { if (type === "card.updated.label.removed" && label) {
return ( return (
<Trans> <Trans>
removed label <TextHighlight>{label}</TextHighlight> removed label <TextHighlight>{truncate(label)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.checklist.added" && toTitle) {
return (
<Trans>
added checklist <TextHighlight>{truncate(toTitle)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.checklist.renamed" && toTitle) {
return (
<Trans>
renamed checklist <TextHighlight>{truncate(toTitle)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.checklist.deleted" && fromTitle) {
return (
<Trans>
deleted checklist <TextHighlight>{truncate(fromTitle)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.checklist.item.added" && toTitle) {
return (
<Trans>
added checklist item <TextHighlight>{truncate(toTitle)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.checklist.item.updated" && toTitle) {
return (
<Trans>
renamed checklist item to{" "}
<TextHighlight>{truncate(toTitle)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.checklist.item.completed" && toTitle) {
return (
<Trans>
completed checklist item{" "}
<TextHighlight>{truncate(toTitle)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.checklist.item.uncompleted" && toTitle) {
return (
<Trans>
marked checklist item <TextHighlight>{truncate(toTitle)}</TextHighlight>{" "}
as incomplete
</Trans>
);
}
if (type === "card.updated.checklist.item.deleted" && fromTitle) {
return (
<Trans>
deleted checklist item{" "}
<TextHighlight>{truncate(fromTitle)}</TextHighlight>
</Trans> </Trans>
); );
} }
@@ -133,6 +221,14 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
"card.updated.label.removed": <HiOutlineTag />, "card.updated.label.removed": <HiOutlineTag />,
"card.updated.member.added": <HiOutlineUserPlus />, "card.updated.member.added": <HiOutlineUserPlus />,
"card.updated.member.removed": <HiOutlineUserMinus />, "card.updated.member.removed": <HiOutlineUserMinus />,
"card.updated.checklist.added": <HiOutlinePlus />,
"card.updated.checklist.renamed": <HiOutlinePencil />,
"card.updated.checklist.deleted": <HiOutlineTrash />,
"card.updated.checklist.item.added": <HiOutlinePlus />,
"card.updated.checklist.item.updated": <HiOutlinePencil />,
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
} as const; } as const;
const getActivityIcon = ( const getActivityIcon = (
@@ -155,11 +251,13 @@ const ActivityList = ({
cardPublicId, cardPublicId,
isLoading, isLoading,
isAdmin, isAdmin,
isViewOnly,
}: { }: {
activities: NonNullable<GetCardByIdOutput>["activities"]; activities: NonNullable<GetCardByIdOutput>["activities"];
cardPublicId: string; cardPublicId: string;
isLoading: boolean; isLoading: boolean;
isAdmin?: boolean; isAdmin?: boolean;
isViewOnly?: boolean;
}) => { }) => {
const { data } = authClient.useSession(); const { data } = authClient.useSession();
const { locale } = useLocalisation(); const { locale } = useLocalisation();
@@ -177,6 +275,7 @@ const ActivityList = ({
memberName: activity.member?.user?.name ?? null, memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === data?.user.id, isSelf: activity.member?.user?.id === data?.user.id,
label: activity.label?.name ?? null, label: activity.label?.name ?? null,
fromTitle: activity.fromTitle ?? null,
}); });
if (activity.type === "card.updated.comment.added") if (activity.type === "card.updated.comment.added")
@@ -193,6 +292,7 @@ const ActivityList = ({
isEdited={!!activity.comment?.updatedAt} isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === data?.user.id} isAuthor={activity.comment?.createdBy === data?.user.id}
isAdmin={isAdmin ?? false} isAdmin={isAdmin ?? false}
isViewOnly={!!isViewOnly}
/> />
); );

View File

@@ -0,0 +1,197 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface ChecklistItemRowProps {
item: {
publicId: string;
title: string;
completed: boolean;
};
cardPublicId: string;
viewOnly?: boolean;
}
export default function ChecklistItemRow({
item,
cardPublicId,
viewOnly = false,
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const [title, setTitle] = useState("");
const [completed, setCompleted] = useState(false);
const updateItem = 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 as any;
const updatedChecklists = old.checklists.map((cl) => ({
...cl,
items: cl.items.map((ci) =>
ci.publicId === item.publicId
? {
...ci,
...(vars.title !== undefined ? { title: vars.title } : {}),
...(vars.completed !== undefined
? { completed: vars.completed }
: {}),
}
: ci,
),
}));
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 update checklist item`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});
const deleteItem = api.checklist.deleteItem.useMutation({
onMutate: async () => {
await utils.card.byId.cancel({ cardPublicId });
const previous = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (old) => {
if (!old) return old as any;
const updatedChecklists = old.checklists.map((cl) => ({
...cl,
items: cl.items.filter((ci) => ci.publicId !== item.publicId),
}));
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 delete checklist item`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});
// Only resync from props when switching items to avoid clobbering edits
useEffect(() => {
setTitle(item.title);
setCompleted(item.completed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.publicId]);
const sanitizeHtmlToPlainText = (html: string): string =>
html
.replace(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
const handleToggleCompleted = () => {
if (viewOnly) return;
setCompleted((prev) => !prev);
updateItem.mutate({
checklistItemPublicId: item.publicId,
completed: !completed,
});
};
const commitTitle = (rawHtml: string) => {
if (viewOnly) return;
const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) {
setTitle(item.title);
return;
}
setTitle(plain);
updateItem.mutate({
checklistItemPublicId: item.publicId,
title: plain,
});
};
const handleDelete = () => {
if (viewOnly) return;
deleteItem.mutate({ checklistItemPublicId: item.publicId });
};
return (
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
<label
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
>
<input
type="checkbox"
checked={completed}
onChange={(e) => {
if (viewOnly) {
e.preventDefault();
return;
}
handleToggleCompleted();
}}
className={twMerge(
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
viewOnly ? "cursor-default" : "cursor-pointer",
)}
/>
</label>
<div className="flex-1 pr-7">
<ContentEditable
html={title}
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)}
// @ts-expect-error - valid event
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)}
className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
viewOnly && "cursor-default",
)}
placeholder={t`Add details...`}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
commitTitle(title);
}
if (e.key === "Escape") {
e.preventDefault();
setTitle(item.title);
}
}}
/>
</div>
{!viewOnly && (
<button
type="button"
onClick={handleDelete}
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded-md p-1 text-light-900 group-hover:block hover:bg-light-200 dark:text-dark-700 dark:hover:bg-dark-200"
>
<HiXMark size={16} />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function ChecklistNameInput({
checklistPublicId,
initialName,
cardPublicId,
viewOnly = false,
}: {
checklistPublicId: string;
initialName: string;
cardPublicId: string;
viewOnly?: boolean;
}) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const [name, setName] = useState(initialName);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setName(initialName);
}, [initialName, checklistPublicId]);
const update = api.checklist.update.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 as any;
const updated = old.checklists.map((cl) =>
cl.publicId === checklistPublicId ? { ...cl, name: vars.name } : cl,
);
return { ...old, checklists: updated } as typeof old;
});
return { previous };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous)
utils.card.byId.setData({ cardPublicId }, ctx.previous);
showPopup({
header: t`Unable to update checklist`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});
const commit = () => {
if (viewOnly) return;
const trimmed = name.trim();
if (!trimmed || trimmed === initialName) return;
update.mutate({ checklistPublicId, name: trimmed });
};
return (
<input
ref={inputRef}
type="text"
value={name}
readOnly={viewOnly}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
commit();
inputRef.current?.blur();
}
if (e.key === "Escape") {
e.preventDefault();
setName(initialName);
inputRef.current?.blur();
}
}}
title={name}
className={twMerge(
"text-md block w-full truncate border-0 bg-transparent p-0 py-0 font-medium text-light-1000 outline-none focus:ring-0 dark:text-dark-1000",
viewOnly && "cursor-default",
)}
/>
);
}

View File

@@ -0,0 +1,142 @@
import { HiPlus, HiXMark } from "react-icons/hi2";
import CircularProgress from "~/components/CircularProgress";
import { useModal } from "~/providers/modal";
import ChecklistItemRow from "./ChecklistItemRow";
import ChecklistNameInput from "./ChecklistNameInput";
import NewChecklistItemForm from "./NewChecklistItemForm";
interface ChecklistItem {
publicId: string;
title: string;
completed: boolean;
}
interface Checklist {
publicId: string;
name: string;
items: ChecklistItem[];
}
interface ChecklistsProps {
checklists: Checklist[];
cardPublicId: string;
activeChecklistForm?: string | null;
setActiveChecklistForm?: (id: string | null) => void;
viewOnly?: boolean;
}
export default function Checklists({
checklists,
cardPublicId,
activeChecklistForm,
setActiveChecklistForm,
viewOnly = false,
}: ChecklistsProps) {
const { openModal } = useModal();
if (!checklists || checklists.length === 0) return null;
return (
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
{!viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
<div>
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</div>
</div>
)}
{viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
</div>
)}
</div>
<div className="ml-1">
{checklist.items.map((item) => (
<ChecklistItemRow
key={item.publicId}
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
))}
</div>
{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -27,6 +27,7 @@ const Comment = ({
isAuthor, isAuthor,
isAdmin, isAdmin,
isEdited = false, isEdited = false,
isViewOnly = false,
}: { }: {
publicId: string | undefined; publicId: string | undefined;
cardPublicId: string; cardPublicId: string;
@@ -38,6 +39,7 @@ const Comment = ({
isAuthor: boolean; isAuthor: boolean;
isAdmin: boolean; isAdmin: boolean;
isEdited: boolean; isEdited: boolean;
isViewOnly: boolean;
}) => { }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
@@ -124,7 +126,7 @@ const Comment = ({
</p> </p>
</div> </div>
{dropdownItems.length > 0 && ( {dropdownItems.length > 0 && !isViewOnly && (
<div className="absolute right-4 top-4"> <div className="absolute right-4 top-4">
<Dropdown items={dropdownItems}> <Dropdown items={dropdownItems}>
<HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" /> <HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />

View File

@@ -0,0 +1,71 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export function DeleteChecklistConfirmation({
cardPublicId,
checklistPublicId,
}: {
cardPublicId: string;
checklistPublicId: string;
}) {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const deleteChecklist = api.checklist.delete.useMutation({
onMutate: async () => {
await utils.card.byId.cancel({ cardPublicId });
const previous = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (old) => {
if (!old) return old as any;
const updatedChecklists = old.checklists.filter(
(cl) => cl.publicId !== checklistPublicId,
);
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 delete checklist`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
closeModal();
await utils.card.byId.invalidate({ cardPublicId });
},
});
const handleDelete = () => {
deleteChecklist.mutate({ checklistPublicId });
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{t`Are you sure you want to delete this checklist?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
{t`Cancel`}
</Button>
<Button onClick={handleDelete} isLoading={deleteChecklist.isPending}>
{t`Delete`}
</Button>
</div>
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2"; import {
HiEllipsisHorizontal,
HiOutlineCheckCircle,
HiOutlineTrash,
} from "react-icons/hi2";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -10,6 +14,13 @@ export default function BoardDropdown() {
return ( return (
<Dropdown <Dropdown
items={[ items={[
{
label: t`Add checklist`,
action: () => openModal("ADD_CHECKLIST"),
icon: (
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
),
},
{ {
label: t`Delete card`, label: t`Delete card`,
action: () => openModal("DELETE_CARD"), action: () => openModal("DELETE_CARD"),

View File

@@ -113,7 +113,7 @@ export default function LabelSelector({
</Menu.Button> </Menu.Button>
</div> </div>
) : ( ) : (
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200"> <Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 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">
<HiMiniPlus size={22} className="pr-2" /> <HiMiniPlus size={22} className="pr-2" />
{t`Add label`} {t`Add label`}
</Menu.Button> </Menu.Button>

View File

@@ -79,7 +79,7 @@ export default function ListSelector({
}} }}
asChild asChild
> >
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200"> <Menu.Button 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}
</Menu.Button> </Menu.Button>
</CheckboxDropdown> </CheckboxDropdown>

View File

@@ -112,7 +112,7 @@ export default function MemberSelector({
createNewItemLabel={t`Invite member`} createNewItemLabel={t`Invite member`}
asChild asChild
> >
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200"> <Menu.Button 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 }) => (

View File

@@ -0,0 +1,140 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { generateUID } from "@kan/shared/utils";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface NewChecklistFormInput {
name: string;
cardPublicId: string;
}
export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
const { closeModal, setModalState } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const { register, handleSubmit, reset, watch } =
useForm<NewChecklistFormInput>({
defaultValues: {
name: "Checklist",
cardPublicId,
},
});
const createChecklist = api.checklist.create.useMutation({
onMutate: async (args) => {
await utils.card.byId.cancel({ cardPublicId: args.cardPublicId });
const previous = utils.card.byId.getData({
cardPublicId: args.cardPublicId,
});
utils.card.byId.setData({ cardPublicId: args.cardPublicId }, (old) => {
if (!old) return old as any;
const placeholderChecklist = {
publicId: `PLACEHOLDER_${generateUID()}`,
name: args.name,
index: old.checklists.length,
items: [] as {
publicId: string;
title: string;
completed: boolean;
index: number;
}[],
};
return {
...old,
checklists: [...old.checklists, placeholderChecklist],
} as typeof old;
});
return { previous };
},
onSuccess: (data) => {
setModalState("ADD_CHECKLIST", { createdChecklistId: data.publicId });
},
onError: (_error, vars, ctx) => {
if (ctx?.previous)
utils.card.byId.setData(
{ cardPublicId: vars.cardPublicId },
ctx.previous,
);
showPopup({
header: t`Unable to create checklist`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async (_data, _error, vars) => {
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
},
});
useEffect(() => {
const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#checklist-name");
if (nameElement) nameElement.focus();
}, []);
const onSubmit = (data: NewChecklistFormInput) => {
closeModal();
reset({
name: "",
});
createChecklist.mutate({
name: data.name,
cardPublicId: data.cardPublicId,
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
{t`New checklist`}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Input
id="checklist-name"
placeholder={t`Checklist name`}
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"
disabled={createChecklist.isPending || !watch("name")}
>
{t`Create checklist`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,167 @@
import { t } from "@lingui/core/macro";
import { useEffect, useRef } from "react";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form";
import { generateUID } from "@kan/shared/utils";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface FormValues {
title: string;
}
interface NewChecklistItemFormProps {
checklistPublicId: string;
cardPublicId: string;
onCancel: () => void;
readOnly?: boolean;
}
const NewChecklistItemForm = ({
checklistPublicId,
cardPublicId,
onCancel,
readOnly = false,
}: NewChecklistItemFormProps) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { setValue, watch, reset, getValues } = useForm<FormValues>({
defaultValues: {
title: "",
},
});
const title = watch("title");
const editableRef = useRef<HTMLElement | null>(null);
const keepOpenRef = useRef(false);
const refocusEditable = () => {
const el = editableRef.current;
if (!el) return;
if (readOnly) return;
el.focus();
// hack to ensure the input is focused after creating new checklist item
setTimeout(() => {
el.focus();
}, 100);
};
const addChecklistItemMutation = api.checklist.createItem.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 as any;
const placeholder = {
publicId: `PLACEHOLDER_${generateUID()}`,
title: vars.title,
completed: false,
};
const updatedChecklists = old.checklists.map((cl) =>
cl.publicId === checklistPublicId
? { ...cl, items: [...cl.items, placeholder] }
: cl,
);
return { ...old, checklists: updatedChecklists } as typeof old;
});
if (keepOpenRef.current) {
reset({ title: "" });
if (editableRef.current) editableRef.current.innerHTML = "";
refocusEditable();
} else {
onCancel();
}
return { previous };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous)
utils.card.byId.setData({ cardPublicId }, ctx.previous);
showPopup({
header: t`Unable to add checklist item`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});
const sanitizeHtmlToPlainText = (html: string): string => {
return html
.replace(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
};
const submitIfNotEmpty = (keepOpen: boolean) => {
if (readOnly) return;
keepOpenRef.current = keepOpen;
const currentHtml = getValues("title") ?? "";
const plain = sanitizeHtmlToPlainText(currentHtml);
if (!plain) {
onCancel();
return;
}
addChecklistItemMutation.mutate({
checklistPublicId,
title: plain,
});
};
useEffect(() => {
refocusEditable();
}, []);
return (
<form onSubmit={(e) => e.preventDefault()}>
<div className="group relative flex h-9 items-center gap-3 rounded-md pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
<label className="relative inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-default items-center justify-center">
<input
type="checkbox"
disabled
className="peer h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 hover:border-light-500 hover:bg-transparent focus:outline-none focus:ring-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500"
/>
</label>
<div className="flex-1 pr-7">
<ContentEditable
id={`checklist-item-input-${checklistPublicId}`}
tabIndex={readOnly ? -1 : 0}
placeholder={t`Add an item...`}
html={title}
disabled={readOnly}
onChange={(e) => setValue("title", e.target.value)}
className="m-0 min-h-[20px] w-full p-0 text-sm leading-5 text-light-900 outline-none focus-visible:outline-none dark:text-dark-950"
onBlur={() => submitIfNotEmpty(false)}
onKeyDown={async (e) => {
if (readOnly) return;
if (e.key === "Enter") {
e.preventDefault();
submitIfNotEmpty(true);
}
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
innerRef={(el) => {
editableRef.current = (el as unknown as HTMLElement) ?? null;
}}
/>
</div>
</div>
</form>
);
};
export default NewChecklistItemForm;

View File

@@ -51,7 +51,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
return ( return (
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex w-full max-w-[1000px] flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6" className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
> >
<ContentEditable <ContentEditable
placeholder={t`Add a comment...`} placeholder={t`Add a comment...`}

View File

@@ -1,11 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal";
import { LabelForm } from "~/components/LabelForm"; import { LabelForm } from "~/components/LabelForm";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
@@ -18,12 +20,15 @@ import { api } from "~/utils/api";
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";
import Checklists from "./components/Checklists";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation"; import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
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 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";
import { NewChecklistForm } from "./components/NewChecklistForm";
import NewCommentForm from "./components/NewCommentForm"; import NewCommentForm from "./components/NewCommentForm";
interface FormValues { interface FormValues {
@@ -99,7 +104,7 @@ export function CardRightPanel() {
}) ?? []; }) ?? [];
return ( return (
<div className="h-full w-[360px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"> <div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<div className="mb-4 flex w-full flex-row"> <div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p> <p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
<ListSelector <ListSelector
@@ -131,9 +136,18 @@ export function CardRightPanel() {
export default function CardPage() { export default function CardPage() {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const { modalContentType, entityId } = useModal(); const {
modalContentType,
entityId,
openModal,
getModalState,
clearModalState,
} = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
null,
);
const cardId = Array.isArray(router.query.cardId) const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0] ? router.query.cardId[0]
@@ -180,6 +194,17 @@ export default function CardPage() {
}); });
}; };
// Open the new item form after creating a new checklist
useEffect(() => {
if (!card) return;
const state = getModalState("ADD_CHECKLIST");
const createdId: string | undefined = state?.createdChecklistId;
if (createdId) {
setActiveChecklistForm(createdId);
clearModalState("ADD_CHECKLIST");
}
}, [card, getModalState, clearModalState]);
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
@@ -187,91 +212,101 @@ export default function CardPage() {
<PageHead <PageHead
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`} title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
/> />
<div className="flex h-full flex-1 flex-row"> <div className="flex h-full flex-1 flex-row overflow-hidden">
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-6 md:p-8"> <div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col">
<div className="mb-8 flex w-full items-center justify-between"> <div className="p-6 md:p-8">
{!card && isLoading && ( <div className="mb-8 flex w-full items-center justify-between md:mt-6">
<div className="flex space-x-2"> {!card && isLoading && (
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
)} </div>
)}
{card && (
<>
<Link
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
href={`/boards/${board?.publicId}`}
>
{board?.name}
</Link>
<IoChevronForwardSharp
size={18}
className="mx-2 text-light-900 dark:text-dark-900"
/>
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div>
<input
type="text"
id="title"
{...register("title")}
onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
/>
</div>
</form>
<div className="flex">
<Dropdown />
</div>
</>
)}
{!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Card not found`}
</p>
)}
</div>
{card && ( {card && (
<> <>
<Link <div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]" <form
href={`/boards/${board?.publicId}`} onSubmit={handleSubmit(onSubmit)}
> className="w-full space-y-6"
{board?.name} >
</Link> <div className="mt-2">
<IoChevronForwardSharp <Editor
size={18} content={card.description}
className="mx-2 text-light-900 dark:text-dark-900" onChange={(e) => setValue("description", e)}
onBlur={() => handleSubmit(onSubmit)()}
workspaceMembers={board?.workspace.members ?? []}
/>
</div>
</form>
</div>
<Checklists
checklists={card.checklists}
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
/> />
<form <div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
onSubmit={handleSubmit(onSubmit)} <h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
className="w-full space-y-6" {t`Activity`}
> </h2>
<div> <div>
<input <ActivityList
type="text" cardPublicId={cardId}
id="title" activities={activities ?? []}
{...register("title")} isLoading={!card}
onBlur={handleSubmit(onSubmit)} isAdmin={workspace.role === "admin"}
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
/> />
</div> </div>
</form> <div className="mt-6">
<div className="flex"> <NewCommentForm cardPublicId={cardId} />
<Dropdown /> </div>
</div> </div>
</> </>
)} )}
{!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Card not found`}
</p>
)}
</div> </div>
{card && (
<>
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div className="mt-2">
<Editor
content={card.description}
onChange={(e) => setValue("description", e)}
onBlur={() => handleSubmit(onSubmit)()}
/>
</div>
</form>
</div>
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
{t`Activity`}
</h2>
<div>
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={!card}
isAdmin={workspace.role === "admin"}
/>
</div>
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</div>
</div>
</>
)}
</div> </div>
</div> </div>
<Modal> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
{modalContentType === "NEW_LABEL" && ( {modalContentType === "NEW_LABEL" && (
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} /> <LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} />
)} )}
@@ -301,6 +336,15 @@ export default function CardPage() {
/> />
)} )}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />} {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "ADD_CHECKLIST" && (
<NewChecklistForm cardPublicId={cardId} />
)}
{modalContentType === "DELETE_CHECKLIST" && (
<DeleteChecklistConfirmation
cardPublicId={cardId}
checklistPublicId={entityId}
/>
)}
</Modal> </Modal>
</div> </div>
</> </>

View File

@@ -40,7 +40,7 @@ export default function HomeView() {
</div> </div>
</div> </div>
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400"> {/* <div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50"> <div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
<Link <Link
href="https://news.ycombinator.com/item?id=44157177" href="https://news.ycombinator.com/item?id=44157177"
@@ -58,7 +58,7 @@ export default function HomeView() {
</div> </div>
</Link> </Link>
</div> </div>
</div> </div> */}
</div> </div>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl"> <p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">

View File

@@ -1,26 +1,43 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types"; import type { InviteMemberInput } from "@kan/api/types";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export function InviteMemberForm() { export function InviteMemberForm() {
const utils = api.useUtils(); const utils = api.useUtils();
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
const { closeModal } = useModal(); const { closeModal } = useModal();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const { register, handleSubmit } = useForm<InviteMemberInput>({ const InviteMemberSchema = z.object({
email: z.string().email({ message: t`Invalid email address` }),
workspacePublicId: z.string(),
});
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<InviteMemberInput>({
defaultValues: { defaultValues: {
email: "", email: "",
workspacePublicId: workspace.publicId || "", workspacePublicId: workspace.publicId || "",
}, },
resolver: zodResolver(InviteMemberSchema),
}); });
const refetchBoards = () => utils.board.all.refetch(); const refetchBoards = () => utils.board.all.refetch();
@@ -31,6 +48,24 @@ export function InviteMemberForm() {
await utils.workspace.byId.refetch(); await utils.workspace.byId.refetch();
await refetchBoards(); await refetchBoards();
}, },
onError: (error) => {
reset();
if (!isCreateAnotherEnabled) closeModal();
if (error.data?.code === "CONFLICT") {
showPopup({
header: t`Error inviting member`,
message: t`User is already a member of this workspace`,
icon: "error",
});
} else {
showPopup({
header: t`Error inviting member`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
}
},
}); });
const onSubmit = (data: InviteMemberInput) => { const onSubmit = (data: InviteMemberInput) => {
@@ -69,10 +104,16 @@ export function InviteMemberForm() {
await handleSubmit(onSubmit)(); await handleSubmit(onSubmit)();
} }
}} }}
errorMessage={errors.email?.message}
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Invite another`}
isChecked={isCreateAnotherEnabled}
onChange={() => setIsCreateAnotherEnabled(!isCreateAnotherEnabled)}
/>
<div> <div>
<Button <Button
type="submit" type="submit"

View File

@@ -7,6 +7,7 @@ import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
@@ -96,7 +97,7 @@ export default function MembersPage() {
</td> </td>
<td <td
className={twMerge( className={twMerge(
"w-auto min-w-[120px] sm:w-[35%] sm:min-w-[150px]", "w-auto min-w-[120px] overflow-visible sm:w-[35%] sm:min-w-[150px]",
isLastRow && "rounded-br-lg", isLastRow && "rounded-br-lg",
)} )}
> >
@@ -120,7 +121,7 @@ export default function MembersPage() {
</div> </div>
<div <div
className={twMerge( className={twMerge(
"relative", "relative z-50",
(workspace.role !== "admin" || showSkeleton) && "hidden", (workspace.role !== "admin" || showSkeleton) && "hidden",
)} )}
> >
@@ -154,7 +155,7 @@ export default function MembersPage() {
return ( return (
<> <>
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} /> <PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto h-full max-w-[1600px] p-6 px-5 md:px-28 md:py-12"> <div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between"> <div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Members`} {t`Members`}
@@ -171,10 +172,10 @@ export default function MembersPage() {
</div> </div>
<div className="mt-8 flow-root"> <div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div className="-mx-4 -my-2 sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full px-4 py-2 align-middle sm:px-6 lg:px-8"> <div className="inline-block min-w-full overflow-x-auto px-4 py-2 pb-16 align-middle sm:px-6 lg:px-8">
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg"> <div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600"> <table className="min-w-full divide-y divide-light-600 overflow-visible dark:divide-dark-600">
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200"> <thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
<tr> <tr>
<th <th
@@ -191,7 +192,7 @@ export default function MembersPage() {
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100"> <tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading && {!isLoading &&
data?.members.map((member, index) => ( data?.members.map((member, index) => (
<TableRow <TableRow
@@ -221,7 +222,8 @@ export default function MembersPage() {
</div> </div>
</div> </div>
<Modal> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />} {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />} {modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
{modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />} {modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}

View File

@@ -1,13 +1,15 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import { authClient } from "@kan/auth/client"; import Badge from "~/components/Badge";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList"; import ActivityList from "~/views/card/components/ActivityList";
import Checklists from "~/views/card/components/Checklists";
export function CardModal({ export function CardModal({
cardPublicId, cardPublicId,
@@ -20,7 +22,9 @@ export function CardModal({
}) { }) {
const router = useRouter(); const router = useRouter();
const { closeModal, isOpen } = useModal(); const { closeModal, isOpen } = useModal();
const { data: session } = authClient.useSession(); const [showFade, setShowFade] = useState(false);
const [showTopFade, setShowTopFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const { data, isLoading } = api.card.byId.useQuery( const { data, isLoading } = api.card.byId.useQuery(
{ {
@@ -33,85 +37,126 @@ export function CardModal({
const labels = data?.labels ?? []; const labels = data?.labels ?? [];
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
const isAtTop = scrollTop <= 5;
setShowFade(!isAtBottom);
setShowTopFade(!isAtTop);
};
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
handleScroll();
scrollElement.addEventListener("scroll", handleScroll);
return () => scrollElement.removeEventListener("scroll", handleScroll);
}, [data]);
return ( return (
<div className="flex h-full flex-1 flex-row"> <div className="flex h-full flex-1 flex-row">
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8"> <div className="h-full p-8">
<div className="flex w-full items-center justify-between">
<button
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={async (e) => {
e.preventDefault();
closeModal();
try {
await router.replace(
`/${workspaceSlug}/${boardSlug}`,
undefined,
{
shallow: true,
},
);
} catch (error) {
console.error(error);
}
}}
>
<HiXMark
size={18}
className="dark:text-dark-9000 text-light-900"
/>
</button>
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
<h1 className="font-medium leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>
</>
)}
</div>
<div className="mb-6"> <div className="mb-6">
{labels.map((label) => ( <div className="flex w-full items-center justify-between">
<div <button
key={label.publicId} className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
className="my-1 mr-1 inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[12px] font-medium text-light-800 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800" onClick={async (e) => {
e.preventDefault();
closeModal();
try {
await router.replace(
`/${workspaceSlug}/${boardSlug}`,
undefined,
{
shallow: true,
},
);
} catch (error) {
console.error(error);
}
}}
> >
<svg <HiXMark
fill={label.colourCode ?? "#3730a3"} size={18}
className="h-2 w-2" className="dark:text-dark-9000 text-light-900"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
<div>{label.name}</div>
</div>
))}
</div>
{data?.description && (
<div className="mb-10 flex w-full max-w-2xl justify-between">
<div className="mt-2">
<Editor content={data.description} readOnly />
</div>
</div>
)}
<div className="border-t-[1px] border-light-600 pb-4 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
{t`Activity`}
</h2>
<div>
{cardPublicId && (
<ActivityList
cardPublicId={cardPublicId}
activities={data?.activities ?? []}
isLoading={isLoading}
/> />
</button>
{isLoading ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
) : (
<>
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>
</>
)} )}
</div> </div>
{labels.length > 0 && (
<div className="mt-2">
{labels.map((label) => (
<Badge
key={label.publicId}
value={label.name}
iconLeft={<LabelIcon colourCode={label.colourCode} />}
/>
))}
</div>
)}
</div>
<div className="relative">
<div
ref={scrollRef}
className="h-full max-h-[425px] overflow-y-auto"
>
{data?.description && (
<div className="mb-10 flex w-full max-w-2xl justify-between">
<div className="mt-2">
<Editor
content={data.description}
readOnly
workspaceMembers={data?.list.board.workspace.members ?? []}
/>
</div>
</div>
)}
{data?.checklists && data.checklists.length > 0 && (
<Checklists
checklists={data.checklists}
cardPublicId={cardPublicId ?? ""}
viewOnly
/>
)}
<div className="border-t-[1px] border-light-600 pb-4 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
{t`Activity`}
</h2>
<div>
{cardPublicId && (
<ActivityList
cardPublicId={cardPublicId}
activities={data?.activities ?? []}
isLoading={isLoading}
isViewOnly={true}
/>
)}
</div>
</div>
</div>
{showTopFade && (
<div className="pointer-events-none absolute left-0 right-0 top-0 h-6 bg-gradient-to-b from-white/80 to-transparent dark:from-dark-100/80" />
)}
{showFade && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-white/80 to-transparent dark:from-dark-100/80" />
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,9 +2,11 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query"; import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { HiLink } from "react-icons/hi2"; import { HiLink, HiOutlineLockClosed } from "react-icons/hi2";
import Button from "~/components/Button";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
@@ -18,6 +20,10 @@ import Card from "~/views/board/components/Card";
import Filters from "~/views/board/components/Filters"; import Filters from "~/views/board/components/Filters";
import { CardModal } from "./CardModal"; import { CardModal } from "./CardModal";
const IS_CLOUD = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const HIDE_POWERED_BY =
env("NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY") === "true";
export default function PublicBoardView() { export default function PublicBoardView() {
const router = useRouter(); const router = useRouter();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
@@ -28,9 +34,14 @@ export default function PublicBoardView() {
? router.query.boardSlug[0] ? router.query.boardSlug[0]
: router.query.boardSlug; : router.query.boardSlug;
const workspaceSlug = Array.isArray(router.query.workspaceSlug)
? router.query.workspaceSlug[0]
: router.query.workspaceSlug;
const { data, isLoading } = api.board.bySlug.useQuery( const { data, isLoading } = api.board.bySlug.useQuery(
{ {
boardSlug: boardSlug ?? "", boardSlug: boardSlug ?? "",
workspaceSlug: workspaceSlug ?? "",
members: formatToArray(router.query.members), members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels), labels: formatToArray(router.query.labels),
}, },
@@ -105,26 +116,47 @@ export default function PublicBoardView() {
{data?.name} {data?.name}
</h1> </h1>
)} )}
<div className="flex items-center space-x-2"> {data && (
<Filters <div className="z-10 flex items-center space-x-2">
labels={data?.labels ?? []} <div className="inline-flex cursor-default items-center justify-center whitespace-nowrap rounded-md border-[1px] border-light-300 bg-light-50 px-3 py-2 text-sm font-semibold text-light-950 shadow-sm dark:border-dark-300 dark:bg-dark-50 dark:text-dark-950">
members={[]} <span className="mr-2">
isLoading={isLoading} <HiOutlineLockClosed />
/> </span>
</div> {t`View only`}
</div>
<Filters
labels={data.labels ?? []}
members={[]}
isLoading={isLoading}
/>
</div>
)}
</div> </div>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"> <div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
{isLoading ? ( {isLoading ? (
<div className="ml-[2rem] flex"> <div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" /> <div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
</div> </div>
) : !data ? (
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
<div className="flex flex-col items-center">
<HiOutlineLockClosed className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{t`Board not found`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{t`This board is private or does not exist`}
</p>
</div>
<Button href={`/${workspaceSlug}`}>{t`View workspace`}</Button>
</div>
) : ( ) : (
<div className="flex"> <div className="flex">
<div className="min-w-[2rem]" /> <div className="min-w-[2rem]" />
{data?.lists.map((list) => ( {data.lists.map((list) => (
<div <div
key={list.publicId} key={list.publicId}
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100" className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
@@ -148,6 +180,7 @@ export default function PublicBoardView() {
<Card <Card
title={card.title} title={card.title}
labels={card.labels} labels={card.labels}
checklists={card.checklists ?? []}
members={[]} members={[]}
/> />
</Link> </Link>
@@ -166,12 +199,26 @@ export default function PublicBoardView() {
<CopyBoardLink /> <CopyBoardLink />
</div> </div>
<Link {IS_CLOUD && (
className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000" <Link
href="/" className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000"
> href="/"
kan.bn >
</Link> kan.bn
</Link>
)}
{!IS_CLOUD && !HIDE_POWERED_BY && (
<a
href="https://kan.bn"
target="_blank"
rel="noreferrer noopener"
className="absolute right-[1rem] inline-flex items-center gap-[0.175rem] rounded-full border border-light-300 bg-light-50 px-3 py-1 text-[11px] font-medium text-light-950 shadow-sm transition-colors hover:bg-light-100 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100"
>
<span>{`Powered by`}</span>
<span className="font-semibold">kan.bn</span>
</a>
)}
</div> </div>
</div> </div>
<Popup /> <Popup />

View File

@@ -3,6 +3,8 @@ import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { useState } from "react"; import { useState } from "react";
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 { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
@@ -58,7 +60,7 @@ export default function Avatar({
} }
const fileExt = file.name.split(".").pop(); const fileExt = file.name.split(".").pop();
const fileName = `${userId}/avatar.${fileExt}`; const fileName = `${userId}/avatar-${generateUID()}.${fileExt}`;
setUploading(true); setUploading(true);

View File

@@ -1,9 +1,11 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2"; import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import { LanguageSelector } from "~/components/LanguageSelector"; import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -27,6 +29,9 @@ export default function SettingsPage() {
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const router = useRouter();
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { data } = api.user.getUser.useQuery(); const { data } = api.user.getUser.useQuery();
@@ -59,6 +64,24 @@ export default function SettingsPage() {
}; };
}, [refetchIntegrations]); }, [refetchIntegrations]);
useEffect(() => {
if (
router.query.edit === "workspace_url" &&
workspaceUrlSectionRef.current &&
scrollContainerRef.current
) {
const element = workspaceUrlSectionRef.current;
const container = scrollContainerRef.current;
container.scrollTop = element.offsetTop - 40;
const input = element.querySelector('input[type="text"]');
if (input instanceof HTMLInputElement) {
input.focus();
}
}
}, [router.query.edit]);
const { mutateAsync: disconnectTrello } = const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({ api.integration.disconnect.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -104,9 +127,12 @@ export default function SettingsPage() {
return ( return (
<> <>
<div className="flex h-full w-full flex-col overflow-hidden"> <div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto"> <div
ref={scrollContainerRef}
className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]"
>
<PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} /> <PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto max-w-[1600px] px-5 py-6 md:px-28 md:py-12"> <div className="m-auto max-w-[1100px] px-5 py-6 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between"> <div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Settings`} {t`Settings`}
@@ -134,14 +160,16 @@ export default function SettingsPage() {
workspaceName={workspace.name} workspaceName={workspace.name}
/> />
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000"> <div ref={workspaceUrlSectionRef}>
{t`Workspace URL`} <h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
</h2> {t`Workspace URL`}
<UpdateWorkspaceUrlForm </h2>
workspacePublicId={workspace.publicId} <UpdateWorkspaceUrlForm
workspaceUrl={workspace.slug ?? ""} workspacePublicId={workspace.publicId}
workspacePlan={workspace.plan ?? "free"} workspaceUrl={workspace.slug ?? ""}
/> workspacePlan={workspace.plan ?? "free"}
/>
</div>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000"> <h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Workspace description`} {t`Workspace description`}
@@ -273,7 +301,8 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<Modal> <Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />} {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "DELETE_WORKSPACE" && ( {modalContentType === "DELETE_WORKSPACE" && (
<DeleteWorkspaceConfirmation /> <DeleteWorkspaceConfirmation />

View File

@@ -23,19 +23,22 @@ services:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
# SMTP # Email
- 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}
- SMTP_SECURE=${SMTP_SECURE} - SMTP_SECURE=${SMTP_SECURE}
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
- PLUNK_API_KEY=${PLUNK_API_KEY}
- PLUNK_API_URL=${PLUNK_API_URL}
# S3 storage # S3 storage
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION} - S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT} - S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL} - NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}

View File

@@ -30,10 +30,14 @@ services:
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION} - S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT} - S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL} - NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# White label
- NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY=${NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY}
# Auth config (optional) # Auth config (optional)
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS} - NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP} - NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}

View File

@@ -1,5 +1,6 @@
import { boardRouter } from "./routers/board"; import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card"; import { cardRouter } from "./routers/card";
import { checklistRouter } from "./routers/checklist";
import { feedbackRouter } from "./routers/feedback"; import { feedbackRouter } from "./routers/feedback";
import { importRouter } from "./routers/import"; import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration"; import { integrationRouter } from "./routers/integration";
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
board: boardRouter, board: boardRouter,
card: cardRouter, card: cardRouter,
checklist: checklistRouter,
feedback: feedbackRouter, feedback: feedbackRouter,
label: labelRouter, label: labelRouter,
list: listRouter, list: listRouter,

View File

@@ -111,15 +111,21 @@ export const boardRouter = createTRPCRouter({
.meta({ .meta({
openapi: { openapi: {
method: "GET", method: "GET",
path: "/board/{boardSlug}", path: "/workspaces/{workspaceSlug}/boards/{boardSlug}",
summary: "Get board by slug", summary: "Get board by slug",
description: "Retrieves a board by its slug", description:
"Retrieves a board by its slug within a specific workspace",
tags: ["Boards"], tags: ["Boards"],
protect: false, protect: false,
}, },
}) })
.input( .input(
z.object({ z.object({
workspaceSlug: z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z boardSlug: z
.string() .string()
.min(3) .min(3)
@@ -131,10 +137,26 @@ export const boardRouter = createTRPCRouter({
) )
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>()) .output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const result = await boardRepo.getBySlug(ctx.db, input.boardSlug, { const workspace = await workspaceRepo.getBySlugWithBoards(
members: input.members ?? [], ctx.db,
labels: input.labels ?? [], input.workspaceSlug,
}); );
if (!workspace)
throw new TRPCError({
message: `Workspace with slug ${input.workspaceSlug} not found`,
code: "NOT_FOUND",
});
const result = await boardRepo.getBySlug(
ctx.db,
input.boardSlug,
workspace.id,
{
members: input.members ?? [],
labels: input.labels ?? [],
},
);
return result; return result;
}), }),
@@ -276,6 +298,21 @@ export const boardRouter = createTRPCRouter({
await assertUserInWorkspace(ctx.db, userId, board.workspaceId); await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
if (input.slug) {
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
ctx.db,
input.slug,
board.workspaceId,
);
if (!isBoardSlugAvailable) {
throw new TRPCError({
message: `Board slug ${input.slug} is not available`,
code: "BAD_REQUEST",
});
}
}
const result = await boardRepo.update(ctx.db, { const result = await boardRepo.update(ctx.db, {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
@@ -407,11 +444,23 @@ export const boardRouter = createTRPCRouter({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const board = await boardRepo.getWorkspaceAndBoardIdByBoardPublicId(
ctx.db,
input.boardPublicId,
);
if (!board)
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable( const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
ctx.db, ctx.db,
input.boardSlug, input.boardSlug,
input.boardPublicId, board.workspaceId,
); );
return { return {
isReserved: !isBoardSlugAvailable, isReserved: !isBoardSlugAvailable,
}; };

View File

@@ -0,0 +1,410 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
const checklistSchema = z.object({
publicId: z.string().length(12),
name: z.string().min(1).max(255),
});
const checklistItemSchema = z.object({
publicId: z.string().length(12),
title: z.string().min(1).max(500),
completed: z.boolean(),
});
export const checklistRouter = createTRPCRouter({
create: protectedProcedure
.meta({
openapi: {
summary: "Add a checklist to a card",
method: "POST",
path: "/cards/{cardPublicId}/checklists",
description: "Adds a checklist to a card",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().length(12),
name: z.string().min(1).max(255),
}),
)
.output(checklistSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
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",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
const newChecklist = await checklistRepo.create(ctx.db, {
name: input.name,
createdBy: userId,
cardId: card.id,
});
if (!newChecklist?.id)
throw new TRPCError({
message: `Failed to create checklist`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.added",
cardId: card.id,
toTitle: newChecklist.name,
createdBy: userId,
});
return newChecklist;
}),
update: protectedProcedure
.input(
z.object({
checklistPublicId: z.string().length(12),
name: z.string().min(1).max(255),
}),
)
.output(z.object({ publicId: z.string().length(12), name: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const checklist = await checklistRepo.getChecklistByPublicId(
ctx.db,
input.checklistPublicId,
);
if (!checklist)
throw new TRPCError({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
);
const previousName = checklist.name;
const updated = await checklistRepo.updateChecklistById(ctx.db, {
id: checklist.id,
name: input.name,
});
if (!updated)
throw new TRPCError({
message: `Failed to update checklist`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.renamed",
cardId: checklist.cardId,
fromTitle: previousName,
toTitle: updated.name,
createdBy: userId,
});
return updated;
}),
delete: protectedProcedure
.meta({
openapi: {
summary: "Delete a checklist",
method: "DELETE",
path: "/checklists/{checklistPublicId}",
description: "Deletes a checklist by its public ID",
tags: ["Cards"],
protect: true,
},
})
.input(z.object({ checklistPublicId: z.string().length(12) }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const checklist = await checklistRepo.getChecklistByPublicId(
ctx.db,
input.checklistPublicId,
);
if (!checklist)
throw new TRPCError({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
);
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
checklistId: checklist.id,
deletedAt: new Date(),
deletedBy: userId,
});
const deleted = await checklistRepo.softDeleteById(ctx.db, {
id: checklist.id,
deletedAt: new Date(),
deletedBy: userId,
});
if (!deleted)
throw new TRPCError({
message: `Failed to delete checklist`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.deleted",
cardId: checklist.cardId,
fromTitle: checklist.name,
createdBy: userId,
});
return { success: true };
}),
createItem: protectedProcedure
.meta({
openapi: {
summary: "Add an item to a checklist",
method: "POST",
path: "/checklists/{checklistPublicId}/items",
description: "Adds an item to a checklist",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
checklistPublicId: z.string().length(12),
title: z.string().min(1).max(500),
}),
)
.output(checklistItemSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const checklist = await checklistRepo.getChecklistByPublicId(
ctx.db,
input.checklistPublicId,
);
if (!checklist)
throw new TRPCError({
message: `Checklist with public ID ${input.checklistPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
checklist.card.list.board.workspace.id,
);
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
title: input.title,
createdBy: userId,
checklistId: checklist.id,
});
if (!newChecklistItem?.id)
throw new TRPCError({
message: `Failed to create checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.item.added",
cardId: checklist.cardId,
toTitle: newChecklistItem.title,
createdBy: userId,
});
return newChecklistItem;
}),
updateItem: protectedProcedure
.meta({
openapi: {
summary: "Update a checklist item",
method: "PUT",
path: "/checklists/items/{checklistItemPublicId}",
description: "Updates a checklist item (title/completed)",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
checklistItemPublicId: z.string().length(12),
title: z.string().min(1).max(500).optional(),
completed: z.boolean().optional(),
}),
)
.output(checklistItemSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist(
ctx.db,
input.checklistItemPublicId,
);
if (!item)
throw new TRPCError({
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
);
const previousTitle = item.title;
const updated = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
if (!updated)
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
// Log completion toggle
if (input.completed !== undefined) {
await cardActivityRepo.create(ctx.db, {
type: input.completed
? "card.updated.checklist.item.completed"
: "card.updated.checklist.item.uncompleted",
cardId: item.checklist.cardId,
toTitle: updated.title,
createdBy: userId,
});
}
// Log title change
if (input.title !== undefined && input.title !== previousTitle) {
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.item.updated",
cardId: item.checklist.cardId,
fromTitle: previousTitle,
toTitle: updated.title,
createdBy: userId,
});
}
return updated;
}),
deleteItem: protectedProcedure
.meta({
openapi: {
summary: "Delete a checklist item",
method: "DELETE",
path: "/checklists/items/{checklistItemPublicId}",
description: "Deletes a checklist item",
tags: ["Cards"],
protect: true,
},
})
.input(z.object({ checklistItemPublicId: z.string().length(12) }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist(
ctx.db,
input.checklistItemPublicId,
);
if (!item)
throw new TRPCError({
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
code: "NOT_FOUND",
});
await assertUserInWorkspace(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
);
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {
id: item.id,
deletedAt: new Date(),
deletedBy: userId,
});
if (!deleted)
throw new TRPCError({
message: `Failed to delete item`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.checklist.item.deleted",
cardId: item.checklist.cardId,
fromTitle: item.title,
createdBy: userId,
});
return { success: true };
}),
});

View File

@@ -8,6 +8,12 @@ import * as labelRepo from "@kan/db/repository/label.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth"; import { assertUserInWorkspace } from "../utils/auth";
const labelSchema = z.object({
publicId: z.string(),
name: z.string(),
colourCode: z.string().nullable(),
});
export const labelRouter = createTRPCRouter({ export const labelRouter = createTRPCRouter({
byPublicId: protectedProcedure byPublicId: protectedProcedure
.meta({ .meta({
@@ -21,7 +27,7 @@ export const labelRouter = createTRPCRouter({
}, },
}) })
.input(z.object({ labelPublicId: z.string().min(12) })) .input(z.object({ labelPublicId: z.string().min(12) }))
.output(z.custom<Awaited<ReturnType<typeof labelRepo.getByPublicId>>>()) .output(labelSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userId = ctx.user?.id; const userId = ctx.user?.id;
@@ -52,7 +58,11 @@ export const labelRouter = createTRPCRouter({
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
return result; return {
publicId: result.publicId,
name: result.name,
colourCode: result.colourCode,
};
}), }),
create: protectedProcedure create: protectedProcedure
.meta({ .meta({
@@ -72,7 +82,7 @@ export const labelRouter = createTRPCRouter({
colourCode: z.string().length(7), colourCode: z.string().length(7),
}), }),
) )
.output(z.custom<Awaited<ReturnType<typeof labelRepo.create>>>()) .output(labelSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id; const userId = ctx.user?.id;
@@ -108,7 +118,11 @@ export const labelRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
}); });
return result; return {
publicId: result.publicId,
name: result.name,
colourCode: result.colourCode,
};
}), }),
update: protectedProcedure update: protectedProcedure
.meta({ .meta({
@@ -128,7 +142,7 @@ export const labelRouter = createTRPCRouter({
colourCode: z.string().length(7), colourCode: z.string().length(7),
}), }),
) )
.output(z.custom<Awaited<ReturnType<typeof labelRepo.update>>>()) .output(labelSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id; const userId = ctx.user?.id;
@@ -153,7 +167,17 @@ export const labelRouter = createTRPCRouter({
const result = await labelRepo.update(ctx.db, input); const result = await labelRepo.update(ctx.db, input);
return result; if (!result)
throw new TRPCError({
message: `Failed to update label`,
code: "INTERNAL_SERVER_ERROR",
});
return {
publicId: result.publicId,
name: result.name,
colourCode: result.colourCode,
};
}), }),
delete: protectedProcedure delete: protectedProcedure
.meta({ .meta({

View File

@@ -1,7 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { authClient } from "@kan/auth/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
@@ -57,7 +56,7 @@ export const memberRouter = createTRPCRouter({
if (isInvitedEmailAlreadyMember) { if (isInvitedEmailAlreadyMember) {
throw new TRPCError({ throw new TRPCError({
message: `User with email ${input.email} is already a member of this workspace`, message: `User with email ${input.email} is already a member of this workspace`,
code: "BAD_REQUEST", code: "CONFLICT",
}); });
} }
@@ -78,16 +77,28 @@ export const memberRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
}); });
const { error } = await authClient.signIn.magicLink({ const { status } = await ctx.auth.api.signInMagicLink({
email: input.email, email: input.email,
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`, callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
}); });
if (error) if (!status) {
console.error("Failed to send magic link invitation:", {
email: input.email,
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
});
await memberRepo.softDelete(ctx.db, {
memberId: invite.id,
deletedAt: new Date(),
deletedBy: userId,
});
throw new TRPCError({ throw new TRPCError({
message: `Failed to send magic link to user with email ${input.email}`, message: `Failed to send magic link invitation to user with email ${input.email}.`,
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
}); });
}
return invite; return invite;
}), }),

View File

@@ -20,58 +20,73 @@ export interface User {
stripeCustomerId?: string | null | undefined; stripeCustomerId?: string | null | undefined;
} }
const createAuthWithHeaders = (
auth: ReturnType<typeof initAuth>,
headers: Headers,
) => {
return {
api: {
getSession: () => auth.api.getSession({ headers }),
signInMagicLink: (input: { email: string; callbackURL: string }) =>
auth.api.signInMagicLink({
headers,
body: { email: input.email, callbackURL: input.callbackURL },
}),
},
};
};
interface CreateContextOptions { interface CreateContextOptions {
user: User | null | undefined; user: User | null | undefined;
db: dbClient; db: dbClient;
auth: ReturnType<typeof createAuthWithHeaders>;
} }
export const createInnerTRPCContext = (opts: CreateContextOptions) => { export const createInnerTRPCContext = (opts: CreateContextOptions) => {
return { return {
user: opts.user, user: opts.user,
db: opts.db, db: opts.db,
auth: opts.auth,
}; };
}; };
export const createTRPCContext = async ({ req }: CreateNextContextOptions) => { export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
const db = createDrizzleClient(); const db = createDrizzleClient();
const auth = initAuth(db); const baseAuth = initAuth(db);
const headers = new Headers(req.headers as Record<string, string>);
const auth = createAuthWithHeaders(baseAuth, headers);
const session = await auth.api.getSession({ const session = await auth.api.getSession();
// @ts-expect-error
headers: new Headers(req.headers),
});
return createInnerTRPCContext({ db, user: session?.user }); return createInnerTRPCContext({ db, user: session?.user, auth });
}; };
export const createNextApiContext = async (req: NextApiRequest) => { export const createNextApiContext = async (req: NextApiRequest) => {
const db = createDrizzleClient(); const db = createDrizzleClient();
const auth = initAuth(db); const baseAuth = initAuth(db);
const headers = new Headers(req.headers as Record<string, string>);
const auth = createAuthWithHeaders(baseAuth, headers);
const session = await auth.api.getSession({ const session = await auth.api.getSession();
// @ts-expect-error
headers: new Headers(req.headers),
});
return createInnerTRPCContext({ db, user: session?.user }); return createInnerTRPCContext({ db, user: session?.user, auth });
}; };
export const createRESTContext = async ({ req }: CreateNextContextOptions) => { export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
const db = createDrizzleClient(); const db = createDrizzleClient();
const auth = initAuth(db); const baseAuth = initAuth(db);
const headers = new Headers(req.headers as Record<string, string>);
const auth = createAuthWithHeaders(baseAuth, headers);
let session; let session;
try { try {
session = await auth.api.getSession({ session = await auth.api.getSession();
// @ts-expect-error
headers: new Headers(req.headers),
});
} catch (error) { } catch (error) {
console.error("Error getting session, ", error); console.error("Error getting session, ", error);
throw error; throw error;
} }
return createInnerTRPCContext({ db, user: session?.user }); return createInnerTRPCContext({ db, user: session?.user, auth });
}; };
const t = initTRPC const t = initTRPC

View File

@@ -11,7 +11,7 @@ import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as schema from "@kan/db/schema"; import * as schema from "@kan/db/schema";
import { sendEmail } from "@kan/email"; import { cloudMailerClient, sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
export const configuredProviders = socialProviderList.reduce< export const configuredProviders = socialProviderList.reduce<
@@ -84,7 +84,7 @@ export const socialProvidersPlugin = () => ({
method: "GET", method: "GET",
}, },
async (ctx) => async (ctx) =>
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())), ctx.json(ctx.context.socialProviders.map((p) => p.id.toLowerCase())),
), ),
}, },
}); });
@@ -166,13 +166,34 @@ export const initAuth = (db: dbClient) => {
databaseHooks: { databaseHooks: {
user: { user: {
create: { create: {
before() { async before(user) {
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") { if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
return Promise.resolve(false); const pendingInvitation = await memberRepo.getByEmailAndStatus(
db,
user.email,
"invited",
);
if (!pendingInvitation) {
return Promise.resolve(false);
}
return Promise.resolve(true);
} }
return Promise.resolve(true); return Promise.resolve(true);
}, },
async after(user) { async after(user) {
if (cloudMailerClient) {
await cloudMailerClient.events.track({
event: "user-signup",
email: user.email,
data: {
name: user.name,
userId: user.id,
},
});
}
if ( if (
user.image && user.image &&
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!) !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
@@ -181,6 +202,7 @@ export const initAuth = (db: dbClient) => {
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",
credentials: { credentials: {
accessKeyId: env("S3_ACCESS_KEY_ID") ?? "", accessKeyId: env("S3_ACCESS_KEY_ID") ?? "",
secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "", secretAccessKey: env("S3_SECRET_ACCESS_KEY") ?? "",

View File

@@ -1,4 +1,4 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp";--> statement-breakpoint
CREATE TYPE "public"."board_visibility" AS ENUM('private', 'public');--> statement-breakpoint CREATE TYPE "public"."board_visibility" AS ENUM('private', 'public');--> statement-breakpoint
CREATE TYPE "public"."card_activity_type" AS ENUM('card.created', 'card.updated.title', 'card.updated.description', 'card.updated.index', 'card.updated.list', 'card.updated.label.added', 'card.updated.label.removed', 'card.updated.member.added', 'card.updated.member.removed', 'card.updated.comment.added', 'card.updated.comment.updated', 'card.updated.comment.deleted', 'card.archived');--> statement-breakpoint CREATE TYPE "public"."card_activity_type" AS ENUM('card.created', 'card.updated.title', 'card.updated.description', 'card.updated.index', 'card.updated.list', 'card.updated.label.added', 'card.updated.label.removed', 'card.updated.member.added', 'card.updated.member.removed', 'card.updated.comment.added', 'card.updated.comment.updated', 'card.updated.comment.deleted', 'card.archived');--> statement-breakpoint

View File

@@ -0,0 +1,84 @@
CREATE TABLE IF NOT EXISTS "card_checklist_item" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"title" varchar(500) NOT NULL,
"completed" boolean DEFAULT false NOT NULL,
"index" integer NOT NULL,
"checklistId" bigint NOT NULL,
"createdBy" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
CONSTRAINT "card_checklist_item_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "card_checklist_item" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "card_checklist" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"name" varchar(255) NOT NULL,
"index" integer NOT NULL,
"cardId" bigint NOT NULL,
"createdBy" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
CONSTRAINT "card_checklist_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "card_checklist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "integration" (
"provider" varchar(255) NOT NULL,
"userId" uuid NOT NULL,
"accessToken" varchar(255) NOT NULL,
"refreshToken" varchar(255),
"expiresAt" timestamp NOT NULL,
"createdAt" timestamp NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
);
--> statement-breakpoint
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_checklistId_card_checklist_id_fk" FOREIGN KEY ("checklistId") REFERENCES "public"."card_checklist"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,8 @@
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.added' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.renamed' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.deleted' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.added' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.updated' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.completed' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.uncompleted' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.deleted' BEFORE 'card.archived';

View File

@@ -1,6 +1,6 @@
{ {
"id": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501", "id": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501",
"prevId": "058e8521-2814-46f0-a33f-1bbd86f450ce", "prevId": "d170c8e0-bf75-4c71-abd9-0ecbeeb14003",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"tables": { "tables": {
@@ -92,13 +92,9 @@
"account_userId_user_id_fk": { "account_userId_user_id_fk": {
"name": "account_userId_user_id_fk", "name": "account_userId_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
} }
@@ -245,13 +241,9 @@
"apiKey_userId_user_id_fk": { "apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk", "name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey", "tableFrom": "apiKey",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
} }
@@ -320,13 +312,9 @@
"session_userId_user_id_fk": { "session_userId_user_id_fk": {
"name": "session_userId_user_id_fk", "name": "session_userId_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
} }
@@ -335,9 +323,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"columns": [ "columns": ["token"],
"token"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -523,52 +509,36 @@
"board_createdBy_user_id_fk": { "board_createdBy_user_id_fk": {
"name": "board_createdBy_user_id_fk", "name": "board_createdBy_user_id_fk",
"tableFrom": "board", "tableFrom": "board",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"board_deletedBy_user_id_fk": { "board_deletedBy_user_id_fk": {
"name": "board_deletedBy_user_id_fk", "name": "board_deletedBy_user_id_fk",
"tableFrom": "board", "tableFrom": "board",
"columnsFrom": [ "columnsFrom": ["deletedBy"],
"deletedBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"board_importId_import_id_fk": { "board_importId_import_id_fk": {
"name": "board_importId_import_id_fk", "name": "board_importId_import_id_fk",
"tableFrom": "board", "tableFrom": "board",
"columnsFrom": [ "columnsFrom": ["importId"],
"importId"
],
"tableTo": "import", "tableTo": "import",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "no action" "onDelete": "no action"
}, },
"board_workspaceId_workspace_id_fk": { "board_workspaceId_workspace_id_fk": {
"name": "board_workspaceId_workspace_id_fk", "name": "board_workspaceId_workspace_id_fk",
"tableFrom": "board", "tableFrom": "board",
"columnsFrom": [ "columnsFrom": ["workspaceId"],
"workspaceId"
],
"tableTo": "workspace", "tableTo": "workspace",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
} }
@@ -577,9 +547,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"board_publicId_unique": { "board_publicId_unique": {
"name": "board_publicId_unique", "name": "board_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -713,91 +681,63 @@
"card_activity_cardId_card_id_fk": { "card_activity_cardId_card_id_fk": {
"name": "card_activity_cardId_card_id_fk", "name": "card_activity_cardId_card_id_fk",
"tableFrom": "card_activity", "tableFrom": "card_activity",
"columnsFrom": [ "columnsFrom": ["cardId"],
"cardId"
],
"tableTo": "card", "tableTo": "card",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"card_activity_fromListId_list_id_fk": { "card_activity_fromListId_list_id_fk": {
"name": "card_activity_fromListId_list_id_fk", "name": "card_activity_fromListId_list_id_fk",
"tableFrom": "card_activity", "tableFrom": "card_activity",
"columnsFrom": [ "columnsFrom": ["fromListId"],
"fromListId"
],
"tableTo": "list", "tableTo": "list",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"card_activity_toListId_list_id_fk": { "card_activity_toListId_list_id_fk": {
"name": "card_activity_toListId_list_id_fk", "name": "card_activity_toListId_list_id_fk",
"tableFrom": "card_activity", "tableFrom": "card_activity",
"columnsFrom": [ "columnsFrom": ["toListId"],
"toListId"
],
"tableTo": "list", "tableTo": "list",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"card_activity_labelId_label_id_fk": { "card_activity_labelId_label_id_fk": {
"name": "card_activity_labelId_label_id_fk", "name": "card_activity_labelId_label_id_fk",
"tableFrom": "card_activity", "tableFrom": "card_activity",
"columnsFrom": [ "columnsFrom": ["labelId"],
"labelId"
],
"tableTo": "label", "tableTo": "label",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"card_activity_workspaceMemberId_workspace_members_id_fk": { "card_activity_workspaceMemberId_workspace_members_id_fk": {
"name": "card_activity_workspaceMemberId_workspace_members_id_fk", "name": "card_activity_workspaceMemberId_workspace_members_id_fk",
"tableFrom": "card_activity", "tableFrom": "card_activity",
"columnsFrom": [ "columnsFrom": ["workspaceMemberId"],
"workspaceMemberId"
],
"tableTo": "workspace_members", "tableTo": "workspace_members",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"card_activity_createdBy_user_id_fk": { "card_activity_createdBy_user_id_fk": {
"name": "card_activity_createdBy_user_id_fk", "name": "card_activity_createdBy_user_id_fk",
"tableFrom": "card_activity", "tableFrom": "card_activity",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"card_activity_commentId_card_comments_id_fk": { "card_activity_commentId_card_comments_id_fk": {
"name": "card_activity_commentId_card_comments_id_fk", "name": "card_activity_commentId_card_comments_id_fk",
"tableFrom": "card_activity", "tableFrom": "card_activity",
"columnsFrom": [ "columnsFrom": ["commentId"],
"commentId"
],
"tableTo": "card_comments", "tableTo": "card_comments",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
} }
@@ -806,9 +746,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"card_activity_publicId_unique": { "card_activity_publicId_unique": {
"name": "card_activity_publicId_unique", "name": "card_activity_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -838,26 +776,18 @@
"_card_workspace_members_cardId_card_id_fk": { "_card_workspace_members_cardId_card_id_fk": {
"name": "_card_workspace_members_cardId_card_id_fk", "name": "_card_workspace_members_cardId_card_id_fk",
"tableFrom": "_card_workspace_members", "tableFrom": "_card_workspace_members",
"columnsFrom": [ "columnsFrom": ["cardId"],
"cardId"
],
"tableTo": "card", "tableTo": "card",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"_card_workspace_members_workspaceMemberId_workspace_members_id_fk": { "_card_workspace_members_workspaceMemberId_workspace_members_id_fk": {
"name": "_card_workspace_members_workspaceMemberId_workspace_members_id_fk", "name": "_card_workspace_members_workspaceMemberId_workspace_members_id_fk",
"tableFrom": "_card_workspace_members", "tableFrom": "_card_workspace_members",
"columnsFrom": [ "columnsFrom": ["workspaceMemberId"],
"workspaceMemberId"
],
"tableTo": "workspace_members", "tableTo": "workspace_members",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
} }
@@ -865,10 +795,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"_card_workspace_members_cardId_workspaceMemberId_pk": { "_card_workspace_members_cardId_workspaceMemberId_pk": {
"name": "_card_workspace_members_cardId_workspaceMemberId_pk", "name": "_card_workspace_members_cardId_workspaceMemberId_pk",
"columns": [ "columns": ["cardId", "workspaceMemberId"]
"cardId",
"workspaceMemberId"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -959,52 +886,36 @@
"card_createdBy_user_id_fk": { "card_createdBy_user_id_fk": {
"name": "card_createdBy_user_id_fk", "name": "card_createdBy_user_id_fk",
"tableFrom": "card", "tableFrom": "card",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"card_deletedBy_user_id_fk": { "card_deletedBy_user_id_fk": {
"name": "card_deletedBy_user_id_fk", "name": "card_deletedBy_user_id_fk",
"tableFrom": "card", "tableFrom": "card",
"columnsFrom": [ "columnsFrom": ["deletedBy"],
"deletedBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"card_listId_list_id_fk": { "card_listId_list_id_fk": {
"name": "card_listId_list_id_fk", "name": "card_listId_list_id_fk",
"tableFrom": "card", "tableFrom": "card",
"columnsFrom": [ "columnsFrom": ["listId"],
"listId"
],
"tableTo": "list", "tableTo": "list",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"card_importId_import_id_fk": { "card_importId_import_id_fk": {
"name": "card_importId_import_id_fk", "name": "card_importId_import_id_fk",
"tableFrom": "card", "tableFrom": "card",
"columnsFrom": [ "columnsFrom": ["importId"],
"importId"
],
"tableTo": "import", "tableTo": "import",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "no action" "onDelete": "no action"
} }
@@ -1013,9 +924,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"card_publicId_unique": { "card_publicId_unique": {
"name": "card_publicId_unique", "name": "card_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1045,26 +954,18 @@
"_card_labels_cardId_card_id_fk": { "_card_labels_cardId_card_id_fk": {
"name": "_card_labels_cardId_card_id_fk", "name": "_card_labels_cardId_card_id_fk",
"tableFrom": "_card_labels", "tableFrom": "_card_labels",
"columnsFrom": [ "columnsFrom": ["cardId"],
"cardId"
],
"tableTo": "card", "tableTo": "card",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"_card_labels_labelId_label_id_fk": { "_card_labels_labelId_label_id_fk": {
"name": "_card_labels_labelId_label_id_fk", "name": "_card_labels_labelId_label_id_fk",
"tableFrom": "_card_labels", "tableFrom": "_card_labels",
"columnsFrom": [ "columnsFrom": ["labelId"],
"labelId"
],
"tableTo": "label", "tableTo": "label",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
} }
@@ -1072,10 +973,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"_card_labels_cardId_labelId_pk": { "_card_labels_cardId_labelId_pk": {
"name": "_card_labels_cardId_labelId_pk", "name": "_card_labels_cardId_labelId_pk",
"columns": [ "columns": ["cardId", "labelId"]
"cardId",
"labelId"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -1148,39 +1046,27 @@
"card_comments_cardId_card_id_fk": { "card_comments_cardId_card_id_fk": {
"name": "card_comments_cardId_card_id_fk", "name": "card_comments_cardId_card_id_fk",
"tableFrom": "card_comments", "tableFrom": "card_comments",
"columnsFrom": [ "columnsFrom": ["cardId"],
"cardId"
],
"tableTo": "card", "tableTo": "card",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"card_comments_createdBy_user_id_fk": { "card_comments_createdBy_user_id_fk": {
"name": "card_comments_createdBy_user_id_fk", "name": "card_comments_createdBy_user_id_fk",
"tableFrom": "card_comments", "tableFrom": "card_comments",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"card_comments_deletedBy_user_id_fk": { "card_comments_deletedBy_user_id_fk": {
"name": "card_comments_deletedBy_user_id_fk", "name": "card_comments_deletedBy_user_id_fk",
"tableFrom": "card_comments", "tableFrom": "card_comments",
"columnsFrom": [ "columnsFrom": ["deletedBy"],
"deletedBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
} }
@@ -1189,9 +1075,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"card_comments_publicId_unique": { "card_comments_publicId_unique": {
"name": "card_comments_publicId_unique", "name": "card_comments_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1253,13 +1137,9 @@
"feedback_createdBy_user_id_fk": { "feedback_createdBy_user_id_fk": {
"name": "feedback_createdBy_user_id_fk", "name": "feedback_createdBy_user_id_fk",
"tableFrom": "feedback", "tableFrom": "feedback",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
} }
@@ -1319,13 +1199,9 @@
"import_createdBy_user_id_fk": { "import_createdBy_user_id_fk": {
"name": "import_createdBy_user_id_fk", "name": "import_createdBy_user_id_fk",
"tableFrom": "import", "tableFrom": "import",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
} }
@@ -1334,9 +1210,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"import_publicId_unique": { "import_publicId_unique": {
"name": "import_publicId_unique", "name": "import_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1421,52 +1295,36 @@
"label_createdBy_user_id_fk": { "label_createdBy_user_id_fk": {
"name": "label_createdBy_user_id_fk", "name": "label_createdBy_user_id_fk",
"tableFrom": "label", "tableFrom": "label",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"label_boardId_board_id_fk": { "label_boardId_board_id_fk": {
"name": "label_boardId_board_id_fk", "name": "label_boardId_board_id_fk",
"tableFrom": "label", "tableFrom": "label",
"columnsFrom": [ "columnsFrom": ["boardId"],
"boardId"
],
"tableTo": "board", "tableTo": "board",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"label_importId_import_id_fk": { "label_importId_import_id_fk": {
"name": "label_importId_import_id_fk", "name": "label_importId_import_id_fk",
"tableFrom": "label", "tableFrom": "label",
"columnsFrom": [ "columnsFrom": ["importId"],
"importId"
],
"tableTo": "import", "tableTo": "import",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "no action" "onDelete": "no action"
}, },
"label_deletedBy_user_id_fk": { "label_deletedBy_user_id_fk": {
"name": "label_deletedBy_user_id_fk", "name": "label_deletedBy_user_id_fk",
"tableFrom": "label", "tableFrom": "label",
"columnsFrom": [ "columnsFrom": ["deletedBy"],
"deletedBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
} }
@@ -1475,9 +1333,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"label_publicId_unique": { "label_publicId_unique": {
"name": "label_publicId_unique", "name": "label_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1562,52 +1418,36 @@
"list_createdBy_user_id_fk": { "list_createdBy_user_id_fk": {
"name": "list_createdBy_user_id_fk", "name": "list_createdBy_user_id_fk",
"tableFrom": "list", "tableFrom": "list",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"list_deletedBy_user_id_fk": { "list_deletedBy_user_id_fk": {
"name": "list_deletedBy_user_id_fk", "name": "list_deletedBy_user_id_fk",
"tableFrom": "list", "tableFrom": "list",
"columnsFrom": [ "columnsFrom": ["deletedBy"],
"deletedBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"list_boardId_board_id_fk": { "list_boardId_board_id_fk": {
"name": "list_boardId_board_id_fk", "name": "list_boardId_board_id_fk",
"tableFrom": "list", "tableFrom": "list",
"columnsFrom": [ "columnsFrom": ["boardId"],
"boardId"
],
"tableTo": "board", "tableTo": "board",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"list_importId_import_id_fk": { "list_importId_import_id_fk": {
"name": "list_importId_import_id_fk", "name": "list_importId_import_id_fk",
"tableFrom": "list", "tableFrom": "list",
"columnsFrom": [ "columnsFrom": ["importId"],
"importId"
],
"tableTo": "import", "tableTo": "import",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "no action" "onDelete": "no action"
} }
@@ -1616,9 +1456,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"list_publicId_unique": { "list_publicId_unique": {
"name": "list_publicId_unique", "name": "list_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1688,9 +1526,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"columns": [ "columns": ["email"],
"email"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1722,9 +1558,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"workspace_slugs_slug_unique": { "workspace_slugs_slug_unique": {
"name": "workspace_slugs_slug_unique", "name": "workspace_slugs_slug_unique",
"columns": [ "columns": ["slug"],
"slug"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1818,39 +1652,27 @@
"workspace_members_userId_user_id_fk": { "workspace_members_userId_user_id_fk": {
"name": "workspace_members_userId_user_id_fk", "name": "workspace_members_userId_user_id_fk",
"tableFrom": "workspace_members", "tableFrom": "workspace_members",
"columnsFrom": [ "columnsFrom": ["userId"],
"userId"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"workspace_members_workspaceId_workspace_id_fk": { "workspace_members_workspaceId_workspace_id_fk": {
"name": "workspace_members_workspaceId_workspace_id_fk", "name": "workspace_members_workspaceId_workspace_id_fk",
"tableFrom": "workspace_members", "tableFrom": "workspace_members",
"columnsFrom": [ "columnsFrom": ["workspaceId"],
"workspaceId"
],
"tableTo": "workspace", "tableTo": "workspace",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "cascade" "onDelete": "cascade"
}, },
"workspace_members_deletedBy_user_id_fk": { "workspace_members_deletedBy_user_id_fk": {
"name": "workspace_members_deletedBy_user_id_fk", "name": "workspace_members_deletedBy_user_id_fk",
"tableFrom": "workspace_members", "tableFrom": "workspace_members",
"columnsFrom": [ "columnsFrom": ["deletedBy"],
"deletedBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
} }
@@ -1859,9 +1681,7 @@
"uniqueConstraints": { "uniqueConstraints": {
"workspace_members_publicId_unique": { "workspace_members_publicId_unique": {
"name": "workspace_members_publicId_unique", "name": "workspace_members_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1948,26 +1768,18 @@
"workspace_createdBy_user_id_fk": { "workspace_createdBy_user_id_fk": {
"name": "workspace_createdBy_user_id_fk", "name": "workspace_createdBy_user_id_fk",
"tableFrom": "workspace", "tableFrom": "workspace",
"columnsFrom": [ "columnsFrom": ["createdBy"],
"createdBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
}, },
"workspace_deletedBy_user_id_fk": { "workspace_deletedBy_user_id_fk": {
"name": "workspace_deletedBy_user_id_fk", "name": "workspace_deletedBy_user_id_fk",
"tableFrom": "workspace", "tableFrom": "workspace",
"columnsFrom": [ "columnsFrom": ["deletedBy"],
"deletedBy"
],
"tableTo": "user", "tableTo": "user",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "no action", "onUpdate": "no action",
"onDelete": "set null" "onDelete": "set null"
} }
@@ -1976,16 +1788,12 @@
"uniqueConstraints": { "uniqueConstraints": {
"workspace_publicId_unique": { "workspace_publicId_unique": {
"name": "workspace_publicId_unique", "name": "workspace_publicId_unique",
"columns": [ "columns": ["publicId"],
"publicId"
],
"nullsNotDistinct": false "nullsNotDistinct": false
}, },
"workspace_slug_unique": { "workspace_slug_unique": {
"name": "workspace_slug_unique", "name": "workspace_slug_unique",
"columns": [ "columns": ["slug"],
"slug"
],
"nullsNotDistinct": false "nullsNotDistinct": false
} }
}, },
@@ -1998,10 +1806,7 @@
"public.board_visibility": { "public.board_visibility": {
"name": "board_visibility", "name": "board_visibility",
"schema": "public", "schema": "public",
"values": [ "values": ["private", "public"]
"private",
"public"
]
}, },
"public.card_activity_type": { "public.card_activity_type": {
"name": "card_activity_type", "name": "card_activity_type",
@@ -2025,53 +1830,32 @@
"public.source": { "public.source": {
"name": "source", "name": "source",
"schema": "public", "schema": "public",
"values": [ "values": ["trello"]
"trello"
]
}, },
"public.status": { "public.status": {
"name": "status", "name": "status",
"schema": "public", "schema": "public",
"values": [ "values": ["started", "success", "failed"]
"started",
"success",
"failed"
]
}, },
"public.role": { "public.role": {
"name": "role", "name": "role",
"schema": "public", "schema": "public",
"values": [ "values": ["admin", "member", "guest"]
"admin",
"member",
"guest"
]
}, },
"public.member_status": { "public.member_status": {
"name": "member_status", "name": "member_status",
"schema": "public", "schema": "public",
"values": [ "values": ["invited", "active", "removed"]
"invited",
"active",
"removed"
]
}, },
"public.slug_type": { "public.slug_type": {
"name": "slug_type", "name": "slug_type",
"schema": "public", "schema": "public",
"values": [ "values": ["reserved", "premium"]
"reserved",
"premium"
]
}, },
"public.workspace_plan": { "public.workspace_plan": {
"name": "workspace_plan", "name": "workspace_plan",
"schema": "public", "schema": "public",
"values": [ "values": ["free", "pro", "enterprise"]
"free",
"pro",
"enterprise"
]
} }
}, },
"schemas": {}, "schemas": {},
@@ -2084,4 +1868,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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