Compare commits

..

44 Commits

Author SHA1 Message Date
Henry
0f92ac0d9b fix: update pnpm lockfile 2025-08-23 17:22:12 +02:00
Ridham Khandar
eb0ca9c738 fix: delay clearing modal content until fade-out on public board (#151)
* fix: closing the modal on the public board page clears the content before the modal fade out animation finishes

* refactor: enhance CheckboxDropdown component to handle empty states and improve item rendering

* Update apps/web/src/views/public/board/CardModal.tsx

Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>

---------

Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
2025-08-22 17:38:39 +02:00
Ridham Khandar
3233c35836 fix: board not found flicker on reloading public boards (#149)
* fix: board not found flicker on reloading public boards

* fix: using netx-theme's ThemeProvider

* chore: installing next-theme

* fix: theme issue on landing page

* fix: image theme hydration issue
2025-08-22 17:28:59 +02:00
Jyotirmoy Bandyopadhayaya
ba2fd7f695 feat: added oidc auth plugin for better auth (#147)
* feat: added oidc auth plugin for better auth

* chore: remove unused code

* chore: revert change to pnpm workspace file

* chore: remove unused code

* chore: remove unused code

* chore: remove unused code

* chore: added requested changes
2025-08-18 12:43:35 +01:00
Ridham Khandar
c7f7531b11 refactor: update modal implementation across multiple views (#150)
* fix: filter out null users from workspace members in board filters

* refactor: update modal implementation across multiple views
2025-08-18 12:41:44 +01:00
Vedant
4f57fc45a7 feat: add change password feature (#145)
* feat: add change password feature

* chore: update translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-08-17 21:21:19 +01:00
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
LovelessCodes
ec1b5d5acb feat: basic mobile support (#64)
* feat: add mobile navigation with responsive layout and theme controls

* feat: prevent workspace owners from removing themselves and update workspace dropdown styling

* feat: add useClickOutside hook and event listener utilities for improved UI interactions

* chore: improve mobile UI styling

* refactor: improve UI layout and buttons in boards/members views
feat: adjust /members layout

* fix: standardize layout containers and improve mobile navigation styling

* feat: update card view layout

* feat: add responsive layout to board view

* feat: readjust boards page layout

* feat: enhance table view on mobile for members page

* feat: extend mobile support with sliding side panels

* fix: prevent two panels from being open at the same time

* feat: close panels when clicking outside

* fix: adjust font-sizing for import source

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-07-02 11:58:10 +01:00
LovelessCodes
316f148663 feat: add template selection when creating new boards with predefined… (#42)
* feat: add template selection when creating new boards with predefined lists

* feat: extend templates with labels, update design and add translations

* feat: add validation and error states to new board form

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-29 21:53:37 +01:00
Henry
67050be27f chore: fix links in changelog 2025-06-28 22:11:22 +01:00
Henry
ff4448ebf4 chore: setup changelog (#97) 2025-06-28 22:06:58 +01:00
Henry
dd061c6d00 feat: localisation and multi-lang support (#95)
* feat: setup localisation with lingui

* Fix hydration and locale issues

* Add missing translations and tweak selector styling

* Extend language support

* Update lang support

* Extend lang support

* Fix build and add dyanmic imports
2025-06-25 21:30:24 +01:00
137 changed files with 14732 additions and 2497 deletions

View File

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

3
.gitignore vendored
View File

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

4
.vscode/launch.json vendored
View File

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

121
CHANGELOG.md Normal file
View File

@@ -0,0 +1,121 @@
# Changelog
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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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
### Added
- Localization and multi-language support
- URLs are now rendered as clickable links in card descriptions
### Changed
- Improved email configuration with optional SMTP authentication
## [0.2.1](https://github.com/kanbn/kan/compare/v0.2.0...v0.2.1) - 2025-06-19
### Added
- PostHog analytics integration
- Debug logging for email functionality
- Standalone mode support for Next.js
- Environment variable for configuring web port
- Docker Compose setup with PostgreSQL
### Changed
- SMTP authentication is now optional
- Added `SMTP_SECURE` configuration option
- Improved Docker build process with better context handling
### Fixed
- Default output mode for Next.js builds
- Container environment variable configuration
- Docker Compose volume naming
- Build context and static file copying in Docker
- Comment rendering inside contenteditable elements
## [0.2.0](https://github.com/kanbn/kan/compare/v0.1.0...v0.2.0) - 2025-06-10
### Added
- Trello integration with OAuth authentication and user field mapping
- Board visibility controls with dropdown menu and success notifications
- Markdown editor for card descriptions
- Email/password authentication for self-hosters
- Account deletion functionality with cascade delete for workspaces
- Enhanced scrollbar styles for dark mode
### Changed
- Simplified auth configuration to use `NEXT_PUBLIC_BASE_URL` instead of `BETTER_AUTH_URL`
- Updated self-hosting documentation
- Improved database schema with relation names
### Fixed
- List buttons stacking issue
- Editor onChange prop is now optional
- Renamed markdown prop to content in Editor component
- Router navigation for disabled signup redirects
## [0.1.0](https://github.com/kanbn/kan/releases/tag/v0.1.0) - 2025-06-02
### Added
- Initial release of Kan
- Core project management features
- Board, list, and card management
- User authentication system
- Workspace collaboration
- Comment system
- Activity logging
- Docker deployment support
- Self-hosting capabilities
### Changed
- Updated license link in footer

View File

@@ -138,35 +138,40 @@ pnpm dev
## Environment Variables 🔐
| Variable | Description | Required | Example |
| -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| Variable | Description | Required | Example |
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
See `.env.example` for a complete list of supported environment variables.

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}
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%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
"%231%20Hacker%20News/singular": de271b412c6c022d5c427d29fc70e0ba
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
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
@@ -14,28 +13,46 @@ checksums:
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
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%20member/singular: 11979625770516ca287e929381778e02
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
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
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
Approval/singular: 2f5426445800ecd5a3102aac940d459c
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
assigned%20%3C0%3E%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
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
Board/singular: 101ff39aab674c033c15ab978d0d8bac
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
@@ -45,30 +62,42 @@ checksums:
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
Boards/singular: 8fff51c5872e051beb9b718336665e80
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
Bug/singular: 4509fffdb5931f8905063c80cf802d71
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
Comments/singular: 502237cd1044491c48494aa60967a985
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
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%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
Contact/singular: 9afa39bc47019ee6dec6c74b6273967c
Contact%20us/singular: 694a45bc5c96f6f3a27873c1cf5a7707
Content%20Creation/singular: 4e6be22ade0188f408471325fe363575
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
Create%20card/singular: 32792935dd5837a9433909b04021c22b
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
Create%20list/singular: 858c3d514aa95765ec82114393199144
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
@@ -77,8 +106,11 @@ checksums:
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
Critical/singular: eb327cd411b50aee954f8d1d215d003a
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
Custom%20URLs%20are%20a%20premium%20feature.%20You'll%20be%20directed%20to%20upgrade%20your%20account./singular: b52e4f6122cf739c9551d29beb89b25e
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
@@ -87,6 +119,11 @@ checksums:
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
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
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
Display%20name/singular: 725f331065e9d594d720d07b1ae51a90
@@ -98,17 +135,27 @@ checksums:
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
email/singular: f31eb214738e037d58e26149797739df
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
Enter%20your%20new%20password/singular: c67251e3002b68bc20a7cf5de23e43ac
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
Error%20Changing%20Password/singular: ebecb5c1b72ba4b063117241f5ba4f2d
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
Error%20inviting%20member/singular: 91553b606a6ebed3534cd2e5635ce004
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
@@ -117,8 +164,11 @@ checksums:
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
Execution/singular: cbac4a3c721123cbc6a883560bf29800
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
Features/singular: 341ff316a339b106a178f0b8d362951b
Feedback/singular: 6fac88806e0c269a30777b283988c61c
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
@@ -127,6 +177,8 @@ checksums:
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
Free/singular: 0326365539c004f6088656f692602078
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
Full-time/singular: fe6e201f6676ae354111435cf6c76910
Fun/singular: 395bdc48e943762b6cde649f4a96771d
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
@@ -137,6 +189,8 @@ checksums:
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
How%20do%20I%20get%20a%20custom%20URL%3F/singular: 5b28fb95969c0dd19e1b9c9ab8a5fb29
How%20do%20I%20import%20my%20Trello%20boards%3F/singular: 44b30a727150cb93364af0a10346012a
@@ -145,23 +199,31 @@ checksums:
How%20is%20the%20project%20funded%3F/singular: ba7e936f7b1b5faa47df59a0ef990092
I%20acknowledge%20that%20all%20of%20my%20account%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: 2ffc824d0b1f9e883c2e87ed4b8b40b6
I%20acknowledge%20that%20all%20of%20the%20workspace%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: fe1183a7439299210d8ab7379b8f4d01
Ideas/singular: 27c24b56895defecd10b0171b39bc93f
Ideas%20to%20improve%20this%20page.../singular: 285decc2cc28403fae043d9b4de97a00
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
Invite/singular: 181884cea804cbde665f160811ee7ad0
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
Labels/singular: 6f15627a90002323eac018274b6922d6
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
Legal/singular: 00d3f08da61a21887009819cf93fb414
License/singular: ac2bcac75151e2fcd9eb768cea275a77
Light/singular: bb78ea2c6edd8662676c81a8a13ecb93
@@ -170,33 +232,53 @@ checksums:
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
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
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
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
Name/singular: 9368b5a047572b6051f334af5aa76819
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
New/singular: 126d036fae5fb6b629728ecb97e6195b
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
New%20card/singular: a33f6219a756127f91c2523cfe845a19
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
New%20label/singular: 890040da52a88c16c249a4ac78980f81
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
Planning/singular: 353f58c75248275fe091740607501610
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
@@ -209,25 +291,38 @@ checksums:
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
Public/singular: c834f11dab0881632aded3f01d4f8fa9
Publishing/singular: d9c8862c0180d9b7bd1272d5dc0d3ac3
Question/singular: 0576462ce60d4263d7c482463fcc9547
Questions%3F/singular: c7c3a47f8b230ce7a43f3c090f21d683
Quick%20Win/singular: a03ff4c0896f14b87455510bab203511
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
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
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
Resources/singular: ec7fb05ed963bb6781a35782b3475502
Review/singular: 299f75db25382980b2895622d7712927
Revoke/singular: be57685a85b6dfeaeb6eab4e9560b520
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
Role/singular: 53743bbb6ca938f5b893552e839d067f
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
Send/singular: 76a8497d7b546628b03bb81d5c1ce995
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
Senior/singular: 3fff865dc00435f82896fc302ea45630
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
@@ -237,44 +332,57 @@ checksums:
Sign%20up%20with%20/singular: 103e5d75506e6835b471695d799ff580
Simple%20pricing/singular: 5aa2bece93a71947b268498a58062937
Simple%2C%20visual%20task%20management%20that%20just%20works.%20Drag%20and%20drop%20cards%2C%20collaborate%20with%20your%20team%2C%20and%20get%20more%20done./singular: f4f138f83017d43dab2882cc08e07bdb
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
Success/singular: c43827becada6750f7a25890905f38b9
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
System/singular: 803281c327a02a2feba182f4a2b38311
Teams/singular: b63448c05270497973ac4407047dae02
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
Theme/singular: 21fe00b7a518089576fb83c08631107a
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
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%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%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
To%20Do/singular: d60813ea824f373462471e092d136eed
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
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%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
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
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
@@ -284,18 +392,25 @@ checksums:
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
Update/singular: 079fc039262fd31b10532929685c2d1b
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
updated%20the%20title%20to%20%3C0%3E%7BtoTitle%7D%3C%2F0%3E/singular: 70cdf681ec2b6ef8e14397bc1f93f945
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
User/singular: 61073457a5c3901084b557d065f876be
User%20is%20already%20a%20member%20of%20this%20workspace/singular: bc246386f0975f750f2133222bb4735e
Video/singular: 8050c90e4289b105a0780f0fdda6ff66
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
View%20only/singular: 21f5f7ba3b49050c4f406113479d6a7a
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
@@ -317,8 +432,10 @@ checksums:
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
Yearly/singular: 87f43e016c19cb25860f456549a2f431
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
You%20are%20about%20to%20change%20your%20password./singular: df4783d593505766d78c5bc08d3d48ac
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
@@ -327,6 +444,7 @@ checksums:
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
Your%20password%20has%20been%20changed./singular: 8f730f68d52b7efa727aa0784282ebcb
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512

View File

@@ -33,7 +33,10 @@ const config = {
remotePatterns: [
{
protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
hostname:
env("S3_FORCE_PATH_STYLE") === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{
protocol: "http",
@@ -51,4 +54,16 @@ const config = {
},
};
// Only allow external images when OIDC is configured (for OIDC provider avatars)
if (
env("OIDC_CLIENT_ID") &&
env("OIDC_CLIENT_SECRET") &&
env("OIDC_DISCOVERY_URL")
) {
config.images?.remotePatterns?.push({
protocol: "https",
hostname: "**",
});
}
export default config;

View File

@@ -33,6 +33,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:",
"@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
@@ -48,6 +49,7 @@
"next": "^15.3.4",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
"next-themes": "^0.4.6",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"posthog-js": "^1.254.0",

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

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import {
FaApple,
@@ -16,6 +16,7 @@ import {
FaGoogle,
FaLinkedin,
FaMicrosoft,
FaOpenid,
FaReddit,
FaSpotify,
FaTiktok,
@@ -33,6 +34,8 @@ import Button from "~/components/Button";
import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
type AuthProvider = SocialProvider | "oidc";
interface FormValues {
name?: string;
email: string;
@@ -141,16 +144,28 @@ const availableSocialProviders = {
name: "Zoom",
icon: SiZoom,
},
oidc: {
id: "oidc",
name: "OIDC",
icon: FaOpenid,
},
};
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | SocialProvider>(null);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
useState<null | AuthProvider>(null);
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const oidcProviderName = "OIDC";
// Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => {
const credentialsAllowed =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
setIsCredentialsEnabled(credentialsAllowed);
}, []);
const {
register,
@@ -226,13 +241,26 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
setIsLoginWithEmailPending(false);
};
const handleLoginWithProvider = async (provider: SocialProvider) => {
const handleLoginWithProvider = async (provider: AuthProvider) => {
setIsLoginWithProviderPending(provider);
setLoginError(null);
const { error } = await authClient.signIn.social({
provider,
callbackURL: "/boards",
});
let error;
if (provider === "oidc") {
// Use oauth2 signin for OIDC provider
const result = await authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/boards",
});
error = result.error;
} else {
// Use social signin for traditional social providers
const result = await authClient.signIn.social({
provider,
callbackURL: "/boards",
});
error = result.error;
}
setIsLoginWithProviderPending(null);
@@ -259,13 +287,17 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
}
return (
<Button
onClick={() => handleLoginWithProvider(key as SocialProvider)}
key={key}
onClick={() => handleLoginWithProvider(key as AuthProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
<Trans>Continue with {provider.name}</Trans>
<Trans>
Continue with{" "}
{key === "oidc" ? oidcProviderName : provider.name}
</Trans>
</Button>
);
})}

View File

@@ -5,7 +5,7 @@ import LoadingSpinner from "./LoadingSpinner";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
size?: "xs" | "sm" | "md" | "lg";
isLoading?: boolean;
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
@@ -28,6 +28,7 @@ const Button = ({
}: ButtonProps) => {
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",
size === "xs" && "text-xs px-2 py-1",
size === "sm" && "text-xs",
size === "lg" && "py-[0.65rem]",
fullWidth && "w-full",

View File

@@ -55,53 +55,67 @@ export default function CheckboxDropdown({
const renderMenuItems = (items: Item[], groupKey: string | null) => (
<>
{items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(groupKey, { key: item.key, value: item.value })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">{item.leftIcon}</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
{items.length > 0 ? (
items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
{item.value}
</label>
{handleEdit && (
<button
className="invisible ml-auto group-hover:visible"
onClick={(event) => {
event.stopPropagation();
handleEdit(item.key);
}}
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(groupKey, { key: item.key, value: item.value })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">{item.leftIcon}</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
<HiEllipsisHorizontal size={20} className="text-dark-900" />
</button>
)}
{item.value}
</label>
{handleEdit && (
<button
type="button"
className="invisible ml-auto group-hover:visible"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleEdit(item.key);
}}
>
<HiEllipsisHorizontal size={20} className="text-dark-900" />
</button>
)}
</div>
</Menu.Item>
))
) : (
!handleCreate && (
<div className="flex items-center p-2 text-[12px] text-dark-900">
No items
</div>
</Menu.Item>
))}
)
)}
{handleCreate && (
<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"
onClick={() => handleCreate()}
onClick={(e) => {
e.preventDefault();
handleCreate();
}}
>
<HiMiniPlus size={20} className="pr-1.5" />
{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,11 +1,96 @@
import Link from "next/link";
import { useRef, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
TbLayoutSidebarRightCollapse,
TbLayoutSidebarRightExpand,
} from "react-icons/tb";
import { api } from "~/utils/api";
import FeedbackButton from "./FeedbackButton";
import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { useTheme } from "next-themes";
import { WorkspaceProvider } from "~/providers/workspace";
import FeedbackModal from "./FeedbackModal";
import Modal from "./modal";
import SideNavigation from "./SideNavigation";
export default function Dashboard(props: { children: React.ReactNode }) {
const { data, isLoading } = api.user.getUser.useQuery();
interface DashboardProps {
children: React.ReactNode;
rightPanel?: React.ReactNode;
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({
children,
rightPanel,
hasRightPanel = false,
}: DashboardProps) {
const { theme } = useTheme();
const { modalContentType } = useModal();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const sideNavRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const sideNavButtonRef = useRef<HTMLButtonElement>(null);
const rightPanelButtonRef = useRef<HTMLButtonElement>(null);
const toggleSideNav = () => {
setIsSideNavOpen(!isSideNavOpen);
if (!isSideNavOpen) {
setIsRightPanelOpen(false);
}
};
const closeSideNav = () => {
setIsSideNavOpen(false);
};
const toggleRightPanel = () => {
setIsRightPanelOpen(!isRightPanelOpen);
if (!isRightPanelOpen) {
setIsSideNavOpen(false);
}
};
useClickOutside(sideNavRef, (event) => {
if (sideNavButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isSideNavOpen) {
setIsSideNavOpen(false);
}
});
useClickOutside(rightPanelRef, (event) => {
if (rightPanelButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isRightPanelOpen) {
setIsRightPanelOpen(false);
}
});
const isDarkMode = theme === "dark";
return (
<>
@@ -13,26 +98,86 @@ export default function Dashboard(props: { children: React.ReactNode }) {
html {
height: 100vh;
overflow: hidden;
min-width: 320px;
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
}
`}</style>
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
<div className="my-auto flex w-full items-center justify-between">
<Link href="/">
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<FeedbackButton />
</div>
<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">
{/* Mobile Header */}
<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
ref={rightPanelButtonRef}
onClick={toggleRightPanel}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
>
{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>
<div className="flex h-full w-full">
<SideNavigation
user={{ email: data?.email, image: data?.image }}
isLoading={isLoading}
/>
<div className="w-full overflow-hidden">{props.children}</div>
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
<div
ref={sideNavRef}
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
user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading}
onCloseSideNav={closeSideNav}
/>
</div>
<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="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 */}
{hasRightPanel && rightPanel && (
<div
ref={rightPanelRef}
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"
}`}
>
<div className="h-full">{rightPanel}</div>
</div>
)}
{/* Desktop Right Panel */}
{hasRightPanel && rightPanel && (
<div className="hidden md:block">{rightPanel}</div>
)}
</div>
</div>
</div>
</div>
</>

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -10,21 +12,23 @@ export function DeleteLabelConfirmation({
labelPublicId: string;
refetch: () => void;
}) {
const { closeModal } = useModal();
const { closeModal, closeModals } = useModal();
const { showPopup } = usePopup();
const deleteLabelMutation = api.label.delete.useMutation({
onSuccess: () => refetch(),
onSuccess: () => {
refetch();
closeModals(2);
},
onError: () =>
showPopup({
header: "Error deleting label",
message: "Please try again later, or contact customer support.",
header: t`Error deleting label`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
}),
});
const handleDeleteLabel = () => {
closeModal();
deleteLabelMutation.mutate({
labelPublicId,
});
@@ -34,17 +38,17 @@ export function DeleteLabelConfirmation({
<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">
Are you sure you want to delete this label?
{t`Are you sure you want to delete this label?`}
</h2>
<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>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
{t`Cancel`}
</Button>
<Button onClick={handleDeleteLabel}>Delete</Button>
<Button onClick={handleDeleteLabel}>{t`Delete`}</Button>
</div>
</div>
);

View File

@@ -17,7 +17,13 @@ import {
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import {
HiH1,
HiH2,
@@ -33,6 +39,9 @@ import {
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import tippy from "tippy.js";
import Mention from "@tiptap/extension-mention";
import Avatar from "./Avatar";
import { getAvatarUrl } from "~/utils/helpers";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -68,6 +77,16 @@ export interface RenderSuggestionsProps {
command: (item: SlashCommandItem) => void;
}
export type WorkspaceMember = {
publicId: string;
user: {
id: string;
name: string | null;
image: string | null;
} | null;
email: string;
};
const CommandsList = forwardRef<
{ 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>({
name: "slash-commands",
addOptions() {
@@ -284,11 +422,13 @@ export default function Editor({
onChange,
onBlur,
readOnly = false,
workspaceMembers,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -299,7 +439,7 @@ export default function Editor({
Placeholder.configure({
placeholder: readOnly
? ""
: t`Add description... (type '/' to open commands)`,
: t`Add description... (type '/' to open commands or '@' to mention)`,
}),
Link.configure({
openOnClick: true,
@@ -320,6 +460,38 @@ export default function Editor({
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,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
@@ -343,14 +515,23 @@ export default function Editor({
editable: !readOnly,
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 (
<div ref={containerRef}>
<style jsx global>{`
.tiptap p.is-empty::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
@@ -359,11 +540,19 @@ export default function Editor({
.tiptap p {
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>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent
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>
);

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

@@ -55,7 +55,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<div className="flex w-full flex-col gap-1">
<div className="relative flex">
{prefix && (
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-sm dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
{prefix}
</div>
)}

View File

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

View File

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

View File

@@ -19,16 +19,19 @@ export function NewWorkspaceForm() {
const { showPopup } = usePopup();
const { switchWorkspace } = useWorkspace();
const { register, handleSubmit } = useForm<FormValues>();
const utils = api.useUtils();
const createWorkspace = api.workspace.create.useMutation({
onSuccess: (values) => {
if (values.publicId && values.name) {
utils.workspace.all.invalidate();
switchWorkspace({
publicId: values.publicId,
name: values.name,
description: values.description,
slug: values.slug,
plan: values.plan,
role: "admin",
});
closeModal();
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { Button } from "@headlessui/react";
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 boardsIconLight from "~/assets/boards-light.json";
@@ -10,11 +18,12 @@ import settingsIconLight from "~/assets/settings-light.json";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useTheme } from "~/providers/theme";
import { useTheme } from "next-themes";
interface SideNavigationProps {
user: UserType;
isLoading: boolean;
onCloseSideNav?: () => void;
}
interface UserType {
@@ -25,14 +34,34 @@ interface UserType {
export default function SideNavigation({
user,
isLoading,
onCloseSideNav,
}: SideNavigationProps) {
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 { activeTheme } = useTheme();
const { theme, resolvedTheme } = useTheme();
const isDarkMode = activeTheme === "dark";
const isDarkMode = resolvedTheme === "dark";
const navigation = [
{
@@ -52,11 +81,50 @@ export default function SideNavigation({
},
];
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
return (
<>
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
<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>
<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">
{navigation.map((item) => (
<li key={item.name}>
@@ -65,16 +133,23 @@ export default function SideNavigation({
current={pathname.includes(item.href)}
name={item.name}
json={item.icon}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
</li>
))}
</ul>
</div>
<UserMenu
email={user.email ?? ""}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
/>
<div className="space-y-3">
<UserMenu
email={user.email ?? ""}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
</div>
</nav>
</>
);

View File

@@ -1,23 +1,22 @@
import { CgDarkMode } from "react-icons/cg";
import { useTheme } from "~/providers/theme";
import { useTheme } from "next-themes";
const ThemeToggle = () => {
const { themePreference, switchTheme } = useTheme();
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
switchTheme(themePreference === "light" ? "dark" : "light");
setTheme(theme === "light" ? "dark" : "light");
};
return (
<button
onClick={toggleTheme}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
aria-label={`Switch to ${themePreference === "light" ? "dark" : "light"} theme`}
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} theme`}
>
<CgDarkMode
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
themePreference === "dark" ? "rotate-180" : "rotate-0"
theme === "dark" ? "rotate-180" : "rotate-0"
}`}
/>
</button>

View File

@@ -1,50 +1,79 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { useTheme } from "~/providers/theme";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useModal } from "~/providers/modal";
import { useTheme } from "next-themes";
import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
email: string;
isLoading: boolean;
}
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
isCollapsed?: boolean;
onCloseSideNav?: () => void;
}
export default function UserMenu({
imageUrl,
email,
isLoading,
isCollapsed = false,
onCloseSideNav,
}: UserMenuProps) {
const router = useRouter();
const { themePreference, switchTheme } = useTheme();
const { theme, setTheme } = useTheme();
const { openModal } = useModal();
const isMobile = useIsMobile();
const handleLogout = async () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
await authClient.signOut();
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;
return (
<Menu as="div" className="relative inline-block w-full text-left">
<div>
{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="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>
) : (
<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 ? (
<Image
src={avatarUrl}
@@ -64,7 +93,14 @@ export default function UserMenu({
</svg>
</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>
)}
</div>
@@ -78,7 +114,12 @@ export default function UserMenu({
leaveFrom="transform opacity-100 scale-100"
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="p-1">
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
@@ -86,12 +127,12 @@ export default function UserMenu({
</div>
<Menu.Item>
<button
onClick={() => switchTheme("system")}
onClick={() => setTheme("system")}
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
className={classNames(
themePreference === "system" ? "visible" : "invisible",
className={twMerge(
theme === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
@@ -100,12 +141,12 @@ export default function UserMenu({
</Menu.Item>
<Menu.Item>
<button
onClick={() => switchTheme("dark")}
onClick={() => setTheme("dark")}
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
className={classNames(
themePreference === "dark" ? "visible" : "invisible",
className={twMerge(
theme === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
@@ -114,12 +155,12 @@ export default function UserMenu({
</Menu.Item>
<Menu.Item>
<button
onClick={() => switchTheme("light")}
onClick={() => setTheme("light")}
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
className={classNames(
themePreference === "light" ? "visible" : "invisible",
className={twMerge(
theme === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
@@ -127,6 +168,38 @@ export default function UserMenu({
</button>
</Menu.Item>
</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">
<Menu.Item>
<button

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export const env = createEnv({
s.split(",").every((l) => z.string().url().safeParse(l).success),
)
.optional(),
POSTGRES_URL: z.string().url(),
POSTGRES_URL: z.string().url().optional().or(z.literal("")),
TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
@@ -51,6 +51,10 @@ export const env = createEnv({
VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(),
// Generic OIDC Provider
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_DISCOVERY_URL: z.string().optional(),
REDDIT_CLIENT_ID: z.string().optional(),
REDDIT_CLIENT_SECRET: z.string().optional(),
ROBLOX_CLIENT_ID: z.string().optional(),
@@ -69,6 +73,7 @@ export const env = createEnv({
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
},
@@ -100,6 +105,13 @@ export const env = createEnv({
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.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.
@@ -118,6 +130,8 @@ export const env = createEnv({
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
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:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

@@ -0,0 +1,59 @@
import type { RefObject } from "react";
import { useEventListener } from "./useEventListener";
/** Supported event types. */
type EventType =
| "mousedown"
| "mouseup"
| "touchstart"
| "touchend"
| "focusin"
| "focusout";
/**
* Custom hook that handles clicks outside a specified element.
* @template T - The type of the element's reference.
* @param {RefObject<T> | RefObject<T>[]} ref - The React ref object(s) representing the element(s) to watch for outside clicks.
* @param {(event: MouseEvent | TouchEvent | FocusEvent) => void} handler - The callback function to be executed when a click outside the element occurs.
* @param {EventType} [eventType] - The mouse event type to listen for (optional, default is 'mousedown').
* @param {?AddEventListenerOptions} [eventListenerOptions] - The options object to be passed to the `addEventListener` method (optional).
* @returns {void}
* @example
* ```tsx
* const containerRef = useRef(null);
* useClickOutside([containerRef], () => {
* // Handle clicks outside the container.
* });
* ```
*/
export function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T> | RefObject<T>[],
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
eventType: EventType = "mousedown",
eventListenerOptions: AddEventListenerOptions = {},
): void {
useEventListener(
eventType,
(event) => {
const target = event.target as Node;
// Do nothing if the target is not connected element with document
if (!target.isConnected) {
return;
}
const isOutside = Array.isArray(ref)
? ref
.filter((r) => Boolean(r.current))
.every((r) => r.current && !r.current.contains(target))
: ref.current && !ref.current.contains(target);
if (isOutside) {
handler(event);
}
},
undefined,
eventListenerOptions,
);
}

View File

@@ -0,0 +1,120 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
// MediaQueryList Event based useEventListener interface
function useEventListener<K extends keyof MediaQueryListEventMap>(
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
element: RefObject<MediaQueryList>,
options?: boolean | AddEventListenerOptions,
): void;
// Window Event based useEventListener interface
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions,
): void;
// Element Event based useEventListener interface
function useEventListener<
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
T extends Element = K extends keyof HTMLElementEventMap
? HTMLDivElement
: SVGElement,
>(
eventName: K,
handler:
| ((event: HTMLElementEventMap[K]) => void)
| ((event: SVGElementEventMap[K]) => void),
element: RefObject<T>,
options?: boolean | AddEventListenerOptions,
): void;
// Document Event based useEventListener interface
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: RefObject<Document>,
options?: boolean | AddEventListenerOptions,
): void;
/**
* Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
* @template KW - The type of event for window events.
* @template KH - The type of event for HTML or SVG element events.
* @template KM - The type of event for media query list events.
* @template T - The type of the DOM element (default is `HTMLElement`).
* @param {KW | KH | KM} eventName - The name of the event to listen for.
* @param {(event: WindowEventMap[KW] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | MediaQueryListEventMap[KM] | Event) => void} handler - The event handler function.
* @param {RefObject<T>} [element] - The DOM element or media query list to attach the event listener to (optional).
* @param {boolean | AddEventListenerOptions} [options] - An options object that specifies characteristics about the event listener (optional).
* @public
* @see [Documentation](https://usehooks-ts.com/react-hook/use-event-listener)
* @example
* ```tsx
* // Example 1: Attach a window event listener
* useEventListener('resize', handleResize);
* ```
* @example
* ```tsx
* // Example 2: Attach a document event listener with options
* const elementRef = useRef(document);
* useEventListener('click', handleClick, elementRef, { capture: true });
* ```
* @example
* ```tsx
* // Example 3: Attach an element event listener
* const buttonRef = useRef<HTMLButtonElement>(null);
* useEventListener('click', handleButtonClick, buttonRef);
* ```
*/
function useEventListener<
KW extends keyof WindowEventMap,
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
KM extends keyof MediaQueryListEventMap,
T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
>(
eventName: KW | KH | KM,
handler: (
event:
| WindowEventMap[KW]
| HTMLElementEventMap[KH]
| SVGElementEventMap[KH]
| MediaQueryListEventMap[KM]
| Event,
) => void,
element?: RefObject<T>,
options?: boolean | AddEventListenerOptions,
) {
// Create a ref that stores handler
const savedHandler = useRef(handler);
useIsomorphicLayoutEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
// Define the listening target
const targetElement: T | Window = element?.current ?? window;
if (!(targetElement && targetElement.addEventListener)) return;
// Create event listener that calls handler function stored in ref
const listener: typeof handler = (event) => {
savedHandler.current(event);
};
targetElement.addEventListener(eventName, listener, options);
// Remove event listener on cleanup
return () => {
targetElement.removeEventListener(eventName, listener, options);
};
}, [eventName, element, options]);
}
export { useEventListener };

View File

@@ -0,0 +1,15 @@
import { useEffect, useLayoutEffect } from "react";
/**
* Custom hook that uses either `useLayoutEffect` or `useEffect` based on the environment (client-side or server-side).
* @param {Function} effect - The effect function to be executed.
* @param {Array<any>} [dependencies] - An array of dependencies for the effect (optional).
* @example
* ```tsx
* useIsomorphicLayoutEffect(() => {
* // Code to be executed during the layout phase on the client side
* }, [dependency1, dependency2]);
* ```
*/
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;

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,7 +1,9 @@
import "~/styles/globals.css";
import "~/utils/i18n";
import type { AppType } from "next/app";
import type { NextPage, Viewport } from "next";
import type { AppProps, AppType } from "next/app";
import type { ReactElement, ReactNode } from "react";
import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script";
import { env } from "next-runtime-env";
@@ -12,7 +14,7 @@ import { useEffect } from "react";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "~/providers/theme";
import { ThemeProvider } from "next-themes";
import { api } from "~/utils/api";
const jakarta = Plus_Jakarta_Sans({
@@ -26,7 +28,22 @@ export const metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const MyApp: AppType = ({ Component, pageProps }) => {
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
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");
useEffect(() => {
@@ -42,6 +59,8 @@ const MyApp: AppType = ({ Component, pageProps }) => {
}
}, [posthogKey]);
const getLayout = Component.getLayout ?? ((page) => page);
return (
<>
<style jsx global>{`
@@ -62,18 +81,18 @@ const MyApp: AppType = ({ Component, pageProps }) => {
<script src="/__ENV.js" />
<main className="font-sans">
<LinguiProviderWrapper>
<ThemeProvider>
<ModalProvider>
<PopupProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
<Component {...pageProps} />
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
<Component {...pageProps} />
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</main>

View File

@@ -46,6 +46,7 @@ export default async function handler(
const client = new S3Client({
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
@@ -58,6 +59,7 @@ export default async function handler(
new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename,
ACL: "public-read",
}),
);

View File

@@ -1,4 +1,4 @@
import { type NextApiRequest, type NextApiResponse } from "next";
import type { NextApiRequest, NextApiResponse } from "next";
import { openApiDocument } from "@kan/api/openapi";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,11 @@
@tailwind components;
@tailwind utilities;
[contenteditable=true]:empty:before {
[contenteditable="true"]:empty:before {
content: attr(placeholder);
display: block;
color: #707070;
font-size: 0.8rem;
}
.gradient-border::before {
@@ -18,7 +19,7 @@
transparent 110deg,
transparent 360deg
);
content: '';
content: "";
position: absolute;
inset: -50%;
width: 200%;

View File

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

View File

@@ -14,10 +14,11 @@ import { generateUID } from "@kan/shared/utils";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import Editor from "~/components/Editor";
import Editor, { WorkspaceMember } from "~/components/Editor";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle";
import { useModalFormState } from "~/hooks/useModalFormState";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -45,21 +46,28 @@ export function NewCardForm({
queryParams,
}: NewCardFormProps) {
const { showPopup } = usePopup();
const { closeModal, openModal } = useModal();
const { closeModal, openModal, modalStates, clearModalState } = useModal();
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 } =
useForm<NewCardFormInput>({
defaultValues: {
title: "",
description: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
},
values: formState,
});
const labelPublicIds = watch("labelPublicIds") || [];
@@ -67,11 +75,49 @@ export function NewCardForm({
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position");
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, {
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({
onMutate: async (args) => {
await utils.board.byId.cancel();
@@ -131,17 +177,24 @@ export function NewCardForm({
},
onSuccess: async () => {
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);
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
},
});
@@ -264,8 +317,22 @@ export function NewCardForm({
<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">
<Editor
content=""
onChange={(value) => setValue("description", value)}
content={description}
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>

View File

@@ -145,7 +145,12 @@ export function NewListForm({
/>
<div>
<Button type="submit">{t`Create list`}</Button>
<Button
type="submit"
disabled={createList.isPending || !watch("name")}
>
{t`Create list`}
</Button>
</div>
</div>
</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`
: 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) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -152,7 +152,14 @@ export function UpdateBoardSlugForm({
/>
</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>
<div className="flex items-center gap-2">
<Button
variant="secondary"
href="/settings?edit=workspace_url"
onClick={closeModal}
>
{t`Edit workspace URL`}
</Button>
<Button
type="submit"
isLoading={updateBoardSlug.isPending}
@@ -160,7 +167,8 @@ export function UpdateBoardSlugForm({
!isDirty ||
updateBoardSlug.isPending ||
errors.slug?.message !== undefined ||
isBoardSlugAvailable?.isReserved
isBoardSlugAvailable?.isReserved ||
checkBoardSlugAvailability.isLoading
}
>
{t`Update`}

View File

@@ -32,6 +32,7 @@ import Filters from "./components/Filters";
import List from "./components/List";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
import VisibilityButton from "./components/VisibilityButton";
@@ -43,7 +44,7 @@ export default function BoardPage() {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { openModal, modalContentType, entityId } = useModal();
const { openModal, modalContentType, entityId, isOpen } = useModal();
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
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 (
<>
<PageHead
@@ -240,7 +332,7 @@ export default function BoardPage() {
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
<div className="z-10 flex w-full justify-between p-8">
<div className="z-10 flex w-full flex-col justify-between p-6 md:flex-row md:p-8">
{isLoading && !boardData && (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
@@ -249,7 +341,7 @@ export default function BoardPage() {
{boardData && (
<form
onSubmit={handleSubmit(onSubmit)}
className="focus-visible:outline-none"
className="order-2 focus-visible:outline-none md:order-1"
>
<input
id="name"
@@ -261,12 +353,18 @@ export default function BoardPage() {
</form>
)}
{!boardData && !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]">
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
{t`Board not found`}
</p>
)}
<div className="flex items-center space-x-2">
<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
visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""}
@@ -277,7 +375,7 @@ export default function BoardPage() {
/>
<Filters
labels={boardData?.labels ?? []}
members={boardData?.workspace.members ?? []}
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
position="left"
isLoading={!boardData}
/>
@@ -358,7 +456,7 @@ export default function BoardPage() {
<div
ref={provided.innerRef}
{...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) => (
<Draggable
@@ -393,6 +491,7 @@ export default function BoardPage() {
title={card.title}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}
/>
</Link>
)}
@@ -414,55 +513,7 @@ export default function BoardPage() {
</>
) : null}
</div>
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
{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>
{renderModalContent()}
</div>
</>
);

View File

@@ -19,7 +19,7 @@ export function BoardsList() {
if (isLoading)
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" />
@@ -45,12 +45,12 @@ export function BoardsList() {
);
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) => (
<Link key={board.publicId} href={`boards/${board.publicId}`}>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<p className="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}
</p>
</div>

View File

@@ -81,7 +81,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
{integrationProviders[field.value]?.icon}
<span className="ml-2 block truncate">
<span className="ml-2 block truncate text-sm">
{integrationProviders[field.value]?.name}
</span>
</span>
@@ -113,7 +113,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
integrationProviders[integration.provider]
?.icon
}
<span className="ml-2 block truncate font-normal">
<span className="ml-2 block truncate text-sm font-normal">
{
integrationProviders[integration.provider]
?.name
@@ -125,12 +125,12 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
) : (
<Listbox.Option
key="trello_placeholder"
className="relative cursor-default select-none px-1"
className="font-sm relative cursor-default select-none px-1"
value="trello"
>
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
{integrationProviders.trello?.icon}
<span className="ml-2 block truncate font-normal">
<span className="ml-2 block truncate text-sm">
{integrationProviders.trello?.name}
</span>
</div>

View File

@@ -1,28 +1,59 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import type { NewBoardInput } from "@kan/api/types";
import type { Template } from "./TemplateBoards";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import TemplateBoards, { getTemplates } from "./TemplateBoards";
const schema = z.object({
name: z
.string()
.min(1, { message: t`Board name is required` })
.max(100, { message: t`Board name cannot exceed 100 characters` }),
workspacePublicId: z.string(),
template: z.custom<Template | null>(),
});
interface NewBoardInputWithTemplate {
name: string;
workspacePublicId: string;
template: Template | null;
}
export function NewBoardForm() {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const [showTemplates, setShowTemplates] = useState(false);
const { register, handleSubmit } = useForm<NewBoardInput>({
const templates = getTemplates();
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<NewBoardInputWithTemplate>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
workspacePublicId: workspace.publicId || "",
template: null,
},
});
const currentTemplate = watch("template");
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.board.create.useMutation({
@@ -32,8 +63,13 @@ export function NewBoardForm() {
},
});
const onSubmit = (data: NewBoardInput) => {
createBoard.mutate(data);
const onSubmit = (data: NewBoardInputWithTemplate) => {
createBoard.mutate({
name: data.name,
workspacePublicId: data.workspacePublicId,
lists: data.template?.lists ?? [],
labels: data.template?.labels ?? [],
});
};
useEffect(() => {
@@ -62,6 +98,7 @@ export function NewBoardForm() {
id="name"
placeholder={t`Name`}
{...register("name", { required: true })}
errorMessage={errors.name?.message}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -70,8 +107,22 @@ export function NewBoardForm() {
}}
/>
</div>
<TemplateBoards
currentBoard={currentTemplate}
setCurrentBoard={(t) => setValue("template", t)}
showTemplates={showTemplates}
/>
<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`Use template`}
isChecked={showTemplates}
onChange={() => {
setShowTemplates(!showTemplates);
if (!showTemplates && !currentTemplate) {
setValue("template", templates[0] ?? null);
}
}}
/>
<div>
<Button type="submit" isLoading={createBoard.isPending}>
{t`Create board`}

View File

@@ -0,0 +1,191 @@
import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react";
import { HiCheckCircle } from "react-icons/hi2";
export interface Template {
id: string;
name: string;
lists: string[];
labels: string[];
}
export const getTemplates = (): Template[] => [
{
id: "basic",
name: t`Basic Kanban`,
lists: [t`To Do`, t`In Progress`, t`Done`],
labels: [t`High Priority`, t`Medium Priority`, t`Low Priority`],
},
{
id: "software-dev",
name: t`Software Development`,
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
},
{
id: "content-creation",
name: t`Content Creation`,
lists: [
t`Brainstorming`,
t`Writing`,
t`Editing`,
t`Design`,
t`Approval`,
t`Publishing`,
t`Done`,
],
labels: [t`Blog Post`, t`Social Media`, t`Video`, t`Newsletter`, t`Urgent`],
},
{
id: "customer-support",
name: t`Customer Support`,
lists: [
t`New Ticket`,
t`Triaging`,
t`In Progress`,
t`Awaiting Customer`,
t`Resolution`,
t`Done`,
],
labels: [
t`Bug Report`,
t`Feature Request`,
t`Question`,
t`Urgent`,
t`Billing`,
],
},
{
id: "recruitment",
name: t`Recruitment`,
lists: [
t`Applicants`,
t`Screening`,
t`Interviewing`,
t`Offer`,
t`Onboarding`,
t`Hired`,
],
labels: [t`Remote`, t`Full-time`, t`Part-time`, t`Senior`, t`Junior`],
},
{
id: "personal-project",
name: t`Personal Project`,
lists: [
t`Ideas`,
t`Research`,
t`Planning`,
t`Execution`,
t`Review`,
t`Next Steps`,
t`Complete`,
],
labels: [t`Important`, t`Quick Win`, t`Long-term`, t`Learning`, t`Fun`],
},
];
export default function TemplateBoards({
currentBoard,
setCurrentBoard,
showTemplates,
}: {
currentBoard: Template | null;
setCurrentBoard: (board: Template | null) => void;
showTemplates: boolean;
}) {
const [showFade, setShowFade] = useState(false);
const [showTopFade, setShowTopFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const templates = getTemplates();
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);
}, [showTemplates]);
useEffect(() => {
if (showTemplates && currentBoard && scrollRef.current) {
const selectedElement = scrollRef.current.querySelector(
`[data-template-id="${currentBoard.id}"]`,
);
if (selectedElement) {
selectedElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, [showTemplates, currentBoard]);
const handleBoardSelect = (boardId: string) => {
if (currentBoard?.id === boardId) {
setCurrentBoard(null);
} else {
setCurrentBoard(
templates.find((template) => template.id === boardId) ?? null,
);
}
};
if (!showTemplates) {
return null;
}
return (
<div className="px-5 pt-4">
<div className="relative">
<div
ref={scrollRef}
className="scroll-container -mr-2 flex max-h-[200px] flex-col gap-3 overflow-y-auto pr-2 pt-0.5"
>
{templates.map((template) => (
<label
key={template.id}
data-template-id={template.id}
onClick={() => handleBoardSelect(template.id)}
className={`scroll-container relative flex cursor-pointer rounded-lg border p-3 transition-all hover:bg-light-100 dark:hover:bg-dark-200 ${
currentBoard?.id === template.id
? "border-light-700 bg-light-100 ring-1 ring-inset ring-light-700 dark:border-dark-700 dark:bg-dark-200 dark:ring-dark-700"
: "border-light-600 dark:border-dark-600"
}`}
>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{template.name}
</h4>
<p className="text-xs text-light-950 dark:text-dark-900">
{template.lists.join(", ")}
</p>
</div>
{currentBoard?.id === template.id && (
<div className="absolute right-3 top-3 text-light-1000 dark:text-dark-1000">
<HiCheckCircle className="h-5 w-5" />
</div>
)}
</label>
))}
</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>
);
}

View File

@@ -1,6 +1,8 @@
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -11,7 +13,7 @@ import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
export default function BoardsPage() {
const { openModal, modalContentType } = useModal();
const { openModal, modalContentType, isOpen } = useModal();
const { workspace, hasLoaded } = useWorkspace();
if (hasLoaded && !workspace.publicId) openModal("NEW_WORKSPACE");
@@ -19,46 +21,64 @@ export default function BoardsPage() {
return (
<>
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
<div className="h-full p-8">
<div className="mb-8 flex w-full justify-between">
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Boards`}
</h1>
<div className="flex">
<button
<div className="flex gap-2">
<Button
type="button"
className="bg-dark-3000 mr-2 flex items-center gap-x-1.5 rounded-md border-[1px] border-light-600 px-3 py-2 text-sm text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-dark-600 dark:text-dark-1000"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
<div className="flex h-5 w-5 items-center">
<HiArrowDownTray
className="-mr-0.5 h-4 w-4"
aria-hidden="true"
/>
</div>
{t`Import`}
</button>
<button
</Button>
<Button
type="button"
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}
>
<div className="h-5 w-5 items-center">
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
</div>
{t`New`}
</button>
</Button>
</div>
</div>
<Modal>
{modalContentType === "NEW_BOARD" && <NewBoardForm />}
{modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
<>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_BOARD"}
>
<NewBoardForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "IMPORT_BOARDS"}
>
<ImportBoardsForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
<div className="flex h-full flex-row">
<BoardsList />

View File

@@ -5,9 +5,11 @@ import { de, enGB, es, fr, it, nl } from "date-fns/locale";
import {
HiOutlineArrowLeft,
HiOutlineArrowRight,
HiOutlineCheckCircle,
HiOutlinePencil,
HiOutlinePlus,
HiOutlineTag,
HiOutlineTrash,
HiOutlineUserMinus,
HiOutlineUserPlus,
} from "react-icons/hi2";
@@ -31,6 +33,11 @@ const dateLocaleMap = {
nl: nl,
} as const;
const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
};
const getActivityText = ({
type,
toTitle,
@@ -39,6 +46,7 @@ const getActivityText = ({
memberName,
isSelf,
label,
fromTitle,
}: {
type: ActivityType;
toTitle: string | null;
@@ -47,6 +55,7 @@ const getActivityText = ({
memberName: string | null;
isSelf: boolean;
label: string | null;
fromTitle?: string | null;
}) => {
const ACTIVITY_TYPE_MAP = {
"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.member.added": t`added a member to 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;
if (!(type in ACTIVITY_TYPE_MAP)) return null;
@@ -71,7 +88,7 @@ const getActivityText = ({
if (type === "card.updated.title" && toTitle) {
return (
<Trans>
updated the title to <TextHighlight>{toTitle}</TextHighlight>
updated the title to <TextHighlight>{truncate(toTitle)}</TextHighlight>
</Trans>
);
}
@@ -79,8 +96,9 @@ const getActivityText = ({
if (type === "card.updated.list" && fromList && toList) {
return (
<Trans>
moved the card from <TextHighlight>{fromList}</TextHighlight> to
<TextHighlight>{toList}</TextHighlight>
moved the card from <TextHighlight>{truncate(fromList)}</TextHighlight>{" "}
to
<TextHighlight>{truncate(toList)}</TextHighlight>
</Trans>
);
}
@@ -90,7 +108,8 @@ const getActivityText = ({
return (
<Trans>
assigned <TextHighlight>{memberName}</TextHighlight> to the card
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
card
</Trans>
);
}
@@ -100,7 +119,8 @@ const getActivityText = ({
return (
<Trans>
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
the card
</Trans>
);
}
@@ -108,7 +128,7 @@ const getActivityText = ({
if (type === "card.updated.label.added" && label) {
return (
<Trans>
added label <TextHighlight>{label}</TextHighlight>
added label <TextHighlight>{truncate(label)}</TextHighlight>
</Trans>
);
}
@@ -116,7 +136,75 @@ const getActivityText = ({
if (type === "card.updated.label.removed" && label) {
return (
<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>
);
}
@@ -133,6 +221,14 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
"card.updated.label.removed": <HiOutlineTag />,
"card.updated.member.added": <HiOutlineUserPlus />,
"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;
const getActivityIcon = (
@@ -155,11 +251,13 @@ const ActivityList = ({
cardPublicId,
isLoading,
isAdmin,
isViewOnly,
}: {
activities: NonNullable<GetCardByIdOutput>["activities"];
cardPublicId: string;
isLoading: boolean;
isAdmin?: boolean;
isViewOnly?: boolean;
}) => {
const { data } = authClient.useSession();
const { locale } = useLocalisation();
@@ -177,6 +275,7 @@ const ActivityList = ({
memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === data?.user.id,
label: activity.label?.name ?? null,
fromTitle: activity.fromTitle ?? null,
});
if (activity.type === "card.updated.comment.added")
@@ -193,6 +292,7 @@ const ActivityList = ({
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === data?.user.id}
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,
isAdmin,
isEdited = false,
isViewOnly = false,
}: {
publicId: string | undefined;
cardPublicId: string;
@@ -38,6 +39,7 @@ const Comment = ({
isAuthor: boolean;
isAdmin: boolean;
isEdited: boolean;
isViewOnly: boolean;
}) => {
const [isEditing, setIsEditing] = useState(false);
const utils = api.useUtils();
@@ -124,7 +126,7 @@ const Comment = ({
</p>
</div>
{dropdownItems.length > 0 && (
{dropdownItems.length > 0 && !isViewOnly && (
<div className="absolute right-4 top-4">
<Dropdown items={dropdownItems}>
<HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />
@@ -145,7 +147,7 @@ const Comment = ({
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end space-x-2">
<Button

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

View File

@@ -113,7 +113,7 @@ export default function LabelSelector({
</Menu.Button>
</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" />
{t`Add label`}
</Menu.Button>

View File

@@ -79,7 +79,7 @@ export default function ListSelector({
}}
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}
</Menu.Button>
</CheckboxDropdown>

View File

@@ -112,7 +112,7 @@ export default function MemberSelector({
createNewItemLabel={t`Invite member`}
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 ? (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{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 (
<form
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
placeholder={t`Add a comment...`}

View File

@@ -1,11 +1,13 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5";
import Avatar from "~/components/Avatar";
import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal";
import { LabelForm } from "~/components/LabelForm";
import LabelIcon from "~/components/LabelIcon";
import Modal from "~/components/modal";
@@ -18,12 +20,15 @@ import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
import ActivityList from "./components/ActivityList";
import Checklists from "./components/Checklists";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
import Dropdown from "./components/Dropdown";
import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
import { NewChecklistForm } from "./components/NewChecklistForm";
import NewCommentForm from "./components/NewCommentForm";
interface FormValues {
@@ -32,29 +37,18 @@ interface FormValues {
description: string;
}
export default function CardPage() {
export function CardRightPanel() {
const router = useRouter();
const utils = api.useUtils();
const { modalContentType, entityId } = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery({
const { data: card } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const refetchCard = async () => {
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
};
const board = card?.list.board;
const boardId = board?.publicId;
const labels = board?.labels;
const activities = card?.activities;
const workspaceMembers = board?.workspace.members;
const selectedLabels = card?.labels;
const selectedMembers = card?.members;
@@ -109,6 +103,69 @@ export default function CardPage() {
};
}) ?? [];
return (
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
<ListSelector
cardPublicId={cardId ?? ""}
lists={formattedLists}
isLoading={!card}
/>
</div>
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Labels`}</p>
<LabelSelector
cardPublicId={cardId ?? ""}
labels={formattedLabels}
isLoading={!card}
/>
</div>
<div className="flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId ?? ""}
members={formattedMembers}
isLoading={!card}
/>
</div>
</div>
);
}
export default function CardPage() {
const router = useRouter();
const utils = api.useUtils();
const {
modalContentType,
entityId,
openModal,
getModalState,
clearModalState,
isOpen,
} = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
null,
);
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const refetchCard = async () => {
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
};
const board = card?.list.board;
const boardId = board?.publicId;
const activities = card?.activities;
const updateCard = api.card.update.useMutation({
onError: () => {
showPopup({
@@ -138,7 +195,16 @@ export default function CardPage() {
});
};
const description = watch("description");
// 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 <></>;
@@ -147,147 +213,179 @@ export default function CardPage() {
<PageHead
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
/>
<div className="flex h-full flex-1 flex-row">
<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="mb-8 flex w-full items-center justify-between">
{!card && isLoading && (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
)}
<div className="flex h-full flex-1 flex-row overflow-hidden">
<div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300">
<div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col">
<div className="p-6 md:p-8">
<div className="mb-8 flex w-full items-center justify-between md:mt-6">
{!card && isLoading && (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
)}
{card && (
<>
<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 && (
<>
<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"
<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)()}
workspaceMembers={board?.workspace.members ?? []}
/>
</div>
</form>
</div>
<Checklists
checklists={card.checklists}
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
/>
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
{t`Activity`}
</h2>
<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]"
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={!card}
isAdmin={workspace.role === "admin"}
/>
</div>
</form>
<div className="flex">
<Dropdown />
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</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>
{card && (
<>
<div className="mb-10 flex w-full max-w-2xl 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 className="w-[475px] 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="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">{t`List`}</p>
<ListSelector
cardPublicId={cardId}
lists={formattedLists}
isLoading={!card}
/>
</div>
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">{t`Labels`}</p>
<LabelSelector
cardPublicId={cardId}
labels={formattedLabels}
isLoading={!card}
/>
</div>
<div className="flex w-full">
<p className="my-2 w-[100px] text-sm">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId}
members={formattedMembers}
isLoading={!card}
/>
</div>
</div>
<Modal>
{modalContentType === "NEW_LABEL" && (
<>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LABEL"}
>
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} />
)}
{modalContentType === "EDIT_LABEL" && (
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
>
<LabelForm
boardPublicId={boardId ?? ""}
refetch={refetchCard}
isEdit
/>
)}
{modalContentType === "DELETE_LABEL" && (
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
>
<DeleteLabelConfirmation
refetch={refetchCard}
labelPublicId={entityId}
/>
)}
{modalContentType === "DELETE_CARD" && (
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_CARD"}
>
<DeleteCardConfirmation
boardPublicId={boardId ?? ""}
cardPublicId={cardId}
/>
)}
{modalContentType === "DELETE_COMMENT" && (
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_COMMENT"}
>
<DeleteCommentConfirmation
cardPublicId={cardId}
commentPublicId={entityId}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "ADD_CHECKLIST"}
>
<NewChecklistForm cardPublicId={cardId} />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_CHECKLIST"}
>
<DeleteChecklistConfirmation
cardPublicId={cardId}
checklistPublicId={entityId}
/>
</Modal>
</>
</div>
</>
);

View File

@@ -1,17 +1,17 @@
import PatternedBackground from "~/components/PatternedBackground";
import { useTheme } from "~/providers/theme";
import { useTheme } from "next-themes";
import Footer from "./Footer";
import Header from "./Header";
import { authClient } from "@kan/auth/client";
export default function Layout({ children }: { children: React.ReactNode }) {
const theme = useTheme();
const { theme } = useTheme();
const { data: session } = authClient.useSession();
const isLoggedIn = !!session?.user;
const isDarkMode = theme.activeTheme === "dark";
const isDarkMode = theme === "dark";
return (
<>

View File

@@ -6,7 +6,7 @@ import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
import Button from "~/components/Button";
import { PageHead } from "~/components/PageHead";
import { useTheme } from "~/providers/theme";
import { useTheme } from "next-themes";
import Cta from "./components/Cta";
import FAQs from "./components/Faqs";
import Features from "./components/Features";
@@ -14,9 +14,10 @@ import Layout from "./components/Layout";
import Pricing from "./components/Pricing";
export default function HomeView() {
const theme = useTheme();
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";
const isDarkMode = theme.activeTheme === "dark";
return (
<Layout>
<PageHead title="Kan.bn | The open source alternative to Trello" />
@@ -40,7 +41,7 @@ export default function HomeView() {
</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">
<Link
href="https://news.ycombinator.com/item?id=44157177"
@@ -58,7 +59,7 @@ export default function HomeView() {
</div>
</Link>
</div>
</div>
</div> */}
</div>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
@@ -88,19 +89,27 @@ export default function HomeView() {
</div>
<div className="px-4">
<div className="mb-24 rounded-[16px] border border-light-300 bg-light-50 p-1 shadow-md dark:border-dark-300 dark:bg-dark-100 lg:rounded-[24px] lg:p-2">
<div className="overflow-hidden rounded-[12px] border border-light-300 shadow-sm dark:border-dark-300 lg:rounded-[16px]">
<div className="relative overflow-hidden rounded-[12px] border border-light-300 shadow-sm dark:border-dark-300 lg:rounded-[16px]">
<Image
src={`/hero-${isDarkMode ? "dark" : "light"}.png`}
src={`/hero-light.png`}
alt="kanban"
width={1100}
height={1000}
className="block dark:hidden"
/>
<Image
src={`/hero-dark.png`}
alt="kanban"
width={1100}
height={1000}
className="hidden dark:block"
/>
</div>
</div>
</div>
<div className="relative pt-10">
<div id="features" className="absolute -top-20" />
<Features theme={theme.activeTheme} />
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} />
</div>
<div className="relative pt-10">
<div id="pricing" className="absolute -top-20" />
@@ -111,7 +120,7 @@ export default function HomeView() {
<FAQs />
</div>
<div className="relative">
<Cta theme={theme.activeTheme} />
<Cta theme={resolvedTheme ?? "light"} />
</div>
</div>
</Layout>

View File

@@ -1,26 +1,43 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function InviteMemberForm() {
const utils = api.useUtils();
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
const { closeModal } = useModal();
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: {
email: "",
workspacePublicId: workspace.publicId || "",
},
resolver: zodResolver(InviteMemberSchema),
});
const refetchBoards = () => utils.board.all.refetch();
@@ -31,6 +48,24 @@ export function InviteMemberForm() {
await utils.workspace.byId.refetch();
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) => {
@@ -69,10 +104,16 @@ export function InviteMemberForm() {
await handleSubmit(onSubmit)();
}
}}
errorMessage={errors.email?.message}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Invite another`}
isChecked={isCreateAnotherEnabled}
onChange={() => setIsCreateAnotherEnabled(!isCreateAnotherEnabled)}
/>
<div>
<Button
type="submit"

View File

@@ -2,9 +2,12 @@ import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -16,7 +19,7 @@ import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation"
import { InviteMemberForm } from "./components/InviteMemberForm";
export default function MembersPage() {
const { modalContentType, openModal } = useModal();
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const { data, isLoading } = api.workspace.byId.useQuery(
@@ -26,6 +29,7 @@ export default function MembersPage() {
const TableRow = ({
memberPublicId,
memberId,
memberName,
memberEmail,
memberImage,
@@ -35,6 +39,7 @@ export default function MembersPage() {
showSkeleton,
}: {
memberPublicId?: string;
memberId?: string | null | undefined;
memberName?: string | null | undefined;
memberEmail?: string | null | undefined;
memberImage?: string | null | undefined;
@@ -43,13 +48,19 @@ export default function MembersPage() {
isLastRow?: boolean;
showSkeleton?: boolean;
}) => {
const { data: session } = authClient.useSession();
return (
<tr className="rounded-b-lg">
<td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}>
<div className="flex items-center p-4">
<td
className={twMerge(
"w-full sm:w-[65%]",
isLastRow ? "rounded-bl-lg" : "",
)}
>
<div className="flex items-center p-2 sm:p-4">
<div className="flex-shrink-0">
{showSkeleton ? (
<div className="h-9 w-9 animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
<div className="h-8 w-8 animate-pulse rounded-full bg-light-200 dark:bg-dark-200 sm:h-9 sm:w-9" />
) : (
<Avatar
name={memberName ?? ""}
@@ -63,7 +74,7 @@ export default function MembersPage() {
<div className="flex items-center">
<p
className={twMerge(
"mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000",
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
showSkeleton &&
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
@@ -73,7 +84,7 @@ export default function MembersPage() {
</div>
<p
className={twMerge(
"truncate text-sm text-dark-900",
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
@@ -86,15 +97,15 @@ export default function MembersPage() {
</td>
<td
className={twMerge(
"w-[35%] min-w-[150px]",
"w-auto min-w-[120px] overflow-visible sm:w-[35%] sm:min-w-[150px]",
isLastRow && "rounded-br-lg",
)}
>
<div className="flex w-full items-center justify-between px-3">
<div>
<div className="flex w-full items-center justify-between px-2 sm:px-3">
<div className="flex flex-col sm:flex-row sm:items-center">
<span
className={twMerge(
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20",
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
showSkeleton &&
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
)}
@@ -103,35 +114,37 @@ export default function MembersPage() {
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
</span>
{memberStatus === "invited" && (
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
{t`Pending`}
</span>
)}
</div>
<div
className={twMerge(
"relative",
"relative z-50",
(workspace.role !== "admin" || showSkeleton) && "hidden",
)}
>
<Dropdown
items={[
{
label: t`Remove member`,
action: () =>
openModal(
"REMOVE_MEMBER",
memberPublicId,
memberEmail ?? "",
),
},
]}
>
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Dropdown>
{session?.user.id !== memberId && (
<Dropdown
items={[
{
label: t`Remove member`,
action: () =>
openModal(
"REMOVE_MEMBER",
memberPublicId,
memberEmail ?? "",
),
},
]}
>
<HiEllipsisHorizontal
size={20}
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
/>
</Dropdown>
)}
</div>
</div>
</td>
@@ -142,7 +155,7 @@ export default function MembersPage() {
return (
<>
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto max-w-[1600px] px-28 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">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Members`}
@@ -159,32 +172,33 @@ export default function MembersPage() {
</div>
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-visible sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="-mx-4 -my-2 sm:-mx-6 lg:-mx-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">
<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">
<tr>
<th
scope="col"
className="w-[65%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
className="w-full rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[65%] sm:pl-6"
>
{t`User`}
</th>
<th
scope="col"
className="w-[35%] rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
className="w-auto whitespace-nowrap rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[35%]"
>
{t`Role`}
</th>
</tr>
</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 &&
data?.members.map((member, index) => (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}
@@ -208,11 +222,35 @@ export default function MembersPage() {
</div>
</div>
<Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
{modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}
</Modal>
<>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
>
<InviteMemberForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "REMOVE_MEMBER"}
>
<DeleteMemberConfirmation />
</Modal>
</>
</div>
</>
);

View File

@@ -1,13 +1,15 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react";
import { HiXMark } from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Badge from "~/components/Badge";
import Editor from "~/components/Editor";
import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList";
import Checklists from "~/views/card/components/Checklists";
export function CardModal({
cardPublicId,
@@ -20,7 +22,9 @@ export function CardModal({
}) {
const router = useRouter();
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(
{
@@ -33,85 +37,129 @@ export function CardModal({
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 (
<div className="flex h-full flex-1 flex-row">
<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="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="h-full p-8">
<div className="mb-6">
{labels.map((label) => (
<div
key={label.publicId}
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"
>
<svg
fill={label.colourCode ?? "#3730a3"}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
<div>{label.name}</div>
</div>
))}
</div>
<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();
{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}
setTimeout(async () => {
try {
await router.replace(
`/${workspaceSlug}/${boardSlug}`,
undefined,
{
shallow: true,
},
);
} catch (error) {
console.error(error);
}
}, 400);
}}
>
<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="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>
</>
)}
</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>

View File

@@ -2,9 +2,11 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env";
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 { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
@@ -18,6 +20,10 @@ import Card from "~/views/board/components/Card";
import Filters from "~/views/board/components/Filters";
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() {
const router = useRouter();
const { showPopup } = usePopup();
@@ -28,13 +34,18 @@ export default function PublicBoardView() {
? router.query.boardSlug[0]
: router.query.boardSlug;
const workspaceSlug = Array.isArray(router.query.workspaceSlug)
? router.query.workspaceSlug[0]
: router.query.workspaceSlug;
const { data, isLoading } = api.board.bySlug.useQuery(
{
boardSlug: boardSlug ?? "",
workspaceSlug: workspaceSlug ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
},
{ enabled: !!boardSlug, placeholderData: keepPreviousData },
{ enabled: router.isReady && !!boardSlug, placeholderData: keepPreviousData },
);
const CopyBoardLink = () => {
@@ -96,7 +107,7 @@ export default function PublicBoardView() {
<div className="relative h-full overflow-hidden rounded-md border pb-8 dark:border-dark-200">
<PatternedBackground />
<div className="z-10 flex w-full justify-between p-8">
{isLoading ? (
{isLoading || !router.isReady ? (
<div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
</div>
@@ -105,26 +116,47 @@ export default function PublicBoardView() {
{data?.name}
</h1>
)}
<div className="flex items-center space-x-2">
<Filters
labels={data?.labels ?? []}
members={[]}
isLoading={isLoading}
/>
</div>
{data && (
<div className="z-10 flex items-center space-x-2">
<div className="inline-flex cursor-default items-center justify-center whitespace-nowrap rounded-md border-[1px] border-light-300 bg-light-50 px-3 py-2 text-sm font-semibold text-light-950 shadow-sm dark:border-dark-300 dark:bg-dark-50 dark:text-dark-950">
<span className="mr-2">
<HiOutlineLockClosed />
</span>
{t`View only`}
</div>
<Filters
labels={data.labels ?? []}
members={[]}
isLoading={isLoading}
/>
</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">
{isLoading ? (
<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 || !router.isReady ? (
<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-[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>
) : !data && !isLoading && router.isReady && !!boardSlug ? (
<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="min-w-[2rem]" />
{data?.lists.map((list) => (
{data.lists.map((list) => (
<div
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"
@@ -148,6 +180,7 @@ export default function PublicBoardView() {
<Card
title={card.title}
labels={card.labels}
checklists={card.checklists ?? []}
members={[]}
/>
</Link>
@@ -166,12 +199,26 @@ export default function PublicBoardView() {
<CopyBoardLink />
</div>
<Link
className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000"
href="/"
>
kan.bn
</Link>
{IS_CLOUD && (
<Link
className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000"
href="/"
>
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>
<Popup />

View File

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

View File

@@ -0,0 +1,184 @@
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const FormSchema = z
.object({
currentPassword: z.string().min(1, t`Current password is required`),
newPassword: z
.string()
.min(8, t`Password must be at least 8 characters`)
.min(1, t`New password is required`),
confirmPassword: z.string().min(1, t`Please confirm your new password`),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: t`New password must be different from current password`,
path: ["newPassword"],
});
type FormValues = z.infer<typeof FormSchema>;
export function ChangePasswordFormConfirmation() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const router = useRouter();
const utils = api.useUtils();
const {
register,
handleSubmit,
formState: { errors, isValid },
reset,
setError,
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
mode: "onChange",
});
const changePasswordMutation = useMutation({
mutationFn: async (data: FormValues) => {
const response = await authClient.changePassword({
newPassword: data.newPassword,
currentPassword: data.currentPassword,
revokeOtherSessions: true,
});
if (response?.error) {
throw new Error(response?.error.message || "Invalid Password");
}
},
onSuccess: async () => {
closeModal();
showPopup({
header: t`Password Changed`,
message: t`Your password has been changed.`,
icon: "success",
});
utils.invalidate();
reset();
router.push("/");
},
onError: async (error: any) => {
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes("invalid password")) {
setError("currentPassword", {
type: "manual",
message: t`The current password you entered is incorrect.`,
});
} else {
closeModal();
showPopup({
header: t`Error Changing Password`,
message: t`An unexpected error occurred. Please try again later.`,
icon: "error",
});
}
},
});
const onSubmit = (data: FormValues) => {
changePasswordMutation.mutate(data);
};
const handleCancel = () => {
reset();
closeModal();
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
<p className="mb-4 text-sm text-light-900">
{t`Enter your current password and choose a new secure password.`}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<div>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
placeholder={t`Enter your current password`}
/>
{errors.currentPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.currentPassword.message}
</p>
)}
</div>
<div>
<Input
id="newPassword"
type="password"
{...register("newPassword")}
placeholder={t`Enter your new password`}
/>
{errors.newPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.newPassword.message}
</p>
)}
</div>
<div>
<Input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
placeholder={t`Confirm your new password`}
/>
{errors.confirmPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
</div>
<div className="mt-[1.5rem] flex items-center gap-4">
<Button
variant="secondary"
onClick={handleCancel}
type="button"
fullWidth
size="lg"
>
{t`Cancel`}
</Button>
<Button
variant="primary"
type="submit"
disabled={!isValid || changePasswordMutation.isPending}
isLoading={changePasswordMutation.isPending}
fullWidth
size="lg"
>
{t`Change Password`}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -13,6 +15,7 @@ import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import Avatar from "./components/Avatar";
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
@@ -23,11 +26,15 @@ import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
export default function SettingsPage() {
const { modalContentType, openModal } = useModal();
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const utils = api.useUtils();
const { showPopup } = usePopup();
const router = useRouter();
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const { data } = api.user.getUser.useQuery();
const {
@@ -59,6 +66,24 @@ export default function SettingsPage() {
};
}, [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 } =
api.integration.disconnect.useMutation({
onSuccess: () => {
@@ -104,9 +129,12 @@ export default function SettingsPage() {
return (
<>
<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"}`} />
<div className="px-28 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">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Settings`}
@@ -134,14 +162,16 @@ export default function SettingsPage() {
workspaceName={workspace.name}
/>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Workspace URL`}
</h2>
<UpdateWorkspaceUrlForm
workspacePublicId={workspace.publicId}
workspaceUrl={workspace.slug ?? ""}
workspacePlan={workspace.plan ?? "free"}
/>
<div ref={workspaceUrlSectionRef}>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Workspace URL`}
</h2>
<UpdateWorkspaceUrlForm
workspacePublicId={workspace.publicId}
workspaceUrl={workspace.slug ?? ""}
workspacePlan={workspace.plan ?? "free"}
/>
</div>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Workspace description`}
@@ -255,6 +285,25 @@ export default function SettingsPage() {
</div>
</div>
{isCredentialsEnabled && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Change Password`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`You are about to change your password.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("CHANGE_PASSWORD")}
>
{t`Change Password`}
</Button>
</div>
</div>
)}
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Delete account`}
@@ -273,18 +322,49 @@ export default function SettingsPage() {
</div>
</div>
<Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "DELETE_WORKSPACE" && (
<>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_WORKSPACE"}
>
<DeleteWorkspaceConfirmation />
)}
{modalContentType === "UPDATE_WORKSPACE_URL" && (
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPDATE_WORKSPACE_URL"}
>
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
)}
{modalContentType === "DELETE_ACCOUNT" && (
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_ACCOUNT"}
>
<DeleteAccountConfirmation />
)}
</Modal>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
>
<ChangePasswordFormConfirmation />
</Modal>
</>
</div>
</div>
</>

View File

@@ -4,6 +4,7 @@ import { fontFamily } from "tailwindcss/defaultTheme";
import baseConfig from "@kan/tailwind-config/web";
export default {
darkMode: "class",
content: [...baseConfig.content],
plugins: [require("@tailwindcss/typography")],
presets: [baseConfig],

View File

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

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