Compare commits
1 Commits
fix/member
...
feat/smtp-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a190d6fb |
@@ -10,18 +10,16 @@ POSTGRES_PASSWORD=
|
||||
|
||||
# SMTP (optional)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=465
|
||||
SMTP_PORT=465 # Port 587 will not work
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||
SMTP_SECURE= # set to "false" to use port 587
|
||||
|
||||
# S3 storage (optional)
|
||||
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
@@ -48,5 +48,4 @@ dist/
|
||||
docker-compose.override.yml
|
||||
|
||||
# runtime env
|
||||
__ENV.js
|
||||
i18n.cache
|
||||
__ENV.js
|
||||
4
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"cwd": "${workspaceFolder}/apps/web",
|
||||
"cwd": "${workspaceFolder}/apps/nextjs",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"sourceMaps": true,
|
||||
"sourceMapPathOverrides": {
|
||||
@@ -14,4 +14,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
121
CHANGELOG.md
@@ -1,121 +0,0 @@
|
||||
# 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
|
||||
58
README.md
@@ -138,36 +138,34 @@ 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` |
|
||||
| `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` |
|
||||
| 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 | For Email | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
|
||||
| `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` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 433 KiB |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
version: 1
|
||||
checksums:
|
||||
8033ed03a24d7c4898c90a5365297c20:
|
||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||
"%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
|
||||
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
|
||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||
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%20label/singular: 0be732d46df263265935fda342097a08
|
||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||
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
|
||||
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
|
||||
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%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||
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
|
||||
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
|
||||
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
||||
Board%20URL%20must%20be%20at%20least%203%20characters%20long/singular: 105f5b4f3b606baf7b5302117022bd68
|
||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
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
|
||||
Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
|
||||
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%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
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
|
||||
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
|
||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
Design/singular: 991b75727b6784c1a063a7462b76186d
|
||||
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
|
||||
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
|
||||
Display%20name/singular: 725f331065e9d594d720d07b1ae51a90
|
||||
Display%20name%20cannot%20exceed%20280%20characters/singular: 426ddbcc87590e9d028bdca4e585c549
|
||||
Display%20name%20must%20be%20at%20least%203%20characters%20long/singular: 84178b79845a70937af5e26020403052
|
||||
Display%20name%20updated/singular: 4e27503ca24ef5f009713951ca9e5b97
|
||||
Do%20you%20offer%20a%20free%20plan%3F/singular: 3ef16b0994f0d9ae17067782ca9bed1b
|
||||
docs/singular: 307bff5678ada918da1f1ec3edc76cac
|
||||
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
|
||||
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
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%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
||||
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
||||
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
|
||||
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
|
||||
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
|
||||
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
|
||||
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
|
||||
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
|
||||
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
|
||||
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
|
||||
Filter/singular: 626325a05e4c8800f7ede7012b0cadaf
|
||||
Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
|
||||
Free/singular: 0326365539c004f6088656f692602078
|
||||
Free%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
|
||||
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
||||
Get%20started%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
|
||||
Get%20started%20on%20Cloud/singular: ed926f526266ad2063283c58e6f4284a
|
||||
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
|
||||
How%20do%20I%20invite%20team%20members%3F/singular: e620622060a06288e86ccda35395db87
|
||||
How%20do%20I%20self-host%3F/singular: ec0b6afc8ad2f581b512c0fd82926b75
|
||||
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
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
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
|
||||
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
|
||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
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
|
||||
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%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%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||
New%20label/singular: 890040da52a88c16c249a4ac78980f81
|
||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||
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
|
||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||
Private/singular: d740d0660e68c61db1853489356030ae
|
||||
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
|
||||
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%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
|
||||
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
|
||||
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
|
||||
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%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%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%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
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%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%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
|
||||
Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20No%20credit%20card%20required./singular: 8d9733137deb26664d76b55ad561c42c
|
||||
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
|
||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||
updated%20the%20title%20to%20%3C0%3E%7BtoTitle%7D%3C%2F0%3E/singular: 70cdf681ec2b6ef8e14397bc1f93f945
|
||||
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
|
||||
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%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
|
||||
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
|
||||
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
|
||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
|
||||
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20time%20(and%20atlassification)%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 60a55e9bd16c3d1beff1a6c7633c5911
|
||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
Workspace%20deleted/singular: bc5c267ce3f04de94ff1379cf2ca48a3
|
||||
Workspace%20description/singular: 7159d620b7ef5c5afef734989a5be08b
|
||||
Workspace%20description%20cannot%20exceed%20280%20characters/singular: d0e83b9a0304d7c36cf8c85d98783ce2
|
||||
Workspace%20description%20must%20be%20at%20least%203%20characters%20long/singular: 5ebac407715e6569adc381ebbb4f32d4
|
||||
Workspace%20description%20updated/singular: 19ddb6ee9ead26982a90dee426f3ee1f
|
||||
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
||||
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
||||
Workspace%20name%20cannot%20exceed%2024%20characters/singular: c8b8f79fb83abf8329cc34c1f51fcfc9
|
||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||
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%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
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
||||
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
||||
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
|
||||
Your%20workspace%20description%20has%20been%20updated./singular: 98de6f65c567c6654cc0adce3c9d5656
|
||||
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
||||
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
||||
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
path: "src/locales/{locale}/messages",
|
||||
include: ["src"],
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -31,12 +31,9 @@ const config = {
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: process.env.S3_FORCE_PATH_STYLE === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
@@ -49,8 +46,7 @@ const config = {
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
// instrumentationHook: true,
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
instrumentationHook: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --",
|
||||
"lingui:extract": "lingui extract",
|
||||
"lingui:compile": "lingui compile --typescript",
|
||||
"lingui:extract:watch": "lingui extract --watch"
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
@@ -25,14 +22,9 @@
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@lingui/babel-preset-react": "^2.9.2",
|
||||
"@lingui/conf": "^5.3.2",
|
||||
"@lingui/macro": "^5.3.2",
|
||||
"@lingui/react": "^5.3.2",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
@@ -45,7 +37,7 @@
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.3.1",
|
||||
"next": "^15.3.4",
|
||||
"next": "^14.2.15",
|
||||
"next-logger": "^5.0.1",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
@@ -70,9 +62,6 @@
|
||||
"@kan/stripe": "workspace:*",
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@types/node": "^20.17.7",
|
||||
"@types/react": "catalog:react18",
|
||||
"@types/react-beautiful-dnd": "^13.1.7",
|
||||
|
||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 433 KiB |
@@ -1,7 +1,5 @@
|
||||
import type { SocialProvider } from "better-auth/social-providers";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
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";
|
||||
@@ -185,8 +183,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: t`Success`,
|
||||
message: t`You have been signed up successfully.`,
|
||||
header: "Success",
|
||||
message: "You have been signed up successfully.",
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
@@ -202,8 +200,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: t`Success`,
|
||||
message: t`You have been logged in successfully.`,
|
||||
header: "Success",
|
||||
message: "You have been logged in successfully.",
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
@@ -238,7 +236,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
|
||||
if (error) {
|
||||
setLoginError(
|
||||
t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
||||
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -259,14 +257,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
<Trans>Continue with {provider.name}</Trans>
|
||||
Continue with {provider.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -277,7 +274,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
or
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
@@ -287,11 +284,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid name`}
|
||||
Please enter a valid name
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -299,11 +296,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -312,11 +309,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid password`}
|
||||
Please enter a valid password
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,10 +329,10 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||
{isSignUp ? "Sign up with " : "Continue with "}
|
||||
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||
? t`email`
|
||||
: t`magic link`}
|
||||
? "email"
|
||||
: "magic link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,96 +1,11 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
TbLayoutSidebarRightCollapse,
|
||||
TbLayoutSidebarRightExpand,
|
||||
} from "react-icons/tb";
|
||||
import Link from "next/link";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import FeedbackModal from "./FeedbackModal";
|
||||
import Modal from "./modal";
|
||||
import { api } from "~/utils/api";
|
||||
import FeedbackButton from "./FeedbackButton";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
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.activeTheme === "dark";
|
||||
export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = api.user.getUser.useQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -98,86 +13,26 @@ export default function Dashboard({
|
||||
html {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 320px;
|
||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
|
||||
}
|
||||
`}</style>
|
||||
<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 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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -5,8 +5,6 @@ import type {
|
||||
} from "@tiptap/suggestion";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import {
|
||||
BubbleMenu,
|
||||
@@ -299,17 +297,7 @@ export default function Editor({
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: t`Add description... (type '/' to open commands)`,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
HTMLAttributes: {
|
||||
class: "text-blue-600 hover:text-blue-800 underline cursor-pointer",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
},
|
||||
validate: (href) => /^https?:\/\//.test(href),
|
||||
autolink: true,
|
||||
: "Add description... (type '/' to open commands)",
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: CommandItems,
|
||||
@@ -350,6 +338,7 @@ export default function Editor({
|
||||
<div ref={containerRef}>
|
||||
<style jsx global>{`
|
||||
.tiptap p.is-empty::before {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
@@ -362,7 +351,7 @@ export default function Editor({
|
||||
{!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-light-900 [&_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-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
148
apps/web/src/components/FeedbackButton.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import Link from "next/link";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
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: "Feedback sent",
|
||||
message: "Thank you for your feedback!",
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: async () => {
|
||||
showPopup({
|
||||
header: "Unable to send feedback",
|
||||
message: "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">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="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">
|
||||
Need help?{" "}
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
, or see our{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="justify-end">
|
||||
<Button size="sm" isLoading={createFeedback.isPending}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackButton;
|
||||
@@ -1,122 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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-sm 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-base dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
|
||||
@@ -115,7 +114,7 @@ export function LabelForm({
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-medium">
|
||||
{isEdit ? t`Edit label` : t`New label`}
|
||||
{isEdit ? "Edit label" : "New label"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -131,7 +130,7 @@ export function LabelForm({
|
||||
|
||||
<Input
|
||||
id="label-name"
|
||||
placeholder={t`Name`}
|
||||
placeholder="Name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -209,7 +208,7 @@ export function LabelForm({
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isEdit && (
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
label="Create another"
|
||||
isChecked={!!isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||
@@ -223,14 +222,14 @@ export function LabelForm({
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_LABEL", entityId)}
|
||||
>
|
||||
{t`Delete`}
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||
>
|
||||
{isEdit ? t`Update label` : t`Create label`}
|
||||
{isEdit ? "Update label" : "Create label"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { HiLanguage } from "react-icons/hi2";
|
||||
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { localeNames } from "~/locales";
|
||||
|
||||
export function LanguageSelector() {
|
||||
const { locale, setLocale, availableLocales } = useLocalisation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<HiLanguage className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<select
|
||||
id="language-select"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as any)}
|
||||
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500 sm:text-sm"
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{localeNames[loc]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import dynamic from "next/dynamic";
|
||||
|
||||
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
|
||||
|
||||
interface IconProps {
|
||||
type 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: 18, height: 18, fill: "white" }}
|
||||
style={{ width: 20, height: 20, fill: "white" }}
|
||||
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
@@ -19,12 +18,10 @@ 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,
|
||||
@@ -37,8 +34,8 @@ export function NewWorkspaceForm() {
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to create workspace`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to create workspace",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -61,7 +58,7 @@ export function NewWorkspaceForm() {
|
||||
<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 workspace`}
|
||||
New workspace
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -77,7 +74,7 @@ export function NewWorkspaceForm() {
|
||||
|
||||
<Input
|
||||
id="workspace-name"
|
||||
placeholder={t`Workspace name`}
|
||||
placeholder="Workspace name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -90,7 +87,7 @@ export function NewWorkspaceForm() {
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit" isLoading={createWorkspace.isPending}>
|
||||
{t`Create workspace`}
|
||||
Create workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,6 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ const PatternedBackground = () => (
|
||||
<svg className="h-full w-full">
|
||||
<pattern
|
||||
id="pattern"
|
||||
x="10"
|
||||
y="10"
|
||||
x="0.034759358288862785"
|
||||
y="3.335370511841166"
|
||||
width="14.423223834988539"
|
||||
height="14.423223834988539"
|
||||
patternUnits="userSpaceOnUse"
|
||||
|
||||
@@ -1,51 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const Button: React.FC<{
|
||||
href: string;
|
||||
current: boolean;
|
||||
name: string;
|
||||
json: object;
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
|
||||
}> = ({ href, current, name, json }) => {
|
||||
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}
|
||||
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",
|
||||
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",
|
||||
)}
|
||||
title={isCollapsed ? name : undefined}
|
||||
>
|
||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
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";
|
||||
@@ -23,7 +14,6 @@ import { useTheme } from "~/providers/theme";
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
isLoading: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
@@ -34,28 +24,8 @@ 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;
|
||||
|
||||
@@ -65,66 +35,27 @@ export default function SideNavigation({
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: t`Boards`,
|
||||
name: "Boards",
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
},
|
||||
{
|
||||
name: t`Members`,
|
||||
name: "Members",
|
||||
href: "/members",
|
||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||
},
|
||||
{
|
||||
name: t`Settings`,
|
||||
name: "Settings",
|
||||
href: "/settings",
|
||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||
},
|
||||
];
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
|
||||
<div>
|
||||
<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} />
|
||||
<WorkspaceMenu />
|
||||
<ul role="list" className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
@@ -133,23 +64,16 @@ export default function SideNavigation({
|
||||
current={pathname.includes(item.href)}
|
||||
name={item.name}
|
||||
json={item.icon}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
</div>
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
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 { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
@@ -17,63 +12,38 @@ interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
}: UserMenuProps) {
|
||||
const router = useRouter();
|
||||
const { themePreference, switchTheme } = 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={twMerge(!isCollapsed && "flex")}>
|
||||
<div className="flex">
|
||||
<div className="h-[30px] w-[30px] animate-pulse rounded-full 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 className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
</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"
|
||||
title={isCollapsed ? email : undefined}
|
||||
>
|
||||
<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">
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
@@ -93,14 +63,7 @@ export default function UserMenu({
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={twMerge(
|
||||
"mx-2 truncate text-sm",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
<span className="mx-2 truncate text-sm">{email}</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -114,16 +77,11 @@ export default function UserMenu({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
<span>{t`Theme`}</span>
|
||||
<span>Theme</span>
|
||||
</div>
|
||||
<Menu.Item>
|
||||
<button
|
||||
@@ -131,12 +89,12 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
className={classNames(
|
||||
themePreference === "system" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
{t`System`}
|
||||
System
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -145,12 +103,12 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
className={classNames(
|
||||
themePreference === "dark" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
{t`Dark`}
|
||||
Dark
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -159,44 +117,12 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
className={classNames(
|
||||
themePreference === "light" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
{t`Light`}
|
||||
</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`}
|
||||
Light
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
@@ -206,7 +132,7 @@ export default function UserMenu({
|
||||
onClick={handleLogout}
|
||||
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`Logout`}
|
||||
Logout
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
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({
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
export default function WorkspaceMenu() {
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
@@ -20,43 +14,22 @@ export default function WorkspaceMenu({
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<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 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>
|
||||
) : (
|
||||
<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">
|
||||
<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">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
@@ -73,12 +46,7 @@ export default function WorkspaceMenu({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
@@ -113,7 +81,7 @@ export default function WorkspaceMenu({
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
Create workspace
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,6 @@ 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(),
|
||||
},
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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 };
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { useLinguiContext } from "~/providers/lingui";
|
||||
import { activateLocale } from "~/utils/i18n";
|
||||
|
||||
export function useLocalisation() {
|
||||
const { i18n } = useLingui();
|
||||
const { locale, setLocale, availableLocales } = useLinguiContext();
|
||||
|
||||
const handleSetLocale = async (newLocale: Locale) => {
|
||||
await activateLocale(newLocale);
|
||||
setLocale(newLocale);
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
setLocale: handleSetLocale,
|
||||
availableLocales,
|
||||
formatDate: i18n.date,
|
||||
formatNumber: i18n.number,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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)");
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export const locales = ["en", "fr", "de", "es", "it", "nl"] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "en";
|
||||
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
en: "English",
|
||||
fr: "Français",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
nl: "Nederlands",
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
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: ["/"],
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import "~/styles/globals.css";
|
||||
import "~/utils/i18n";
|
||||
|
||||
import type { NextPage, Viewport } from "next";
|
||||
import type { AppProps, AppType } from "next/app";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { AppType } from "next/app";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { env } from "next-runtime-env";
|
||||
@@ -11,7 +8,6 @@ import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||
import { ModalProvider } from "~/providers/modal";
|
||||
import { PopupProvider } from "~/providers/popup";
|
||||
import { ThemeProvider } from "~/providers/theme";
|
||||
@@ -28,22 +24,7 @@ export const metadata = {
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
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 MyApp: AppType = ({ Component, pageProps }) => {
|
||||
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,8 +40,6 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
}
|
||||
}, [posthogKey]);
|
||||
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
@@ -80,21 +59,19 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
)}
|
||||
<script src="/__ENV.js" />
|
||||
<main className="font-sans">
|
||||
<LinguiProviderWrapper>
|
||||
<ThemeProvider>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
getLayout(<Component {...pageProps} />)
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</LinguiProviderWrapper>
|
||||
<ThemeProvider>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
<Component {...pageProps} />
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
<Component {...pageProps} />
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,6 @@ 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 ?? "",
|
||||
@@ -59,7 +58,6 @@ export default async function handler(
|
||||
new PutObjectCommand({
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
import { openApiDocument } from "@kan/api/openapi";
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import BoardView from "~/views/board";
|
||||
|
||||
const BoardPage: NextPageWithLayout = () => {
|
||||
export default function BoardPage() {
|
||||
return (
|
||||
<>
|
||||
<BoardView />
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<BoardView />
|
||||
</Dashboard>
|
||||
<Popup />
|
||||
</>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
BoardPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default BoardPage;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { NextPageWithLayout } from "../_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import BoardsView from "~/views/boards";
|
||||
|
||||
const BoardsPage: NextPageWithLayout = () => {
|
||||
export default function BoardsPage() {
|
||||
return (
|
||||
<>
|
||||
<BoardsView />
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<BoardsView />
|
||||
</Dashboard>
|
||||
<Popup />
|
||||
</>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
BoardsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default BoardsPage;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import CardView, { CardRightPanel } from "~/views/card";
|
||||
import CardView from "~/views/card";
|
||||
|
||||
const CardPage: NextPageWithLayout = () => {
|
||||
export default function CardPage() {
|
||||
return (
|
||||
<>
|
||||
<CardView />
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<CardView />
|
||||
</Dashboard>
|
||||
<Popup />
|
||||
</>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
CardPage.getLayout = (page) =>
|
||||
getDashboardLayout(page, <CardRightPanel />, true);
|
||||
|
||||
export default CardPage;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import MembersView from "~/views/members";
|
||||
|
||||
const MembersPage: NextPageWithLayout = () => {
|
||||
export default function MembersPage() {
|
||||
return (
|
||||
<>
|
||||
<MembersView />
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<MembersView />
|
||||
</Dashboard>
|
||||
<Popup />
|
||||
</>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
MembersPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default MembersPage;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import SettingsView from "~/views/settings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const SettingsPage: NextPageWithLayout = () => {
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<SettingsView />
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<SettingsView />
|
||||
</Dashboard>
|
||||
<Popup />
|
||||
</>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
SettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default SettingsPage;
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { defaultLocale, locales } from "~/locales";
|
||||
import { activateLocale, i18n, initializeI18n } from "~/utils/i18n";
|
||||
|
||||
interface LinguiContextType {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
availableLocales: Locale[];
|
||||
}
|
||||
|
||||
const LinguiContext = createContext<LinguiContextType | undefined>(undefined);
|
||||
|
||||
export function useLinguiContext() {
|
||||
const context = useContext(LinguiContext);
|
||||
if (!context) {
|
||||
throw new Error("useLinguiContext must be used within a LinguiProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface LinguiProviderProps {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}
|
||||
|
||||
function detectBrowserLocale(availableLocales: readonly string[]): Locale {
|
||||
if (typeof navigator === "undefined") {
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
const browserLanguages = navigator.languages || [navigator.language];
|
||||
|
||||
for (const browserLang of browserLanguages) {
|
||||
const langCode = browserLang.split("-")[0];
|
||||
|
||||
if (langCode && availableLocales.includes(langCode.toLowerCase())) {
|
||||
return langCode.toLowerCase() as Locale;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export function LinguiProviderWrapper({
|
||||
children,
|
||||
initialLocale = defaultLocale,
|
||||
}: LinguiProviderProps) {
|
||||
const [locale, setLocale] = useState<Locale>(defaultLocale);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
initializeI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const savedLocale = localStorage.getItem("locale") as Locale;
|
||||
|
||||
if (savedLocale && locales.includes(savedLocale)) {
|
||||
setLocale(savedLocale);
|
||||
} else {
|
||||
const detectedLocale = detectBrowserLocale(locales);
|
||||
setLocale(detectedLocale);
|
||||
}
|
||||
setIsHydrated(true);
|
||||
}, [initialLocale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHydrated && locale !== defaultLocale) {
|
||||
activateLocale(locale).then(() => {
|
||||
localStorage.setItem("locale", locale);
|
||||
});
|
||||
} else if (isHydrated) {
|
||||
localStorage.setItem("locale", locale);
|
||||
}
|
||||
}, [locale, isHydrated]);
|
||||
|
||||
return (
|
||||
<LinguiContext.Provider
|
||||
value={{ locale, setLocale, availableLocales: [...locales] }}
|
||||
>
|
||||
<I18nProvider i18n={i18n} key={locale}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
</LinguiContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,10 @@
|
||||
@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 {
|
||||
@@ -19,7 +18,7 @@
|
||||
transparent 110deg,
|
||||
transparent 360deg
|
||||
);
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
width: 200%;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { defaultLocale } from "~/locales";
|
||||
import { messages as enMessages } from "~/locales/en/messages";
|
||||
|
||||
const loadMessages = async (locale: Locale) => {
|
||||
switch (locale) {
|
||||
case "en":
|
||||
return enMessages;
|
||||
case "fr":
|
||||
return (await import("~/locales/fr/messages")).messages;
|
||||
case "de":
|
||||
return (await import("~/locales/de/messages")).messages;
|
||||
case "es":
|
||||
return (await import("~/locales/es/messages")).messages;
|
||||
case "it":
|
||||
return (await import("~/locales/it/messages")).messages;
|
||||
case "nl":
|
||||
return (await import("~/locales/nl/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
};
|
||||
|
||||
let isInitialized = false;
|
||||
const loadedLocales = new Set<string>();
|
||||
|
||||
export function initializeI18n(locale: Locale = defaultLocale) {
|
||||
if (!isInitialized) {
|
||||
i18n.load(defaultLocale, enMessages);
|
||||
i18n.activate(defaultLocale);
|
||||
loadedLocales.add(defaultLocale);
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
export async function activateLocale(locale: Locale) {
|
||||
if (!loadedLocales.has(locale)) {
|
||||
const messages = await loadMessages(locale);
|
||||
i18n.load(locale, messages);
|
||||
loadedLocales.add(locale);
|
||||
}
|
||||
|
||||
i18n.activate(locale);
|
||||
return i18n;
|
||||
}
|
||||
|
||||
initializeI18n();
|
||||
|
||||
export { i18n };
|
||||
@@ -1,7 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -28,7 +26,7 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Login | kan.bn`} />
|
||||
<PageHead title="Login | kan.bn" />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
@@ -38,15 +36,12 @@ export default function LoginPage() {
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Welcome back`}
|
||||
{isMagicLinkSent ? "Check your inbox" : "Welcome back"}
|
||||
</p>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Click on the link we've sent to {magicLinkRecipient} to sign
|
||||
in.
|
||||
</Trans>
|
||||
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -58,12 +53,10 @@ export default function LoginPage() {
|
||||
)}
|
||||
{!isSignUpDisabled && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
@@ -10,51 +7,34 @@ import { authClient } from "@kan/auth/client";
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export default function SignUpPage() {
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
if (isSignUpDisabled) {
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{t`Sign up disabled`}
|
||||
</p>
|
||||
<p className="text-md text-center text-light-1000 dark:text-dark-1000">
|
||||
{t`Sign up is currently disabled. Please try again later.`}
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
<div className="flex flex-col bg-light-100 dark:bg-dark-50 h-screen items-center justify-center">
|
||||
<p className="mb-2 text-light-1000 dark:text-dark-1000 font-semibold text-sm">Sign up is disabled</p>
|
||||
<Link href="/login" className="text-light-1000 dark:text-dark-1000 font-semibold text-sm rounded-md border border-light-900 dark:border-dark-900 px-4 py-2">Login</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
<PageHead title="Sign up | kan.bn" />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
@@ -64,15 +44,12 @@ export default function SignUpPage() {
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Get started`}
|
||||
{isMagicLinkSent ? "Check your inbox" : "Create your account"}
|
||||
</p>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Click on the link we've sent to {magicLinkRecipient} to sign
|
||||
in.
|
||||
</Trans>
|
||||
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -83,12 +60,10 @@ export default function SignUpPage() {
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/login">Sign in</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/login">Sign in</Link>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
@@ -12,12 +11,12 @@ export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
label: "Edit board URL",
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Delete board`,
|
||||
label: "Delete board",
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiMiniXMark,
|
||||
HiOutlineTag,
|
||||
@@ -90,7 +89,7 @@ const Filters = ({
|
||||
? [
|
||||
{
|
||||
key: "members",
|
||||
label: t`Members`,
|
||||
label: "Members",
|
||||
icon: <HiOutlineUserCircle size={16} />,
|
||||
items: formattedMembers,
|
||||
},
|
||||
@@ -98,7 +97,7 @@ const Filters = ({
|
||||
: []),
|
||||
{
|
||||
key: "labels",
|
||||
label: t`Labels`,
|
||||
label: "Labels",
|
||||
icon: <HiOutlineTag size={16} />,
|
||||
items: formattedLabels,
|
||||
},
|
||||
@@ -146,13 +145,13 @@ const Filters = ({
|
||||
disabled={isLoading}
|
||||
iconLeft={<IoFilterOutline />}
|
||||
>
|
||||
{t`Filter`}
|
||||
Filter
|
||||
</Button>
|
||||
{numOfFilters > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
aria-label={t`Clear filters`}
|
||||
aria-label="Clear filters"
|
||||
className="group absolute -right-[8px] -top-[8px] flex h-5 w-5 items-center justify-center rounded-full border-2 border-light-100 bg-light-1000 text-[8px] font-[700] text-light-600 dark:border-dark-50 dark:bg-dark-1000 dark:text-dark-600"
|
||||
>
|
||||
<span className="group-hover:hidden">{numOfFilters}</span>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
@@ -107,14 +106,14 @@ export default function List({
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add a card`,
|
||||
label: "Add a card",
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete list`,
|
||||
label: "Delete list",
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
@@ -92,14 +90,9 @@ export function NewCardForm({
|
||||
args.labelPublicIds.includes(label.publicId),
|
||||
),
|
||||
members:
|
||||
oldBoard.workspace.members
|
||||
.filter((member) =>
|
||||
args.memberPublicIds.includes(member.publicId),
|
||||
)
|
||||
.map((member) => ({
|
||||
...member,
|
||||
deletedAt: null,
|
||||
})) ?? [],
|
||||
oldBoard.workspace.members.filter((member) =>
|
||||
args.memberPublicIds.includes(member.publicId),
|
||||
) ?? [],
|
||||
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
|
||||
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
|
||||
index: position === "start" ? 0 : list.cards.length,
|
||||
@@ -122,10 +115,10 @@ export function NewCardForm({
|
||||
onError: (error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: t`Unable to create card`,
|
||||
header: "Unable to create card",
|
||||
message: error.data?.zodError?.fieldErrors.title?.[0]
|
||||
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
|
||||
: t`Please try again later, or contact customer support.`,
|
||||
: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -173,7 +166,6 @@ export function NewCardForm({
|
||||
member.user?.name ?? null,
|
||||
member.user?.email ?? member.email,
|
||||
),
|
||||
selected: memberPublicIds.includes(member.publicId),
|
||||
leftIcon: (
|
||||
<Avatar
|
||||
size="xs"
|
||||
@@ -234,7 +226,7 @@ export function NewCardForm({
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-5">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`New card`}
|
||||
New card
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -251,7 +243,7 @@ export function NewCardForm({
|
||||
<div>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={t`Card title`}
|
||||
placeholder="Card title"
|
||||
{...register("title")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -287,7 +279,7 @@ export function NewCardForm({
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!memberPublicIds.length ? (
|
||||
t`Members`
|
||||
"Members"
|
||||
) : (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{memberPublicIds.map((memberPublicId) => {
|
||||
@@ -324,11 +316,11 @@ export function NewCardForm({
|
||||
openModal("EDIT_LABEL", labelPublicId)
|
||||
}
|
||||
handleCreate={() => openModal("NEW_LABEL")}
|
||||
createNewItemLabel={t`Create new label`}
|
||||
createNewItemLabel="Create new label"
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!labelPublicIds.length ? (
|
||||
t`Labels`
|
||||
"Labels"
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -361,9 +353,7 @@ export function NewCardForm({
|
||||
})}
|
||||
</div>
|
||||
{labelPublicIds.length > 1 && (
|
||||
<div className="ml-1">
|
||||
<Trans>{`${labelPublicIds.length} labels`}</Trans>
|
||||
</div>
|
||||
<div className="ml-1">{`${labelPublicIds.length} labels`}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -388,7 +378,7 @@ export function NewCardForm({
|
||||
|
||||
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
label="Create another"
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={handleToggleCreateAnother}
|
||||
/>
|
||||
@@ -398,7 +388,7 @@ export function NewCardForm({
|
||||
type="submit"
|
||||
disabled={title.length === 0 || createCard.isPending}
|
||||
>
|
||||
{t`Create card`}
|
||||
Create card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
@@ -74,8 +73,8 @@ export function NewListForm({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: t`Unable to create list`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to create list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -109,7 +108,7 @@ export function NewListForm({
|
||||
<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 list`}
|
||||
New list
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -125,7 +124,7 @@ export function NewListForm({
|
||||
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder={t`List name`}
|
||||
placeholder="List name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -137,7 +136,7 @@ export function NewListForm({
|
||||
</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`Create another`}
|
||||
label="Create another"
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||
@@ -145,7 +144,7 @@ export function NewListForm({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit">{t`Create list`}</Button>
|
||||
<Button type="submit">Create list</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -13,6 +12,20 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const schema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: "Board URL must be at least 3 characters long",
|
||||
})
|
||||
.max(60, { message: "Board URL cannot exceed 60 characters" })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: "Board URL can only contain letters, numbers, and hyphens",
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
@@ -34,20 +47,6 @@ export function UpdateBoardSlugForm({
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const schema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: t`Board URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(60, { message: t`Board URL cannot exceed 60 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`Board URL can only contain letters, numbers, and hyphens`,
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -68,8 +67,8 @@ export function UpdateBoardSlugForm({
|
||||
const updateBoardSlug = api.board.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update board URL`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update board URL",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -79,15 +78,17 @@ export function UpdateBoardSlugForm({
|
||||
},
|
||||
});
|
||||
|
||||
const checkBoardSlugAvailability = api.board.checkSlugAvailability.useQuery(
|
||||
{
|
||||
boardSlug: debouncedSlug,
|
||||
boardPublicId,
|
||||
},
|
||||
{
|
||||
enabled: !!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
|
||||
},
|
||||
);
|
||||
const checkBoardSlugAvailability =
|
||||
api.board.checkSlugAvailability.useQuery(
|
||||
{
|
||||
boardSlug: debouncedSlug,
|
||||
boardPublicId,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
|
||||
},
|
||||
);
|
||||
|
||||
const isBoardSlugAvailable = checkBoardSlugAvailability.data;
|
||||
|
||||
@@ -99,7 +100,7 @@ export function UpdateBoardSlugForm({
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
if (!isBoardSlugAvailable) return;
|
||||
if (isBoardSlugAvailable.isReserved) return;
|
||||
if (isBoardSlugAvailable?.isReserved) return;
|
||||
|
||||
updateBoardSlug.mutate({
|
||||
slug: data.slug,
|
||||
@@ -112,7 +113,7 @@ export function UpdateBoardSlugForm({
|
||||
<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`Edit board URL`}
|
||||
Edit board URL
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -129,13 +130,10 @@ export function UpdateBoardSlugForm({
|
||||
<Input
|
||||
id="board-slug"
|
||||
{...register("slug")}
|
||||
errorMessage={
|
||||
errors.slug?.message ||
|
||||
(isBoardSlugAvailable?.isReserved
|
||||
? t`This board URL has already been taken`
|
||||
: undefined)
|
||||
}
|
||||
prefix={`${env("NEXT_PUBLIC_KAN_ENV") === "cloud" ? "kan.bn" : env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
|
||||
? "This board URL has already been taken"
|
||||
: undefined)}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -152,14 +150,7 @@ 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 className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="/settings?edit=workspace_url"
|
||||
onClick={closeModal}
|
||||
>
|
||||
{t`Edit workspace URL`}
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateBoardSlug.isPending}
|
||||
@@ -170,7 +161,7 @@ export function UpdateBoardSlugForm({
|
||||
isBoardSlugAvailable?.isReserved
|
||||
}
|
||||
>
|
||||
{t`Update`}
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
|
||||
@@ -42,15 +41,15 @@ const VisibilityButton = ({
|
||||
const updateBoardVisibility = api.board.update.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Board visibility updated`,
|
||||
message: t`The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
|
||||
header: "Board visibility updated",
|
||||
message: `The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update board visibility`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update board visibility",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -65,12 +64,12 @@ const VisibilityButton = ({
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
value: "Public",
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
value: "Private",
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
@@ -88,7 +87,7 @@ const VisibilityButton = ({
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !isAdmin}
|
||||
>
|
||||
{t`Visibility`}
|
||||
Visibility
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { DropResult } from "react-beautiful-dnd";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
@@ -13,7 +12,6 @@ import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import { LabelForm } from "~/components/LabelForm";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
@@ -33,7 +31,6 @@ 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";
|
||||
|
||||
@@ -131,8 +128,8 @@ export default function BoardPage() {
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: t`Unable to update list`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -187,8 +184,8 @@ export default function BoardPage() {
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: t`Unable to update card`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -238,11 +235,11 @@ export default function BoardPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
|
||||
title={`${boardData?.name ?? "Board"} | ${workspace.name ?? "Workspace"}`}
|
||||
/>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 flex w-full flex-col justify-between p-6 md:flex-row md:p-8">
|
||||
<div className="z-10 flex w-full justify-between 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" />
|
||||
@@ -251,7 +248,7 @@ export default function BoardPage() {
|
||||
{boardData && (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="order-2 focus-visible:outline-none md:order-1"
|
||||
className="focus-visible:outline-none"
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
@@ -263,18 +260,12 @@ export default function BoardPage() {
|
||||
</form>
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
{t`Board not found`}
|
||||
<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]">
|
||||
Board not found
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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 ?? ""}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
@@ -301,7 +292,7 @@ export default function BoardPage() {
|
||||
}}
|
||||
disabled={!boardData}
|
||||
>
|
||||
{t`New list`}
|
||||
New list
|
||||
</Button>
|
||||
<BoardDropdown isLoading={!boardData} />
|
||||
</div>
|
||||
@@ -316,15 +307,15 @@ export default function BoardPage() {
|
||||
</div>
|
||||
) : boardData ? (
|
||||
<>
|
||||
{boardData.lists.length === 0 ? (
|
||||
{boardData?.lists.length === 0 ? (
|
||||
<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">
|
||||
<HiOutlineSquare3Stack3D className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{t`No lists`}
|
||||
No lists
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`Get started by creating a new list`}
|
||||
Get started by creating a new list
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -332,7 +323,7 @@ export default function BoardPage() {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
>
|
||||
{t`Create new list`}
|
||||
Create new list
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -349,7 +340,7 @@ export default function BoardPage() {
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<div className="min-w-[2rem]" />
|
||||
{boardData.lists.map((list, index) => (
|
||||
{boardData?.lists.map((list, index) => (
|
||||
<List
|
||||
index={index}
|
||||
key={index}
|
||||
@@ -366,7 +357,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-225px)] 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-265px)] 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
|
||||
@@ -422,15 +413,7 @@ export default function BoardPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Modal
|
||||
modalSize={
|
||||
modalContentType === "NEW_CARD" ||
|
||||
modalContentType === "NEW_FEEDBACK"
|
||||
? "md"
|
||||
: "sm"
|
||||
}
|
||||
>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||
{modalContentType === "DELETE_BOARD" && (
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -19,7 +18,7 @@ export function BoardsList() {
|
||||
|
||||
if (isLoading)
|
||||
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-3 2xl:grid-cols-7">
|
||||
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xxl: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" />
|
||||
@@ -32,25 +31,23 @@ export function BoardsList() {
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{t`No boards`}
|
||||
No boards
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`Get started by creating a new board`}
|
||||
Get started by creating a new board
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||
{t`Create new board`}
|
||||
</Button>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>Create new board</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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-3 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-4 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="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
<p className="text-md px-4 font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FaTrello } from "react-icons/fa";
|
||||
@@ -81,7 +79,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 text-sm">
|
||||
<span className="ml-2 block truncate">
|
||||
{integrationProviders[field.value]?.name}
|
||||
</span>
|
||||
</span>
|
||||
@@ -113,7 +111,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
integrationProviders[integration.provider]
|
||||
?.icon
|
||||
}
|
||||
<span className="ml-2 block truncate text-sm font-normal">
|
||||
<span className="ml-2 block truncate font-normal">
|
||||
{
|
||||
integrationProviders[integration.provider]
|
||||
?.name
|
||||
@@ -125,12 +123,12 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
) : (
|
||||
<Listbox.Option
|
||||
key="trello_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
className="relative cursor-default select-none px-1"
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
<span className="ml-2 block truncate font-normal">
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -154,7 +152,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||
}
|
||||
>
|
||||
{hasIntegrations ? t`Select source` : t`Connect Trello`}
|
||||
{hasIntegrations ? "Select source" : "Connect Trello"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,8 +186,8 @@ const ImportTrello: React.FC = () => {
|
||||
const importBoards = api.import.trello.importBoards.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Import complete`,
|
||||
message: t`Your boards have been imported.`,
|
||||
header: "Import complete",
|
||||
message: "Your boards have been imported.",
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
@@ -201,8 +199,8 @@ const ImportTrello: React.FC = () => {
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Import failed`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Import failed",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -213,8 +211,6 @@ const ImportTrello: React.FC = () => {
|
||||
value: watch(board.id),
|
||||
}));
|
||||
|
||||
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
|
||||
|
||||
const onSubmitBoards = (values: Record<string, boolean>) => {
|
||||
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
||||
|
||||
@@ -239,7 +235,7 @@ const ImportTrello: React.FC = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`No boards found`}
|
||||
No boards found
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -271,7 +267,7 @@ const ImportTrello: React.FC = () => {
|
||||
|
||||
<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`Select all`}
|
||||
label="Select all"
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
onChange={() => {
|
||||
const newState = !isSelectAllEnabled;
|
||||
@@ -296,13 +292,8 @@ const ImportTrello: React.FC = () => {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>
|
||||
<Plural
|
||||
value={boardCount}
|
||||
one={`Import board (1)`}
|
||||
other={`Import boards (${boardCount})`}
|
||||
/>
|
||||
</Trans>
|
||||
Import boards (
|
||||
{boardWatchers?.filter((w) => w.value === true).length || 0})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,7 +310,7 @@ export function ImportBoardsForm() {
|
||||
<div className="flex w-full items-center justify-between px-5 pb-4 pt-5">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`New import`}
|
||||
New import
|
||||
</h2>
|
||||
<Link
|
||||
href="https://docs.kan.bn/imports/trello"
|
||||
|
||||
@@ -1,59 +1,27 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { Template } from "./TemplateBoards";
|
||||
import type { NewBoardInput } 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 { 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 templates = getTemplates();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<NewBoardInputWithTemplate>({
|
||||
resolver: zodResolver(schema),
|
||||
const { register, handleSubmit } = useForm<NewBoardInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
workspacePublicId: workspace.publicId || "",
|
||||
template: null,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTemplate = watch("template");
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const createBoard = api.board.create.useMutation({
|
||||
@@ -63,13 +31,8 @@ export function NewBoardForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||
createBoard.mutate({
|
||||
name: data.name,
|
||||
workspacePublicId: data.workspacePublicId,
|
||||
lists: data.template?.lists ?? [],
|
||||
labels: data.template?.labels ?? [],
|
||||
});
|
||||
const onSubmit = (data: NewBoardInput) => {
|
||||
createBoard.mutate(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,7 +45,7 @@ export function NewBoardForm() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`New board`}</h2>
|
||||
<h2 className="text-sm font-bold">New board</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
@@ -96,9 +59,8 @@ export function NewBoardForm() {
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`Name`}
|
||||
placeholder="Name"
|
||||
{...register("name", { required: true })}
|
||||
errorMessage={errors.name?.message}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -107,25 +69,11 @@ 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`}
|
||||
Create board
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
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";
|
||||
@@ -20,38 +17,43 @@ export default function BoardsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<PageHead title={`Boards | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="h-full p-8">
|
||||
<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`Boards`}
|
||||
Boards
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
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"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
<Button
|
||||
<div className="flex h-5 w-5 items-center">
|
||||
<HiArrowDownTray
|
||||
className="-mr-0.5 h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
variant="primary"
|
||||
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"
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`New`}
|
||||
</Button>
|
||||
<div className="h-5 w-5 items-center">
|
||||
<HiOutlinePlusSmall
|
||||
className="-mr-0.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
<Modal>
|
||||
{modalContentType === "NEW_BOARD" && <NewBoardForm />}
|
||||
{modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineArrowRight,
|
||||
@@ -11,24 +8,24 @@ import {
|
||||
HiOutlineUserMinus,
|
||||
HiOutlineUserPlus,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import Comment from "./Comment";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
const dateLocaleMap = {
|
||||
en: enGB,
|
||||
fr: fr,
|
||||
de: de,
|
||||
es: es,
|
||||
it: it,
|
||||
nl: nl,
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": "created the card",
|
||||
"card.updated.title": "updated the title",
|
||||
"card.updated.description": "updated the description",
|
||||
"card.updated.list": "moved the card to another list",
|
||||
"card.updated.label.added": "added a label to the card",
|
||||
"card.updated.label.removed": "removed a label from the card",
|
||||
"card.updated.member.added": "added a member to the card",
|
||||
"card.updated.member.removed": "removed a member from the card",
|
||||
} as const;
|
||||
|
||||
const getActivityText = ({
|
||||
@@ -48,17 +45,6 @@ const getActivityText = ({
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
}) => {
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": t`created the card`,
|
||||
"card.updated.title": t`updated the title`,
|
||||
"card.updated.description": t`updated the description`,
|
||||
"card.updated.list": t`moved the card to another list`,
|
||||
"card.updated.label.added": t`added a label to the card`,
|
||||
"card.updated.label.removed": t`removed a label from the card`,
|
||||
"card.updated.member.added": t`added a member to the card`,
|
||||
"card.updated.member.removed": t`removed a member from the card`,
|
||||
} as const;
|
||||
|
||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
|
||||
|
||||
@@ -70,54 +56,54 @@ const getActivityText = ({
|
||||
|
||||
if (type === "card.updated.title" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
<>
|
||||
updated the title to <TextHighlight>{toTitle}</TextHighlight>
|
||||
</Trans>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.list" && fromList && toList) {
|
||||
return (
|
||||
<Trans>
|
||||
<>
|
||||
moved the card from <TextHighlight>{fromList}</TextHighlight> to
|
||||
<TextHighlight>{toList}</TextHighlight>
|
||||
</Trans>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.added" && memberName) {
|
||||
if (isSelf) return <Trans>self-assigned the card</Trans>;
|
||||
if (isSelf) return <>self-assigned the card</>;
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
<>
|
||||
assigned <TextHighlight>{memberName}</TextHighlight> to the card
|
||||
</Trans>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.removed" && memberName) {
|
||||
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
|
||||
if (isSelf) return <>unassigned themselves from the card</>;
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
<>
|
||||
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
|
||||
</Trans>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.label.added" && label) {
|
||||
return (
|
||||
<Trans>
|
||||
<>
|
||||
added label <TextHighlight>{label}</TextHighlight>
|
||||
</Trans>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.label.removed" && label) {
|
||||
return (
|
||||
<Trans>
|
||||
<>
|
||||
removed label <TextHighlight>{label}</TextHighlight>
|
||||
</Trans>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,9 +148,6 @@ const ActivityList = ({
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
const { locale } = useLocalisation();
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] || enGB;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
@@ -175,7 +158,7 @@ const ActivityList = ({
|
||||
fromList: activity.fromList?.name ?? null,
|
||||
toList: activity.toList?.name ?? null,
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
isSelf: activity.member?.user?.id === data?.user.id,
|
||||
isSelf: activity.member?.user?.id === data?.user?.id,
|
||||
label: activity.label?.name ?? null,
|
||||
});
|
||||
|
||||
@@ -188,10 +171,10 @@ const ActivityList = ({
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
isLoading={isLoading}
|
||||
createdAt={activity.createdAt.toISOString()}
|
||||
createdAt={activity.createdAt}
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === data?.user.id}
|
||||
isAuthor={activity.comment?.createdBy === data?.user?.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
/>
|
||||
);
|
||||
@@ -230,7 +213,6 @@ const ActivityList = ({
|
||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||
{formatDistanceToNow(new Date(activity.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: currentDateLocale,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
@@ -58,8 +57,8 @@ const Comment = ({
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update comment`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update comment",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -77,7 +76,7 @@ const Comment = ({
|
||||
...(isAuthor
|
||||
? [
|
||||
{
|
||||
label: t`Edit comment`,
|
||||
label: "Edit comment",
|
||||
action: () => setIsEditing(true),
|
||||
icon: <HiPencil className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
@@ -86,7 +85,7 @@ const Comment = ({
|
||||
...(isAuthor || isAdmin
|
||||
? [
|
||||
{
|
||||
label: t`Delete comment`,
|
||||
label: "Delete comment",
|
||||
action: () => openModal("DELETE_COMMENT", publicId),
|
||||
icon: <HiTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
@@ -118,7 +117,7 @@ const Comment = ({
|
||||
</span>
|
||||
{isEdited && (
|
||||
<span className="text-light-900 dark:text-dark-800">
|
||||
{t` (edited)`}
|
||||
{" (edited)"}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -141,11 +140,11 @@ const Comment = ({
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
placeholder="Add a 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-sm 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-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
|
||||
@@ -153,14 +152,14 @@ const Comment = ({
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
{t`Cancel`}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={updateCommentMutation.isPending}
|
||||
type="submit"
|
||||
size="sm"
|
||||
>
|
||||
{t`Save`}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -48,8 +47,8 @@ export function DeleteCardConfirmation({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: t`Unable to delete card`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to delete card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -72,10 +71,10 @@ export function DeleteCardConfirmation({
|
||||
<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 card?`}
|
||||
Are you sure you want to delete this card?
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`This action can't be undone.`}
|
||||
{"This action can't be undone."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end sm:mt-6">
|
||||
@@ -83,13 +82,13 @@ export function DeleteCardConfirmation({
|
||||
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
{t`Cancel`}
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleDeleteCard}
|
||||
isLoading={deleteCardMutation.isPending}
|
||||
>
|
||||
{t`Delete`}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -41,8 +39,8 @@ export function DeleteCommentConfirmation({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: t`Unable to delete comment`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to delete comment",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -62,10 +60,10 @@ export function DeleteCommentConfirmation({
|
||||
<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 comment?`}
|
||||
Are you sure you want to delete this comment?
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`This action can't be undone.`}
|
||||
{"This action can't be undone."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end sm:mt-6">
|
||||
@@ -73,13 +71,13 @@ export function DeleteCommentConfirmation({
|
||||
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
{t`Cancel`}
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleDeleteComment}
|
||||
isLoading={deleteCommentMutation.isPending}
|
||||
>
|
||||
{t`Delete`}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
@@ -11,7 +10,7 @@ export default function BoardDropdown() {
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Delete card`,
|
||||
label: "Delete card",
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -69,8 +68,8 @@ export default function LabelSelector({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: t`Unable to update labels`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update labels",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -95,7 +94,7 @@ export default function LabelSelector({
|
||||
}}
|
||||
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={() => openModal("NEW_LABEL")}
|
||||
createNewItemLabel={t`Create new label`}
|
||||
createNewItemLabel="Create new label"
|
||||
asChild
|
||||
>
|
||||
{selectedLabels.length ? (
|
||||
@@ -106,16 +105,13 @@ export default function LabelSelector({
|
||||
</Menu.Button>
|
||||
))}
|
||||
<Menu.Button>
|
||||
<Badge
|
||||
value={t`Add label`}
|
||||
iconLeft={<HiMiniPlus size={14} />}
|
||||
/>
|
||||
<Badge value="Add label" iconLeft={<HiMiniPlus size={14} />} />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<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">
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Add label`}
|
||||
Add label
|
||||
</Menu.Button>
|
||||
)}
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -49,8 +48,8 @@ export default function ListSelector({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: t`Unable to update list`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -79,7 +78,7 @@ export default function ListSelector({
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
{selectedList?.value}
|
||||
</Menu.Button>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -56,8 +55,6 @@ export default function MemberSelector({
|
||||
...oldCard.members,
|
||||
{
|
||||
publicId: update.workspaceMemberPublicId,
|
||||
email: memberToAdd?.email ?? "",
|
||||
deletedAt: null,
|
||||
user: {
|
||||
id: memberToAdd?.user?.id ?? "",
|
||||
name: memberToAdd?.user?.name ?? "",
|
||||
@@ -76,8 +73,8 @@ export default function MemberSelector({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: t`Unable to update members`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update members",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -109,10 +106,10 @@ export default function MemberSelector({
|
||||
});
|
||||
}}
|
||||
handleCreate={handleInviteMember}
|
||||
createNewItemLabel={t`Invite member`}
|
||||
createNewItemLabel="Invite member"
|
||||
asChild
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
@@ -127,7 +124,7 @@ export default function MemberSelector({
|
||||
) : (
|
||||
<>
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Add member`}
|
||||
{"Add member"}
|
||||
</>
|
||||
)}
|
||||
</Menu.Button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
@@ -27,8 +26,8 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const addCommentMutation = api.card.addComment.useMutation({
|
||||
onError: (_error, _newList) => {
|
||||
showPopup({
|
||||
header: t`Unable to add comment`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to add comment",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -51,10 +50,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
placeholder="Add a comment..."
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
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";
|
||||
@@ -26,6 +23,7 @@ import LabelSelector from "./components/LabelSelector";
|
||||
import ListSelector from "./components/ListSelector";
|
||||
import MemberSelector from "./components/MemberSelector";
|
||||
import NewCommentForm from "./components/NewCommentForm";
|
||||
import Editor from "~/components/Editor";
|
||||
|
||||
interface FormValues {
|
||||
cardId: string;
|
||||
@@ -33,18 +31,29 @@ interface FormValues {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function CardRightPanel() {
|
||||
export default function CardPage() {
|
||||
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 } = api.card.byId.useQuery({
|
||||
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 labels = board?.labels;
|
||||
const activities = card?.activities;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
const selectedLabels = card?.labels;
|
||||
const selectedMembers = card?.members;
|
||||
@@ -99,64 +108,11 @@ export function CardRightPanel() {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
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 } = 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({
|
||||
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({
|
||||
header: t`Unable to update card`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to update card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -181,17 +137,19 @@ export default function CardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const description = watch("description");
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
|
||||
title={`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
|
||||
/>
|
||||
<div className="flex h-full flex-1 flex-row">
|
||||
<div className="m-auto flex h-full w-full max-w-[800px] flex-col overflow-hidden">
|
||||
<div className="h-full max-h-[calc(100dvh-3rem)] overflow-y-auto p-6 md:max-h-[calc(100dvh-4rem)] md:p-8">
|
||||
<div className="mb-8 flex w-full items-center justify-between md:mt-6">
|
||||
<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" />
|
||||
@@ -231,13 +189,13 @@ export default function CardPage() {
|
||||
)}
|
||||
{!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`}
|
||||
Card not found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card && (
|
||||
<>
|
||||
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
@@ -251,9 +209,9 @@ export default function CardPage() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
<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`}
|
||||
Activity
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
@@ -271,9 +229,34 @@ export default function CardPage() {
|
||||
)}
|
||||
</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">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">Labels</p>
|
||||
<LabelSelector
|
||||
cardPublicId={cardId}
|
||||
labels={formattedLabels}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<p className="my-2 w-[100px] text-sm">Members</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
<Modal>
|
||||
{modalContentType === "NEW_LABEL" && (
|
||||
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -51,14 +50,15 @@ const Cta = ({ theme }: { theme: string }) => {
|
||||
<p>kan.bn/{currentWorkspaceSlug}</p>
|
||||
</div>
|
||||
<h2 className="text-balance text-4xl font-bold tracking-tight text-light-1000 dark:text-dark-1000 sm:text-4xl">
|
||||
{t`Get started for free today`}
|
||||
Get started for free today
|
||||
</h2>
|
||||
<p className="text-md/8 mx-auto mt-6 max-w-[375px] text-pretty text-light-900 dark:text-dark-900">
|
||||
{t`Unlimited boards, unlimited lists, unlimited cards. No credit card required.`}
|
||||
Unlimited boards, unlimited lists, unlimited cards. No credit card
|
||||
required.
|
||||
</p>
|
||||
<Link href="/signup">
|
||||
<div className="mt-10 flex items-center justify-center gap-x-6">
|
||||
<Button size="lg">{t`Get started`}</Button>
|
||||
<Button size="lg">Get started</Button>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HiMiniMinusSmall, HiMiniPlusSmall } from "react-icons/hi2";
|
||||
|
||||
const Text = ({ children }: { children: React.ReactNode }) => {
|
||||
@@ -16,145 +14,143 @@ const Text = ({ children }: { children: React.ReactNode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Faqs = () => {
|
||||
const faqs = [
|
||||
{
|
||||
question: t`Why make an open source Trello?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`When Trello launched in 2011, it blew everyone away with its carefully designed simplicity, but over time (and atlassification) it lost its magic and grew into something closer to "Jira Lite". This project is our attempt to recapture the original magic of Trello's simplicity, in open source.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`What's the difference between Kan and Trello?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`The main difference between Kan and Trello is that Kan is open source, allowing anyone to view, modify, and contribute to our code. Our cloud offering also offers no restrictions on features for individual use, whereas Trello locks basic features such as the number of boards you can create behind a paywall.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How is the project funded?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`For long-term sustainability, we recognise all good open source projects need a source of revenue. Ours comes from the paid cloud offering for workspaces with multiple users (free during the beta) and for custom workspace slugs. There's no obligation to use it, and you can self-host the software on your own infrastructure free of charge.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I import my Trello boards?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
Importing your Trello boards into Kan is easy. You can follow our
|
||||
step-by-step guide{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn/imports/trello"
|
||||
className="underline"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I get a custom URL?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
You can get a custom workspace URL, like{" "}
|
||||
<Link href="https://kan.bn/kan" className="underline">
|
||||
kan.bn/kan
|
||||
</Link>
|
||||
, by going into your{" "}
|
||||
<Link href="https://kan.bn/settings" className="underline">
|
||||
workspace settings
|
||||
</Link>{" "}
|
||||
and purchasing a pro workspace subscription. All subscriptions help
|
||||
fund the development of the project!
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`Do you offer a free plan?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`Yes, we offer an forever free plan for individual use. No restrictions, no paywalls, no limits.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`What license are you using?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
We are using the{" "}
|
||||
<Link
|
||||
href="https://github.com/kan-bn/kan/blob/main/LICENSE"
|
||||
className="underline"
|
||||
>
|
||||
AGPL-3.0 license
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I invite team members?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
You can invite team members by clicking the "Invite" button in the
|
||||
top right corner of the{" "}
|
||||
<Link href="https://kan.bn/members" className="underline">
|
||||
members page
|
||||
</Link>{" "}
|
||||
and entering their email address. They will receive an email with a
|
||||
link to join the workspace.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I self-host?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
You can self-host by following the instructions in our{" "}
|
||||
<Link href="https://github.com/kanbn/kan" className="underline">
|
||||
repo
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
const faqs = [
|
||||
{
|
||||
question: "Why make an open source Trello?",
|
||||
answer: (
|
||||
<Text>
|
||||
When Trello launched in 2011, it blew everyone away with its carefully
|
||||
designed simplicity, but over time (and atlassification) it lost its
|
||||
magic and grew into something closer to "Jira Lite". This project is our
|
||||
attempt to recapture the original magic of Trello’s simplicity, in open
|
||||
source.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "What's the difference between Kan and Trello?",
|
||||
answer: (
|
||||
<Text>
|
||||
The main difference between Kan and Trello is that Kan is open source,
|
||||
allowing anyone to view, modify, and contribute to our code. Our cloud
|
||||
offering also offers no restrictions on features for individual use,
|
||||
whereas Trello locks basic features such as the number of boards you can
|
||||
create behind a paywall.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How is the project funded?",
|
||||
answer: (
|
||||
<Text>
|
||||
For long-term sustainability, we recognise all good open source projects
|
||||
need a source of revenue. Ours comes from the paid cloud offering for
|
||||
workspaces with multiple users (free during the beta) and for custom
|
||||
workspace slugs. There's no obligation to use it, and you can self-host
|
||||
the software on your own infrastructure free of charge.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I import my Trello boards?",
|
||||
answer: (
|
||||
<Text>
|
||||
Importing your Trello boards into Kan is easy. You can follow our
|
||||
step-by-step guide{" "}
|
||||
<Link href="https://docs.kan.bn/imports/trello" className="underline">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I get a custom URL?",
|
||||
answer: (
|
||||
<Text>
|
||||
You can get a custom workspace URL, like{" "}
|
||||
<Link href="https://kan.bn/kan" className="underline">
|
||||
kan.bn/kan
|
||||
</Link>
|
||||
, by going into your{" "}
|
||||
<Link href="https://kan.bn/settings" className="underline">
|
||||
workspace settings
|
||||
</Link>{" "}
|
||||
and purchasing a pro workspace subscription. All subscriptions help fund
|
||||
the development of the project!
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "Do you offer a free plan?",
|
||||
answer: (
|
||||
<Text>
|
||||
Yes, we offer an forever free plan for individual use. No restrictions,
|
||||
no paywalls, no limits.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "What license are you using?",
|
||||
answer: (
|
||||
<Text>
|
||||
We are using the{" "}
|
||||
<Link
|
||||
href="https://github.com/kan-bn/kan/blob/main/LICENSE"
|
||||
className="underline"
|
||||
>
|
||||
AGPL-3.0 license
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I invite team members?",
|
||||
answer: (
|
||||
<Text>
|
||||
You can invite team members by clicking the "Invite" button in the top
|
||||
right corner of the{" "}
|
||||
<Link href="https://kan.bn/members" className="underline">
|
||||
members page
|
||||
</Link>{" "}
|
||||
and entering their email address. They will receive an email with a link
|
||||
to join the workspace.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I self-host?",
|
||||
answer: (
|
||||
<Text>
|
||||
You can self-host by following the instructions in our{" "}
|
||||
<Link href="https://github.com/kanbn/kan" className="underline">
|
||||
repo
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const Faqs = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-4">
|
||||
<div className="flex flex-col items-center justify-center pb-12">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`FAQs`}</p>
|
||||
<p>FAQs</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Questions?`}
|
||||
Questions?
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
<Trans>
|
||||
Find answers to common questions about the project. Can't find what
|
||||
you're looking for? Feel free to{" "}
|
||||
<Link href="mailto:support@kan.bn" className="underline">
|
||||
contact us
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
Find answers to common questions about the project. Can't find what
|
||||
you're looking for? Feel free to{" "}
|
||||
<Link href="mailto:support@kan.bn" className="underline">
|
||||
contact us
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-400">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
|
||||
import activityLogsIconDark from "~/assets/activity-logs-dark.json";
|
||||
@@ -63,7 +62,7 @@ const FeatureItem = ({
|
||||
|
||||
{feature.comingSoon && (
|
||||
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
|
||||
{t`Coming soon`}
|
||||
Coming soon
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -72,47 +71,47 @@ const FeatureItem = ({
|
||||
|
||||
const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: t`Board visibility`,
|
||||
description: t`Control who can view and edit your boards.`,
|
||||
title: "Board visibility",
|
||||
description: "Control who can view and edit your boards.",
|
||||
icon: isDark ? boardVisibilityIconDark : boardVisibilityIconLight,
|
||||
},
|
||||
{
|
||||
title: t`Workspace members`,
|
||||
description: t`Collaborate seamlessly with your team.`,
|
||||
title: "Workspace members",
|
||||
description: "Collaborate seamlessly with your team.",
|
||||
icon: isDark ? membersIconDark : membersIconLight,
|
||||
},
|
||||
{
|
||||
title: t`Trello imports`,
|
||||
description: t`Import your Trello boards and hit the ground running.`,
|
||||
title: "Trello imports",
|
||||
description: "Import your Trello boards and hit the ground running.",
|
||||
icon: isDark ? importsIconDark : importsIconLight,
|
||||
},
|
||||
{
|
||||
title: t`Labels & Filters`,
|
||||
description: t`Organize and find cards quickly with powerful filtering tools.`,
|
||||
title: "Labels & Filters",
|
||||
description:
|
||||
"Organize and find cards quickly with powerful filtering tools.",
|
||||
icon: isDark ? labelsIconDark : labelsIconLight,
|
||||
},
|
||||
{
|
||||
title: t`Comments`,
|
||||
description: t`Discuss and collaborate on cards.`,
|
||||
title: "Comments",
|
||||
description: "Discuss and collaborate on cards.",
|
||||
icon: isDark ? commentsIconDark : commentsIconLight,
|
||||
},
|
||||
{
|
||||
title: t`Activity logs`,
|
||||
description: t`Track all card changes with detailed activity history.`,
|
||||
title: "Activity logs",
|
||||
description: "Track all card changes with detailed activity history.",
|
||||
icon: isDark ? activityLogsIconDark : activityLogsIconLight,
|
||||
},
|
||||
{
|
||||
title: t`Templates`,
|
||||
description: t`Save time with reusable board templates.`,
|
||||
title: "Templates",
|
||||
description: "Save time with reusable board templates.",
|
||||
icon: isDark ? templatesIconDark : templatesIconLight,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
title: t`Integrations`,
|
||||
description: t`Connect your favorite tools to streamline your workflow.`,
|
||||
title: "Integrations",
|
||||
description: "Connect your favorite tools to streamline your workflow.",
|
||||
icon: isDark ? integrationsIconDark : integrationsIconLight,
|
||||
comingSoon: true,
|
||||
},
|
||||
@@ -122,14 +121,15 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-24">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Features`}</p>
|
||||
<p>Features</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Kanban reimagined`}
|
||||
Kanban reimagined
|
||||
</p>
|
||||
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
{t`Simple, visual task management that just works. Drag and drop cards, collaborate with your team, and get more done.`}
|
||||
Simple, visual task management that just works. Drag and drop cards,
|
||||
collaborate with your team, and get more done.
|
||||
</p>
|
||||
<div className="mx-auto mt-16 w-full max-w-7xl">
|
||||
<div className="grid w-full grid-cols-1 gap-4 [mask-image:linear-gradient(to_bottom,black_92%,transparent_100%)] sm:grid-cols-2 md:grid-cols-3 md:[mask-image:linear-gradient(to_bottom,black_85%,transparent_100%)] lg:grid-cols-4 lg:[mask-image:linear-gradient(to_bottom,black_85%,transparent_100%)]">
|
||||
@@ -142,9 +142,9 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
<div>
|
||||
<div className="mt-8 flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<p className="text-xs lg:text-sm">
|
||||
{t`We're just getting started. `}
|
||||
{`We're just getting started. `}
|
||||
<Link href="/kan/roadmap" className="underline">
|
||||
{t`View our roadmap.`}
|
||||
View our roadmap.
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||
|
||||
import { LanguageSelector } from "~/components/LanguageSelector";
|
||||
const navigation = {
|
||||
documentation: [
|
||||
{ name: "Getting started", href: "https://docs.kan.bn/introduction" },
|
||||
{
|
||||
name: "Importing from Trello",
|
||||
href: "https://docs.kan.bn/imports/trello",
|
||||
},
|
||||
{
|
||||
name: "API Reference",
|
||||
href: "https://docs.kan.bn/api-reference/introduction",
|
||||
},
|
||||
],
|
||||
company: [
|
||||
{ name: "Roadmap", href: "/kan/roadmap" },
|
||||
{ name: "GitHub", href: "https://github.com/kanbn/kan" },
|
||||
{ name: "Contact", href: "mailto:support@kan.bn" },
|
||||
],
|
||||
legal: [
|
||||
{ name: "Terms of service", href: "/terms" },
|
||||
{ name: "Privacy policy", href: "/privacy" },
|
||||
{
|
||||
name: "License",
|
||||
href: "https://github.com/kanbn/kan?tab=AGPL-3.0-1-ov-file#readme",
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{ name: "Features", href: "/#features" },
|
||||
{ name: "Pricing", href: "/#pricing" },
|
||||
{ name: "FAQs", href: "/#faq" },
|
||||
],
|
||||
};
|
||||
|
||||
const StatusMarker = () => (
|
||||
<Link
|
||||
@@ -15,43 +44,11 @@ const StatusMarker = () => (
|
||||
<span className="absolute -inset-[1px] animate-[ping_1s_infinite] rounded-full bg-green-500/30"></span>
|
||||
<span className="absolute inset-0 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
{t`All systems operational`}
|
||||
All systems operational
|
||||
</Link>
|
||||
);
|
||||
|
||||
const Footer = () => {
|
||||
const navigation = {
|
||||
documentation: [
|
||||
{ name: t`Getting started`, href: "https://docs.kan.bn/introduction" },
|
||||
{
|
||||
name: t`Importing from Trello`,
|
||||
href: "https://docs.kan.bn/imports/trello",
|
||||
},
|
||||
{
|
||||
name: t`API Reference`,
|
||||
href: "https://docs.kan.bn/api-reference/introduction",
|
||||
},
|
||||
],
|
||||
company: [
|
||||
{ name: t`Roadmap`, href: "/kan/roadmap" },
|
||||
{ name: t`GitHub`, href: "https://github.com/kanbn/kan" },
|
||||
{ name: t`Contact`, href: "mailto:support@kan.bn" },
|
||||
],
|
||||
legal: [
|
||||
{ name: t`Terms of service`, href: "/terms" },
|
||||
{ name: t`Privacy policy`, href: "/privacy" },
|
||||
{
|
||||
name: t`License`,
|
||||
href: "https://github.com/kanbn/kan?tab=AGPL-3.0-1-ov-file#readme",
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{ name: t`Features`, href: "/#features" },
|
||||
{ name: t`Pricing`, href: "/#pricing" },
|
||||
{ name: t`FAQs`, href: "/#faq" },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="z-10 w-full border-t border-light-300 bg-light-50 py-8 dark:border-dark-300 dark:bg-dark-50">
|
||||
<div className="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 lg:py-24">
|
||||
@@ -67,14 +64,13 @@ const Footer = () => {
|
||||
</div>
|
||||
|
||||
<StatusMarker />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
||||
<div className="md:grid md:grid-cols-2 md:gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Documentation`}
|
||||
Documentation
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.documentation.map((item) => (
|
||||
@@ -91,7 +87,7 @@ const Footer = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mt-10 text-sm/6 font-semibold text-light-1000 dark:text-dark-1000 md:mt-0">
|
||||
{t`Company`}
|
||||
Company
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.company.map((item) => (
|
||||
@@ -110,7 +106,7 @@ const Footer = () => {
|
||||
<div className="md:grid md:grid-cols-2 md:gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Resources`}
|
||||
Resources
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.resources.map((item) => (
|
||||
@@ -127,7 +123,7 @@ const Footer = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mt-10 text-sm/6 font-semibold text-light-1000 dark:text-dark-1000 md:mt-0">
|
||||
{t`Legal`}
|
||||
Legal
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.legal.map((item) => (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -10,10 +9,10 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const menuItems = [
|
||||
{ label: t`Roadmap`, href: "/kan/roadmap", openInNewTab: true },
|
||||
{ label: t`Features`, href: "#features" },
|
||||
{ label: t`Pricing`, href: "#pricing" },
|
||||
{ label: t`Docs`, href: "https://docs.kan.bn", openInNewTab: true },
|
||||
{ label: "Roadmap", href: "/kan/roadmap", openInNewTab: true },
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "Docs", href: "https://docs.kan.bn", openInNewTab: true },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,7 +69,7 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="z-50 p-2 lg:hidden"
|
||||
aria-label={t`Toggle menu`}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -93,13 +92,13 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
</button>
|
||||
<div className="hidden w-[200px] justify-end gap-2 lg:flex">
|
||||
{isLoggedIn ? (
|
||||
<Button href="/boards">{t`Go to app`}</Button>
|
||||
<Button href="/boards">Go to app</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button href="/login" variant="ghost">
|
||||
{t`Sign in`}
|
||||
Sign in
|
||||
</Button>
|
||||
<Button href="/signup">{t`Get started`}</Button>
|
||||
<Button href="/signup">Get started</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -130,15 +129,15 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
{isLoggedIn ? (
|
||||
<Button href="/boards" onClick={toggleMenu}>
|
||||
{t`Go to app`}
|
||||
Go to app
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button href="/login" variant="ghost" onClick={toggleMenu}>
|
||||
{t`Sign in`}
|
||||
Sign in
|
||||
</Button>
|
||||
<Button href="/signup" onClick={toggleMenu}>
|
||||
{t`Get started`}
|
||||
Get started
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,104 +1,108 @@
|
||||
import Link from "next/link";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
import { HiCheckCircle } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Frequency = "monthly" | "annually";
|
||||
|
||||
const frequencies = [
|
||||
{
|
||||
value: "monthly" as Frequency,
|
||||
label: "Monthly",
|
||||
priceSuffix: "per user/month",
|
||||
},
|
||||
{
|
||||
value: "annually" as Frequency,
|
||||
label: "Yearly",
|
||||
priceSuffix: "per user/month",
|
||||
},
|
||||
];
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: "Individuals",
|
||||
id: "tier-individuals",
|
||||
href: "signup",
|
||||
buttonText: "Get Started",
|
||||
price: { monthly: "Free", annually: "Free" },
|
||||
description:
|
||||
"Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.",
|
||||
featureHeader: "Free, forever",
|
||||
features: [
|
||||
"1 user",
|
||||
"Unlimited boards",
|
||||
"Unlimited lists",
|
||||
"Unlimited cards",
|
||||
"Unlimited comments",
|
||||
"Unlimited activity log",
|
||||
],
|
||||
showPrice: true,
|
||||
},
|
||||
{
|
||||
name: "Teams",
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: "Get Started",
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description:
|
||||
"Kanban is better with a team. Perfect for small and growing teams looking to collaborate.",
|
||||
featureHeader: "Everything in the free plan, plus:",
|
||||
features: [
|
||||
"Workspace members",
|
||||
"Admin roles",
|
||||
"Priority email support",
|
||||
"Support the development of the project",
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
},
|
||||
{
|
||||
name: "Self Host",
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: "View docs",
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description:
|
||||
"Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.",
|
||||
featureHeader: "Complete control and ownership:",
|
||||
features: [
|
||||
"Run on your own infrastructure",
|
||||
"Own your data",
|
||||
"Custom domain",
|
||||
],
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
const Pricing = () => {
|
||||
const frequencies = [
|
||||
{
|
||||
value: "monthly" as Frequency,
|
||||
label: t`Monthly`,
|
||||
priceSuffix: t`per user/month`,
|
||||
},
|
||||
{
|
||||
value: "annually" as Frequency,
|
||||
label: t`Yearly`,
|
||||
priceSuffix: t`per user/month`,
|
||||
},
|
||||
];
|
||||
|
||||
const [frequency, setFrequency] = useState(frequencies[1]);
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: t`Individuals`,
|
||||
id: "tier-individuals",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: t`Free`, annually: t`Free` },
|
||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
||||
featureHeader: t`Free, forever`,
|
||||
features: [
|
||||
t`1 user`,
|
||||
t`Unlimited boards`,
|
||||
t`Unlimited lists`,
|
||||
t`Unlimited cards`,
|
||||
t`Unlimited comments`,
|
||||
t`Unlimited activity log`,
|
||||
],
|
||||
showPrice: true,
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description: t`Kanban is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
||||
featureHeader: t`Everything in the free plan, plus:`,
|
||||
features: [
|
||||
t`Workspace members`,
|
||||
t`Admin roles`,
|
||||
t`Priority email support`,
|
||||
t`Support the development of the project`,
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
},
|
||||
{
|
||||
name: t`Self Host`,
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: t`View docs`,
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
||||
featureHeader: t`Complete control and ownership:`,
|
||||
features: [
|
||||
t`Run on your own infrastructure`,
|
||||
t`Own your data`,
|
||||
t`Custom domain`,
|
||||
],
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
const initialFrequency = frequencies[1]!;
|
||||
const [frequency, setFrequency] = useState(initialFrequency);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Pricing`}</p>
|
||||
<p>Pricing</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Simple pricing`}
|
||||
Simple pricing
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
Get started for free, with no usage limits. For collaboration, upgrade
|
||||
to a plan that fits the size of your team.
|
||||
</p>
|
||||
|
||||
{/* Beta free pricing pill */}
|
||||
<div className="mt-4 flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800 dark:bg-green-700 dark:text-green-100">
|
||||
{t`All seats are free during the beta until Sept 1st!`}
|
||||
All seats are free during the beta until Sept 1st!
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex justify-center">
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<fieldset aria-label="Payment frequency">
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
onChange={(value) => setFrequency(value)}
|
||||
@@ -141,7 +145,7 @@ const Pricing = () => {
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
{tier.highlighted && frequency?.value === "annually" ? (
|
||||
{tier.highlighted && frequency.value === "annually" ? (
|
||||
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
|
||||
-20%
|
||||
</p>
|
||||
@@ -165,11 +169,11 @@ const Pricing = () => {
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value || "monthly"]}
|
||||
{tier.price[frequency.value]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
{frequency?.priceSuffix}
|
||||
{frequency.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -34,13 +32,13 @@ export default function HomeView() {
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
{t`Star on Github`}
|
||||
Star on Github
|
||||
<IoLogoGithub size={20} />
|
||||
</Link>
|
||||
</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"
|
||||
@@ -48,7 +46,7 @@ export default function HomeView() {
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
{t`#1 Hacker News`}
|
||||
#1 Hacker News
|
||||
<div className="relative">
|
||||
<div className="absolute inset-1 bg-white" />
|
||||
<IoLogoHackernews
|
||||
@@ -58,31 +56,31 @@ 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">
|
||||
<Trans>
|
||||
The open source <br /> alternative to Trello
|
||||
</Trans>
|
||||
The open source <br />
|
||||
alternative to Trello
|
||||
</p>
|
||||
|
||||
<p className="text-md mt-3 max-w-[450px] text-center text-dark-900 lg:max-w-[600px] lg:text-lg">
|
||||
{t`A powerful, flexible kanban app that helps you organise work, track progress, and deliver results—all in one place.`}
|
||||
A powerful, flexible kanban app that helps you organise work,
|
||||
track progress, and deliver results—all in one place.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button href="/signup">{t`Get started on Cloud`}</Button>
|
||||
<Button href="/signup">Get started on Cloud</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="https://github.com/kanbn/kan"
|
||||
openInNewTab
|
||||
>
|
||||
{t`Self host with Github`}
|
||||
Self host with Github
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-sm text-dark-900">
|
||||
{t`No credit card required`}
|
||||
No credit card required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -24,8 +21,8 @@ export function DeleteMemberConfirmation() {
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to remove member`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Unable to remove member",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
closeModal();
|
||||
@@ -44,18 +41,18 @@ export function DeleteMemberConfirmation() {
|
||||
<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">
|
||||
<Trans>Are you sure want to remove {entityLabel}?</Trans>
|
||||
{`Are you sure want to remove ${entityLabel}?`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`They won't be able to access this workspace.`}
|
||||
{"They won't be able to access this workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
{t`Cancel`}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeleteMember} isLoading={deleteMember.isPending}>
|
||||
{t`Remove`}
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
@@ -47,7 +46,7 @@ export function InviteMemberForm() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`Add member`}</h2>
|
||||
<h2 className="text-sm font-bold">Add member</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
@@ -61,7 +60,7 @@ export function InviteMemberForm() {
|
||||
</div>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
placeholder="Email"
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -79,7 +78,7 @@ export function InviteMemberForm() {
|
||||
isLoading={createBoard.isPending}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Invite member`}
|
||||
Invite member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
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";
|
||||
@@ -29,7 +25,6 @@ export default function MembersPage() {
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
memberImage,
|
||||
@@ -39,7 +34,6 @@ export default function MembersPage() {
|
||||
showSkeleton,
|
||||
}: {
|
||||
memberPublicId?: string;
|
||||
memberId?: string | null | undefined;
|
||||
memberName?: string | null | undefined;
|
||||
memberEmail?: string | null | undefined;
|
||||
memberImage?: string | null | undefined;
|
||||
@@ -48,19 +42,13 @@ export default function MembersPage() {
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
}) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-full sm:w-[65%]",
|
||||
isLastRow ? "rounded-bl-lg" : "",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center p-2 sm:p-4">
|
||||
<td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}>
|
||||
<div className="flex items-center p-4">
|
||||
<div className="flex-shrink-0">
|
||||
{showSkeleton ? (
|
||||
<div className="h-8 w-8 animate-pulse rounded-full bg-light-200 dark:bg-dark-200 sm:h-9 sm:w-9" />
|
||||
<div className="h-9 w-9 animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||
) : (
|
||||
<Avatar
|
||||
name={memberName ?? ""}
|
||||
@@ -74,7 +62,7 @@ export default function MembersPage() {
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={twMerge(
|
||||
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||
"mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -84,7 +72,7 @@ export default function MembersPage() {
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
"truncate text-sm text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -97,15 +85,15 @@ export default function MembersPage() {
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-auto min-w-[120px] overflow-visible sm:w-[35%] sm:min-w-[150px]",
|
||||
"w-[35%] min-w-[150px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<div className="flex w-full items-center justify-between px-3">
|
||||
<div>
|
||||
<span
|
||||
className={twMerge(
|
||||
"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]",
|
||||
"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",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -114,37 +102,35 @@ export default function MembersPage() {
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
</span>
|
||||
{memberStatus === "invited" && (
|
||||
<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 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">
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative z-50",
|
||||
"relative",
|
||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Remove member",
|
||||
action: () =>
|
||||
openModal(
|
||||
"REMOVE_MEMBER",
|
||||
memberPublicId,
|
||||
memberEmail ?? "",
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={25}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -154,11 +140,11 @@ export default function MembersPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<PageHead title={`Members | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto max-w-[1600px] px-28 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`}
|
||||
Members
|
||||
</h1>
|
||||
<div className="flex">
|
||||
<Button
|
||||
@@ -166,39 +152,38 @@ export default function MembersPage() {
|
||||
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
|
||||
disabled={workspace.role !== "admin"}
|
||||
>
|
||||
{t`Invite`}
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flow-root">
|
||||
<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="-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="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-light-600 overflow-visible dark:divide-dark-600">
|
||||
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{t`User`}
|
||||
User
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
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%]"
|
||||
className="w-[35%] rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Role`}
|
||||
Role
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
<tbody className="divide-y divide-light-600 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}
|
||||
@@ -222,8 +207,7 @@ export default function MembersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
<Modal>
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
|
||||
{modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import Editor from "~/components/Editor";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import ActivityList from "~/views/card/components/ActivityList";
|
||||
@@ -101,7 +99,7 @@ export function CardModal({
|
||||
)}
|
||||
<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`}
|
||||
Activity
|
||||
</h2>
|
||||
<div>
|
||||
{cardPublicId && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiLink } from "react-icons/hi2";
|
||||
@@ -48,9 +47,9 @@ export default function PublicBoardView() {
|
||||
}
|
||||
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
header: "Link copied",
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
message: "Board URL copied to clipboard",
|
||||
});
|
||||
}}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
@@ -83,7 +82,7 @@ export default function PublicBoardView() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${data?.name ?? "Board"} | ${data?.workspace.name ?? "Workspace"}`}
|
||||
title={`${data?.name ?? "Board"} | ${data?.workspace?.name ?? "Workspace"}`}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
html {
|
||||
@@ -105,15 +104,13 @@ export default function PublicBoardView() {
|
||||
{data?.name}
|
||||
</h1>
|
||||
)}
|
||||
{data && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filters
|
||||
labels={data.labels ?? []}
|
||||
members={[]}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<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">
|
||||
@@ -140,7 +137,7 @@ export default function PublicBoardView() {
|
||||
{list.cards.map((card) => (
|
||||
<Link
|
||||
key={card.publicId}
|
||||
href={`/${data.workspace.slug}/${data.slug}/${card.publicId}`}
|
||||
href={`/${data.workspace?.slug}/${data.slug}/${card.publicId}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col`}
|
||||
shallow={true}
|
||||
onClick={() => {
|
||||
@@ -180,7 +177,7 @@ export default function PublicBoardView() {
|
||||
<Modal modalSize={"md"} positionFromTop={"sm"}>
|
||||
<CardModal
|
||||
cardPublicId={cardPublicId}
|
||||
workspaceSlug={data?.workspace.slug}
|
||||
workspaceSlug={data?.workspace?.slug}
|
||||
boardSlug={data?.slug}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import Image from "next/image";
|
||||
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";
|
||||
@@ -23,8 +20,8 @@ export default function Avatar({
|
||||
const updateUser = api.user.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Profile image updated`,
|
||||
message: t`Your profile image has been updated.`,
|
||||
header: "Profile image updated",
|
||||
message: "Your profile image has been updated.",
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
@@ -36,8 +33,8 @@ export default function Avatar({
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error updating profile image`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Error updating profile image",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -53,14 +50,14 @@ export default function Avatar({
|
||||
|
||||
if (!file || !userId) {
|
||||
return showPopup({
|
||||
header: t`Error uploading profile image`,
|
||||
message: t`Please select a file to upload.`,
|
||||
header: "Error uploading profile image",
|
||||
message: "Please select a file to upload.",
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
|
||||
const fileExt = file.name.split(".").pop();
|
||||
const fileName = `${userId}/avatar-${generateUID()}.${fileExt}`;
|
||||
const fileName = `${userId}/avatar.${fileExt}`;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
@@ -94,8 +91,8 @@ export default function Avatar({
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Error uploading profile image`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
header: "Error uploading profile image",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
|
||||