Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33677f720c | ||
|
|
0ec71b589d | ||
|
|
0cb69e2087 | ||
|
|
1305c55100 | ||
|
|
425c91865b | ||
|
|
a0096a9eb0 | ||
|
|
4058169079 | ||
|
|
ecc42f988f | ||
|
|
1ded560b75 | ||
|
|
f8dc062903 | ||
|
|
2169a379f2 | ||
|
|
03816b192f | ||
|
|
f4ef1217dc | ||
|
|
1c920e3983 | ||
|
|
9daf657db2 | ||
|
|
fdfd1f3d03 | ||
|
|
392be38f10 | ||
|
|
ec1b5d5acb | ||
|
|
316f148663 | ||
|
|
67050be27f | ||
|
|
ff4448ebf4 |
@@ -21,6 +21,7 @@ S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
|
||||
4
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"cwd": "${workspaceFolder}/apps/nextjs",
|
||||
"cwd": "${workspaceFolder}/apps/web",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"sourceMaps": true,
|
||||
"sourceMapPathOverrides": {
|
||||
@@ -14,4 +14,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
121
CHANGELOG.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.4...HEAD)
|
||||
|
||||
## [0.2.4](https://github.com/kanbn/kan/compare/v0.2.3...v0.2.4) - 2025-01-14
|
||||
|
||||
### Added
|
||||
|
||||
- Button to view and update board public URL directly from board page
|
||||
- Redirect from root to login page for self-hosted instances
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated all label router endpoints to return consistent structure
|
||||
- Added verbose error logging to magic link invitation process
|
||||
|
||||
### Fixed
|
||||
|
||||
- Updated tRPC dependencies and added OpenAPI meta to providers endpoint
|
||||
|
||||
## [0.2.3](https://github.com/kanbn/kan/compare/v0.2.2...v0.2.3) - 2025-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- Mobile support with responsive navigation and layout
|
||||
- Template selection when creating new boards with predefined lists and labels
|
||||
- S3_FORCE_PATH_STYLE configuration for MinIO compatibility
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved mobile UI styling and layout containers
|
||||
- Enhanced iOS mobile support with viewport fixes and zoom prevention
|
||||
- Improved card view layout for mobile devices
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile dashboard min-width issues
|
||||
- Dynamic viewport height on mobile devices
|
||||
- Scroll functionality on card pages
|
||||
|
||||
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28
|
||||
|
||||
### Added
|
||||
|
||||
- Localization and multi-language support
|
||||
- URLs are now rendered as clickable links in card descriptions
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved email configuration with optional SMTP authentication
|
||||
|
||||
## [0.2.1](https://github.com/kanbn/kan/compare/v0.2.0...v0.2.1) - 2025-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- PostHog analytics integration
|
||||
- Debug logging for email functionality
|
||||
- Standalone mode support for Next.js
|
||||
- Environment variable for configuring web port
|
||||
- Docker Compose setup with PostgreSQL
|
||||
|
||||
### Changed
|
||||
|
||||
- SMTP authentication is now optional
|
||||
- Added `SMTP_SECURE` configuration option
|
||||
- Improved Docker build process with better context handling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Default output mode for Next.js builds
|
||||
- Container environment variable configuration
|
||||
- Docker Compose volume naming
|
||||
- Build context and static file copying in Docker
|
||||
- Comment rendering inside contenteditable elements
|
||||
|
||||
## [0.2.0](https://github.com/kanbn/kan/compare/v0.1.0...v0.2.0) - 2025-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Trello integration with OAuth authentication and user field mapping
|
||||
- Board visibility controls with dropdown menu and success notifications
|
||||
- Markdown editor for card descriptions
|
||||
- Email/password authentication for self-hosters
|
||||
- Account deletion functionality with cascade delete for workspaces
|
||||
- Enhanced scrollbar styles for dark mode
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified auth configuration to use `NEXT_PUBLIC_BASE_URL` instead of `BETTER_AUTH_URL`
|
||||
- Updated self-hosting documentation
|
||||
- Improved database schema with relation names
|
||||
|
||||
### Fixed
|
||||
|
||||
- List buttons stacking issue
|
||||
- Editor onChange prop is now optional
|
||||
- Renamed markdown prop to content in Editor component
|
||||
- Router navigation for disabled signup redirects
|
||||
|
||||
## [0.1.0](https://github.com/kanbn/kan/releases/tag/v0.1.0) - 2025-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of Kan
|
||||
- Core project management features
|
||||
- Board, list, and card management
|
||||
- User authentication system
|
||||
- Workspace collaboration
|
||||
- Comment system
|
||||
- Activity logging
|
||||
- Docker deployment support
|
||||
- Self-hosting capabilities
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated license link in footer
|
||||
@@ -162,6 +162,7 @@ pnpm dev
|
||||
| `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` |
|
||||
|
||||
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 433 KiB After Width: | Height: | Size: 133 KiB |
@@ -6,7 +6,6 @@ checksums:
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
"%231%20Hacker%20News/singular": de271b412c6c022d5c427d29fc70e0ba
|
||||
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
|
||||
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
@@ -27,15 +26,23 @@ checksums:
|
||||
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
|
||||
@@ -45,6 +52,9 @@ checksums:
|
||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
@@ -52,10 +62,12 @@ checksums:
|
||||
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
|
||||
@@ -63,6 +75,7 @@ checksums:
|
||||
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
|
||||
@@ -77,8 +90,10 @@ checksums:
|
||||
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
|
||||
@@ -87,6 +102,7 @@ checksums:
|
||||
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
|
||||
@@ -98,11 +114,15 @@ checksums:
|
||||
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
|
||||
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
||||
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
||||
@@ -117,8 +137,11 @@ checksums:
|
||||
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
|
||||
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
|
||||
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
|
||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
||||
Features/singular: 341ff316a339b106a178f0b8d362951b
|
||||
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
||||
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
|
||||
@@ -127,6 +150,8 @@ checksums:
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
|
||||
Free/singular: 0326365539c004f6088656f692602078
|
||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
|
||||
@@ -137,6 +162,8 @@ checksums:
|
||||
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
|
||||
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
How%20do%20I%20get%20a%20custom%20URL%3F/singular: 5b28fb95969c0dd19e1b9c9ab8a5fb29
|
||||
How%20do%20I%20import%20my%20Trello%20boards%3F/singular: 44b30a727150cb93364af0a10346012a
|
||||
@@ -145,23 +172,29 @@ checksums:
|
||||
How%20is%20the%20project%20funded%3F/singular: ba7e936f7b1b5faa47df59a0ef990092
|
||||
I%20acknowledge%20that%20all%20of%20my%20account%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: 2ffc824d0b1f9e883c2e87ed4b8b40b6
|
||||
I%20acknowledge%20that%20all%20of%20the%20workspace%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: fe1183a7439299210d8ab7379b8f4d01
|
||||
Ideas/singular: 27c24b56895defecd10b0171b39bc93f
|
||||
Ideas%20to%20improve%20this%20page.../singular: 285decc2cc28403fae043d9b4de97a00
|
||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
||||
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
|
||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||
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
|
||||
@@ -170,7 +203,10 @@ checksums:
|
||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||
@@ -184,19 +220,27 @@ checksums:
|
||||
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
|
||||
@@ -209,25 +253,35 @@ checksums:
|
||||
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
|
||||
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
|
||||
Public/singular: c834f11dab0881632aded3f01d4f8fa9
|
||||
Publishing/singular: d9c8862c0180d9b7bd1272d5dc0d3ac3
|
||||
Question/singular: 0576462ce60d4263d7c482463fcc9547
|
||||
Questions%3F/singular: c7c3a47f8b230ce7a43f3c090f21d683
|
||||
Quick%20Win/singular: a03ff4c0896f14b87455510bab203511
|
||||
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
|
||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||
removed%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1e7ebc1866136352bdc3cd90afc083f0
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Revoke/singular: be57685a85b6dfeaeb6eab4e9560b520
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
|
||||
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
|
||||
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
|
||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||
Send/singular: 76a8497d7b546628b03bb81d5c1ce995
|
||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
@@ -237,8 +291,11 @@ checksums:
|
||||
Sign%20up%20with%20/singular: 103e5d75506e6835b471695d799ff580
|
||||
Simple%20pricing/singular: 5aa2bece93a71947b268498a58062937
|
||||
Simple%2C%20visual%20task%20management%20that%20just%20works.%20Drag%20and%20drop%20cards%2C%20collaborate%20with%20your%20team%2C%20and%20get%20more%20done./singular: f4f138f83017d43dab2882cc08e07bdb
|
||||
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
|
||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||
Success/singular: c43827becada6750f7a25890905f38b9
|
||||
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
|
||||
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
@@ -255,10 +312,12 @@ checksums:
|
||||
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
|
||||
@@ -288,10 +347,13 @@ checksums:
|
||||
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
|
||||
@@ -317,6 +379,7 @@ checksums:
|
||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
|
||||
Yearly/singular: 87f43e016c19cb25860f456549a2f431
|
||||
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
|
||||
You%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
|
||||
|
||||
@@ -31,9 +31,12 @@ const config = {
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
hostname: process.env.S3_FORCE_PATH_STYLE === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
|
||||
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 433 KiB After Width: | Height: | Size: 133 KiB |
@@ -259,6 +259,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
|
||||
@@ -1,11 +1,96 @@
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
TbLayoutSidebarRightCollapse,
|
||||
TbLayoutSidebarRightExpand,
|
||||
} from "react-icons/tb";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
import FeedbackButton from "./FeedbackButton";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import FeedbackModal from "./FeedbackModal";
|
||||
import Modal from "./modal";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = api.user.getUser.useQuery();
|
||||
interface DashboardProps {
|
||||
children: React.ReactNode;
|
||||
rightPanel?: React.ReactNode;
|
||||
hasRightPanel?: boolean;
|
||||
}
|
||||
|
||||
export function getDashboardLayout(
|
||||
page: React.ReactElement,
|
||||
rightPanel?: React.ReactNode,
|
||||
hasRightPanel = false,
|
||||
) {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
|
||||
{page}
|
||||
</Dashboard>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
children,
|
||||
rightPanel,
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const theme = useTheme();
|
||||
const { modalContentType } = useModal();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
|
||||
const sideNavRef = useRef<HTMLDivElement>(null);
|
||||
const rightPanelRef = useRef<HTMLDivElement>(null);
|
||||
const sideNavButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const rightPanelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const toggleSideNav = () => {
|
||||
setIsSideNavOpen(!isSideNavOpen);
|
||||
if (!isSideNavOpen) {
|
||||
setIsRightPanelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeSideNav = () => {
|
||||
setIsSideNavOpen(false);
|
||||
};
|
||||
|
||||
const toggleRightPanel = () => {
|
||||
setIsRightPanelOpen(!isRightPanelOpen);
|
||||
if (!isRightPanelOpen) {
|
||||
setIsSideNavOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useClickOutside(sideNavRef, (event) => {
|
||||
if (sideNavButtonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (isSideNavOpen) {
|
||||
setIsSideNavOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
useClickOutside(rightPanelRef, (event) => {
|
||||
if (rightPanelButtonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (isRightPanelOpen) {
|
||||
setIsRightPanelOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -13,26 +98,86 @@ export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
html {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 320px;
|
||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
||||
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
|
||||
<div className="my-auto flex w-full items-center justify-between">
|
||||
<Link href="/">
|
||||
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<FeedbackButton />
|
||||
</div>
|
||||
<div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
|
||||
{/* Mobile Header */}
|
||||
<div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
|
||||
<button
|
||||
ref={sideNavButtonRef}
|
||||
onClick={toggleSideNav}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{isSideNavOpen ? (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasRightPanel && (
|
||||
<button
|
||||
ref={rightPanelButtonRef}
|
||||
onClick={toggleRightPanel}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{isRightPanelOpen ? (
|
||||
<TbLayoutSidebarRightCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarRightExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full">
|
||||
<SideNavigation
|
||||
user={{ email: data?.email, image: data?.image }}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="w-full overflow-hidden">{props.children}</div>
|
||||
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
|
||||
<div
|
||||
ref={sideNavRef}
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
|
||||
<div className="relative flex h-full min-h-0 w-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-y-auto">{children}</div>
|
||||
|
||||
{/* Mobile Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
|
||||
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full">{rightPanel}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div className="hidden md:block">{rightPanel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -350,7 +350,6 @@ 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;
|
||||
@@ -363,7 +362,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-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import chatIconDark from "~/assets/chat-dark.json";
|
||||
import chatIconLight from "~/assets/chat-light.json";
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface NewFeedbackFormInput {
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
const FeedbackButton: React.FC = () => {
|
||||
const outsideRef = useRef<HTMLDivElement>(null);
|
||||
const { activeTheme } = useTheme();
|
||||
const { showPopup } = usePopup();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const { handleSubmit, setValue, watch, reset } =
|
||||
useForm<NewFeedbackFormInput>({
|
||||
defaultValues: {
|
||||
feedback: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createFeedback = api.feedback.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
reset();
|
||||
showPopup({
|
||||
header: t`Feedback sent`,
|
||||
message: t`Thank you for your feedback!`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: async () => {
|
||||
showPopup({
|
||||
header: t`Unable to send feedback`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
setIndex((index) => index + 1);
|
||||
};
|
||||
|
||||
const onSubmit = (values: NewFeedbackFormInput) => {
|
||||
createFeedback.mutate({
|
||||
feedback: values.feedback,
|
||||
url: window.location.href,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={outsideRef} />
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
|
||||
>
|
||||
<LottieIcon
|
||||
index={index}
|
||||
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
|
||||
isPlaying={isHovered}
|
||||
/>
|
||||
<span className="ml-1">{t`Feedback`}</span>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
as="div"
|
||||
static
|
||||
className="absolute right-0 z-30 mt-2 w-[350px] origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
|
||||
<Input
|
||||
placeholder={t`Ideas to improve this page...`}
|
||||
onChange={(e) => {
|
||||
setValue("feedback", e.target.value);
|
||||
}}
|
||||
value={watch("feedback")}
|
||||
contentEditable
|
||||
className="max-h-[300px] min-h-[100px]"
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between pt-2">
|
||||
<div>
|
||||
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Need help?`}{" "}
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`Contact us`}
|
||||
</Link>
|
||||
{t`, or see our`}{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`docs`}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="justify-end">
|
||||
<Button size="sm" isLoading={createFeedback.isPending}>
|
||||
{t`Send`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackButton;
|
||||
122
apps/web/src/components/FeedbackModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface NewFeedbackFormInput {
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
export default function FeedbackModal() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { handleSubmit, setValue, watch, reset } =
|
||||
useForm<NewFeedbackFormInput>({
|
||||
defaultValues: {
|
||||
feedback: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createFeedback = api.feedback.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
reset();
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Feedback sent`,
|
||||
message: t`Thank you for your feedback!`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: async () => {
|
||||
showPopup({
|
||||
header: t`Unable to send feedback`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: NewFeedbackFormInput) => {
|
||||
createFeedback.mutate({
|
||||
feedback: values.feedback,
|
||||
url: window.location.href,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Feedback`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="feedback"
|
||||
placeholder={t`Ideas to improve this page...`}
|
||||
onChange={(e) => setValue("feedback", e.target.value)}
|
||||
contentEditable
|
||||
value={watch("feedback")}
|
||||
className="min-h-[120px]"
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
closeModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="text-xs text-neutral-600 dark:text-dark-800">
|
||||
<p>
|
||||
{t`Need help?`}{" "}
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`Contact us`}
|
||||
</Link>
|
||||
{t`, or see our`}{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
{t`docs`}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" isLoading={createFeedback.isPending}>
|
||||
{t`Send feedback`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="relative flex">
|
||||
{prefix && (
|
||||
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
|
||||
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-sm dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,11 +2,11 @@ import dynamic from "next/dynamic";
|
||||
|
||||
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
|
||||
|
||||
type IconProps = {
|
||||
interface IconProps {
|
||||
isPlaying: boolean;
|
||||
index: number;
|
||||
json: object;
|
||||
};
|
||||
}
|
||||
|
||||
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||
return (
|
||||
@@ -15,7 +15,7 @@ const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||
animationData={json}
|
||||
play={isPlaying}
|
||||
loop={false}
|
||||
style={{ width: 20, height: 20, fill: "white" }}
|
||||
style={{ width: 18, height: 18, fill: "white" }}
|
||||
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,10 +19,12 @@ 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,
|
||||
|
||||
@@ -4,6 +4,10 @@ export const PageHead = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ const PatternedBackground = () => (
|
||||
<svg className="h-full w-full">
|
||||
<pattern
|
||||
id="pattern"
|
||||
x="0.034759358288862785"
|
||||
y="3.335370511841166"
|
||||
x="10"
|
||||
y="10"
|
||||
width="14.423223834988539"
|
||||
height="14.423223834988539"
|
||||
patternUnits="userSpaceOnUse"
|
||||
|
||||
@@ -1,37 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
|
||||
const Button: React.FC<{
|
||||
href: string;
|
||||
current: boolean;
|
||||
name: string;
|
||||
json: object;
|
||||
}> = ({ href, current, name, json }) => {
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
setIndex((index) => index + 1);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className={classNames(
|
||||
current ? "bg-light-200 dark:bg-dark-200" : "dark:bg-dark-50",
|
||||
"group flex items-center gap-x-3 rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
onClick={handleClick}
|
||||
className={twMerge(
|
||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||
current
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||
isCollapsed
|
||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
||||
: "gap-x-3",
|
||||
)}
|
||||
title={isCollapsed ? name : undefined}
|
||||
>
|
||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||
{name}
|
||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import boardsIconDark from "~/assets/boards-dark.json";
|
||||
import boardsIconLight from "~/assets/boards-light.json";
|
||||
@@ -15,6 +23,7 @@ import { useTheme } from "~/providers/theme";
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
isLoading: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
@@ -25,8 +34,28 @@ 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;
|
||||
|
||||
@@ -52,11 +81,50 @@ export default function SideNavigation({
|
||||
},
|
||||
];
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
|
||||
<nav
|
||||
className={twMerge(
|
||||
"flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:border-r-0 md:py-0 md:pl-0",
|
||||
isCollapsed && "md:w-auto",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<WorkspaceMenu />
|
||||
<div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
|
||||
{!isCollapsed && (
|
||||
<Link href="/" className="block">
|
||||
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
onClick={toggleCollapse}
|
||||
className={twMerge(
|
||||
"flex h-8 items-center justify-center rounded-md hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed ? "w-full" : "w-8",
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
|
||||
|
||||
<WorkspaceMenu isCollapsed={isCollapsed} />
|
||||
<ul role="list" className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
@@ -65,16 +133,23 @@ export default function SideNavigation({
|
||||
current={pathname.includes(item.href)}
|
||||
name={item.name}
|
||||
json={item.icon}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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";
|
||||
|
||||
@@ -13,38 +17,63 @@ interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
}: UserMenuProps) {
|
||||
const router = useRouter();
|
||||
const { themePreference, switchTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
await authClient.signOut();
|
||||
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOpen = (modalType: string) => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
openModal(modalType);
|
||||
};
|
||||
|
||||
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block w-full text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex">
|
||||
<div className={twMerge(!isCollapsed && "flex")}>
|
||||
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000">
|
||||
<Menu.Button
|
||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||
title={isCollapsed ? email : undefined}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
@@ -64,7 +93,14 @@ export default function UserMenu({
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<span className="mx-2 truncate text-sm">{email}</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"mx-2 truncate text-sm",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -78,7 +114,12 @@ export default function UserMenu({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute bottom-[40px] left-0 z-10 mt-2 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "left-0 w-48" : "left-0 w-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
||||
<div className="p-1">
|
||||
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
||||
@@ -90,7 +131,7 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
className={twMerge(
|
||||
themePreference === "system" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
@@ -104,7 +145,7 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
className={twMerge(
|
||||
themePreference === "dark" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
@@ -118,7 +159,7 @@ export default function UserMenu({
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
className={twMerge(
|
||||
themePreference === "light" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
@@ -127,6 +168,38 @@ export default function UserMenu({
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Support`}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Documentation`}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => handleModalOpen("NEW_FEEDBACK")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Feedback`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<button
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment } from "react";
|
||||
import { HiCheck } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
|
||||
export default function WorkspaceMenu() {
|
||||
export default function WorkspaceMenu({
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
@@ -15,22 +20,43 @@ export default function WorkspaceMenu() {
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="mb-1 flex p-1.5">
|
||||
<div className="h-6 w-6 animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||
<div className={twMerge("mb-1", isCollapsed && "md:flex md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "hidden md:block",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700">
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
@@ -47,7 +73,12 @@ export default function WorkspaceMenu() {
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute left-0 z-10 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
|
||||
@@ -69,6 +69,7 @@ export const env = createEnv({
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
},
|
||||
|
||||
|
||||
59
apps/web/src/hooks/useClickOutside.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import { useEventListener } from "./useEventListener";
|
||||
|
||||
/** Supported event types. */
|
||||
type EventType =
|
||||
| "mousedown"
|
||||
| "mouseup"
|
||||
| "touchstart"
|
||||
| "touchend"
|
||||
| "focusin"
|
||||
| "focusout";
|
||||
|
||||
/**
|
||||
* Custom hook that handles clicks outside a specified element.
|
||||
* @template T - The type of the element's reference.
|
||||
* @param {RefObject<T> | RefObject<T>[]} ref - The React ref object(s) representing the element(s) to watch for outside clicks.
|
||||
* @param {(event: MouseEvent | TouchEvent | FocusEvent) => void} handler - The callback function to be executed when a click outside the element occurs.
|
||||
* @param {EventType} [eventType] - The mouse event type to listen for (optional, default is 'mousedown').
|
||||
* @param {?AddEventListenerOptions} [eventListenerOptions] - The options object to be passed to the `addEventListener` method (optional).
|
||||
* @returns {void}
|
||||
* @example
|
||||
* ```tsx
|
||||
* const containerRef = useRef(null);
|
||||
* useClickOutside([containerRef], () => {
|
||||
* // Handle clicks outside the container.
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T> | RefObject<T>[],
|
||||
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
|
||||
eventType: EventType = "mousedown",
|
||||
eventListenerOptions: AddEventListenerOptions = {},
|
||||
): void {
|
||||
useEventListener(
|
||||
eventType,
|
||||
(event) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
// Do nothing if the target is not connected element with document
|
||||
if (!target.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOutside = Array.isArray(ref)
|
||||
? ref
|
||||
.filter((r) => Boolean(r.current))
|
||||
.every((r) => r.current && !r.current.contains(target))
|
||||
: ref.current && !ref.current.contains(target);
|
||||
|
||||
if (isOutside) {
|
||||
handler(event);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
eventListenerOptions,
|
||||
);
|
||||
}
|
||||
120
apps/web/src/hooks/useEventListener.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
|
||||
|
||||
// MediaQueryList Event based useEventListener interface
|
||||
function useEventListener<K extends keyof MediaQueryListEventMap>(
|
||||
eventName: K,
|
||||
handler: (event: MediaQueryListEventMap[K]) => void,
|
||||
element: RefObject<MediaQueryList>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
// Window Event based useEventListener interface
|
||||
function useEventListener<K extends keyof WindowEventMap>(
|
||||
eventName: K,
|
||||
handler: (event: WindowEventMap[K]) => void,
|
||||
element?: undefined,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
// Element Event based useEventListener interface
|
||||
function useEventListener<
|
||||
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
|
||||
T extends Element = K extends keyof HTMLElementEventMap
|
||||
? HTMLDivElement
|
||||
: SVGElement,
|
||||
>(
|
||||
eventName: K,
|
||||
handler:
|
||||
| ((event: HTMLElementEventMap[K]) => void)
|
||||
| ((event: SVGElementEventMap[K]) => void),
|
||||
element: RefObject<T>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
// Document Event based useEventListener interface
|
||||
function useEventListener<K extends keyof DocumentEventMap>(
|
||||
eventName: K,
|
||||
handler: (event: DocumentEventMap[K]) => void,
|
||||
element: RefObject<Document>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
|
||||
* @template KW - The type of event for window events.
|
||||
* @template KH - The type of event for HTML or SVG element events.
|
||||
* @template KM - The type of event for media query list events.
|
||||
* @template T - The type of the DOM element (default is `HTMLElement`).
|
||||
* @param {KW | KH | KM} eventName - The name of the event to listen for.
|
||||
* @param {(event: WindowEventMap[KW] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | MediaQueryListEventMap[KM] | Event) => void} handler - The event handler function.
|
||||
* @param {RefObject<T>} [element] - The DOM element or media query list to attach the event listener to (optional).
|
||||
* @param {boolean | AddEventListenerOptions} [options] - An options object that specifies characteristics about the event listener (optional).
|
||||
* @public
|
||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-event-listener)
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Example 1: Attach a window event listener
|
||||
* useEventListener('resize', handleResize);
|
||||
* ```
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Example 2: Attach a document event listener with options
|
||||
* const elementRef = useRef(document);
|
||||
* useEventListener('click', handleClick, elementRef, { capture: true });
|
||||
* ```
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Example 3: Attach an element event listener
|
||||
* const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
* useEventListener('click', handleButtonClick, buttonRef);
|
||||
* ```
|
||||
*/
|
||||
function useEventListener<
|
||||
KW extends keyof WindowEventMap,
|
||||
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
|
||||
KM extends keyof MediaQueryListEventMap,
|
||||
T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
|
||||
>(
|
||||
eventName: KW | KH | KM,
|
||||
handler: (
|
||||
event:
|
||||
| WindowEventMap[KW]
|
||||
| HTMLElementEventMap[KH]
|
||||
| SVGElementEventMap[KH]
|
||||
| MediaQueryListEventMap[KM]
|
||||
| Event,
|
||||
) => void,
|
||||
element?: RefObject<T>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) {
|
||||
// Create a ref that stores handler
|
||||
const savedHandler = useRef(handler);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
savedHandler.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
// Define the listening target
|
||||
const targetElement: T | Window = element?.current ?? window;
|
||||
|
||||
if (!(targetElement && targetElement.addEventListener)) return;
|
||||
|
||||
// Create event listener that calls handler function stored in ref
|
||||
const listener: typeof handler = (event) => {
|
||||
savedHandler.current(event);
|
||||
};
|
||||
|
||||
targetElement.addEventListener(eventName, listener, options);
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
targetElement.removeEventListener(eventName, listener, options);
|
||||
};
|
||||
}, [eventName, element, options]);
|
||||
}
|
||||
|
||||
export { useEventListener };
|
||||
15
apps/web/src/hooks/useIsomorphicLayoutEffect.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook that uses either `useLayoutEffect` or `useEffect` based on the environment (client-side or server-side).
|
||||
* @param {Function} effect - The effect function to be executed.
|
||||
* @param {Array<any>} [dependencies] - An array of dependencies for the effect (optional).
|
||||
* @example
|
||||
* ```tsx
|
||||
* useIsomorphicLayoutEffect(() => {
|
||||
* // Code to be executed during the layout phase on the client side
|
||||
* }, [dependency1, dependency2]);
|
||||
* ```
|
||||
*/
|
||||
export const useIsomorphicLayoutEffect =
|
||||
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
23
apps/web/src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
setMatches(media.matches);
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery("(max-width: 767px)");
|
||||
}
|
||||
18
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
if (request.nextUrl.pathname === "/") {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/"],
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import "~/styles/globals.css";
|
||||
import "~/utils/i18n";
|
||||
|
||||
import type { AppType } from "next/app";
|
||||
import type { NextPage, Viewport } from "next";
|
||||
import type { AppProps, AppType } from "next/app";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { env } from "next-runtime-env";
|
||||
@@ -26,7 +28,22 @@ export const metadata = {
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
|
||||
getLayout?: (page: ReactElement) => ReactNode;
|
||||
};
|
||||
|
||||
type AppPropsWithLayout = AppProps & {
|
||||
Component: NextPageWithLayout;
|
||||
};
|
||||
|
||||
const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,6 +59,8 @@ const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
}
|
||||
}, [posthogKey]);
|
||||
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
@@ -67,10 +86,10 @@ const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
<Component {...pageProps} />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
<Component {...pageProps} />
|
||||
getLayout(<Component {...pageProps} />)
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
|
||||
@@ -46,6 +46,7 @@ export default async function handler(
|
||||
const client = new S3Client({
|
||||
region: env.S3_REGION ?? "",
|
||||
endpoint: env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
@@ -58,6 +59,7 @@ export default async function handler(
|
||||
new PutObjectCommand({
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { openApiDocument } from "@kan/api/openapi";
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import BoardView from "~/views/board";
|
||||
|
||||
export default function BoardPage() {
|
||||
const BoardPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<BoardView />
|
||||
</Dashboard>
|
||||
<>
|
||||
<BoardView />
|
||||
<Popup />
|
||||
</WorkspaceProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
BoardPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default BoardPage;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import type { NextPageWithLayout } from "../_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import BoardsView from "~/views/boards";
|
||||
|
||||
export default function BoardsPage() {
|
||||
const BoardsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<BoardsView />
|
||||
</Dashboard>
|
||||
<>
|
||||
<BoardsView />
|
||||
<Popup />
|
||||
</WorkspaceProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
BoardsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default BoardsPage;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import CardView from "~/views/card";
|
||||
import CardView, { CardRightPanel } from "~/views/card";
|
||||
|
||||
export default function CardPage() {
|
||||
const CardPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<CardView />
|
||||
</Dashboard>
|
||||
<>
|
||||
<CardView />
|
||||
<Popup />
|
||||
</WorkspaceProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
CardPage.getLayout = (page) =>
|
||||
getDashboardLayout(page, <CardRightPanel />, true);
|
||||
|
||||
export default CardPage;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import MembersView from "~/views/members";
|
||||
|
||||
export default function MembersPage() {
|
||||
const MembersPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<MembersView />
|
||||
</Dashboard>
|
||||
<>
|
||||
<MembersView />
|
||||
<Popup />
|
||||
</WorkspaceProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
MembersPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default MembersPage;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import SettingsView from "~/views/settings";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import SettingsView from "~/views/settings";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const SettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<SettingsView />
|
||||
</Dashboard>
|
||||
<>
|
||||
<SettingsView />
|
||||
<Popup />
|
||||
</WorkspaceProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
SettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default SettingsPage;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
[contenteditable=true]:empty:before {
|
||||
[contenteditable="true"]:empty:before {
|
||||
content: attr(placeholder);
|
||||
display: block;
|
||||
color: #707070;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.gradient-border::before {
|
||||
@@ -18,7 +19,7 @@
|
||||
transparent 110deg,
|
||||
transparent 360deg
|
||||
);
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
width: 200%;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiLink } from "react-icons/hi";
|
||||
|
||||
const UpdateBoardSlugButton = ({
|
||||
handleOnClick,
|
||||
workspaceSlug,
|
||||
boardSlug,
|
||||
isLoading,
|
||||
}: {
|
||||
handleOnClick: () => void;
|
||||
workspaceSlug: string;
|
||||
boardSlug: string;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="hidden h-[36px] w-[225px] animate-pulse rounded-full bg-light-200 dark:bg-dark-100 xl:flex" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleOnClick}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn"
|
||||
: env("NEXT_PUBLIC_BASE_URL")}
|
||||
</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{workspaceSlug}</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{boardSlug}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</Link>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateBoardSlugButton;
|
||||
@@ -135,7 +135,7 @@ export function UpdateBoardSlugForm({
|
||||
? t`This board URL has already been taken`
|
||||
: undefined)
|
||||
}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||
prefix={`${env("NEXT_PUBLIC_KAN_ENV") === "cloud" ? "kan.bn" : env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -152,7 +152,14 @@ export function UpdateBoardSlugForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="/settings?edit=workspace_url"
|
||||
onClick={closeModal}
|
||||
>
|
||||
{t`Edit workspace URL`}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateBoardSlug.isPending}
|
||||
@@ -160,7 +167,8 @@ export function UpdateBoardSlugForm({
|
||||
!isDirty ||
|
||||
updateBoardSlug.isPending ||
|
||||
errors.slug?.message !== undefined ||
|
||||
isBoardSlugAvailable?.isReserved
|
||||
isBoardSlugAvailable?.isReserved ||
|
||||
checkBoardSlugAvailability.isLoading
|
||||
}
|
||||
>
|
||||
{t`Update`}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -32,6 +33,7 @@ import Filters from "./components/Filters";
|
||||
import List from "./components/List";
|
||||
import { NewCardForm } from "./components/NewCardForm";
|
||||
import { NewListForm } from "./components/NewListForm";
|
||||
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
|
||||
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||
import VisibilityButton from "./components/VisibilityButton";
|
||||
|
||||
@@ -240,7 +242,7 @@ export default function BoardPage() {
|
||||
/>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 flex w-full justify-between p-8">
|
||||
<div className="z-10 flex w-full flex-col justify-between p-6 md:flex-row md:p-8">
|
||||
{isLoading && !boardData && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
||||
@@ -249,7 +251,7 @@ export default function BoardPage() {
|
||||
{boardData && (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
className="order-2 focus-visible:outline-none md:order-1"
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
@@ -261,12 +263,18 @@ export default function BoardPage() {
|
||||
</form>
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
{t`Board not found`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
||||
<UpdateBoardSlugButton
|
||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
@@ -358,7 +366,7 @@ export default function BoardPage() {
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600"
|
||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-225px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600"
|
||||
>
|
||||
{list.cards.map((card, index) => (
|
||||
<Draggable
|
||||
@@ -414,7 +422,15 @@ export default function BoardPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||
<Modal
|
||||
modalSize={
|
||||
modalContentType === "NEW_CARD" ||
|
||||
modalContentType === "NEW_FEEDBACK"
|
||||
? "md"
|
||||
: "sm"
|
||||
}
|
||||
>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "DELETE_BOARD" && (
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function BoardsList() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="xxl:grid-cols-7 grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-7">
|
||||
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
@@ -45,12 +45,12 @@ export function BoardsList() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-7">
|
||||
<div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-7">
|
||||
{data?.map((board) => (
|
||||
<Link key={board.publicId} href={`boards/${board.publicId}`}>
|
||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<p className="text-md px-4 font-bold text-neutral-900 dark:text-dark-1000">
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
|
||||
<span className="flex items-center">
|
||||
{integrationProviders[field.value]?.icon}
|
||||
<span className="ml-2 block truncate">
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders[field.value]?.name}
|
||||
</span>
|
||||
</span>
|
||||
@@ -113,7 +113,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
integrationProviders[integration.provider]
|
||||
?.icon
|
||||
}
|
||||
<span className="ml-2 block truncate font-normal">
|
||||
<span className="ml-2 block truncate text-sm font-normal">
|
||||
{
|
||||
integrationProviders[integration.provider]
|
||||
?.name
|
||||
@@ -125,12 +125,12 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
) : (
|
||||
<Listbox.Option
|
||||
key="trello_placeholder"
|
||||
className="relative cursor-default select-none px-1"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate font-normal">
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,59 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { NewBoardInput } from "@kan/api/types";
|
||||
|
||||
import type { Template } from "./TemplateBoards";
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import TemplateBoards, { getTemplates } from "./TemplateBoards";
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Board name is required` })
|
||||
.max(100, { message: t`Board name cannot exceed 100 characters` }),
|
||||
workspacePublicId: z.string(),
|
||||
template: z.custom<Template | null>(),
|
||||
});
|
||||
|
||||
interface NewBoardInputWithTemplate {
|
||||
name: string;
|
||||
workspacePublicId: string;
|
||||
template: Template | null;
|
||||
}
|
||||
|
||||
export function NewBoardForm() {
|
||||
const utils = api.useUtils();
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
const { register, handleSubmit } = useForm<NewBoardInput>({
|
||||
const templates = getTemplates();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<NewBoardInputWithTemplate>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
workspacePublicId: workspace.publicId || "",
|
||||
template: null,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTemplate = watch("template");
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const createBoard = api.board.create.useMutation({
|
||||
@@ -32,8 +63,13 @@ export function NewBoardForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewBoardInput) => {
|
||||
createBoard.mutate(data);
|
||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||
createBoard.mutate({
|
||||
name: data.name,
|
||||
workspacePublicId: data.workspacePublicId,
|
||||
lists: data.template?.lists ?? [],
|
||||
labels: data.template?.labels ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +98,7 @@ export function NewBoardForm() {
|
||||
id="name"
|
||||
placeholder={t`Name`}
|
||||
{...register("name", { required: true })}
|
||||
errorMessage={errors.name?.message}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -70,8 +107,22 @@ export function NewBoardForm() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TemplateBoards
|
||||
currentBoard={currentTemplate}
|
||||
setCurrentBoard={(t) => setValue("template", t)}
|
||||
showTemplates={showTemplates}
|
||||
/>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
isChecked={showTemplates}
|
||||
onChange={() => {
|
||||
setShowTemplates(!showTemplates);
|
||||
if (!showTemplates && !currentTemplate) {
|
||||
setValue("template", templates[0] ?? null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit" isLoading={createBoard.isPending}>
|
||||
{t`Create board`}
|
||||
|
||||
191
apps/web/src/views/boards/components/TemplateBoards.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HiCheckCircle } from "react-icons/hi2";
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
lists: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export const getTemplates = (): Template[] => [
|
||||
{
|
||||
id: "basic",
|
||||
name: t`Basic Kanban`,
|
||||
lists: [t`To Do`, t`In Progress`, t`Done`],
|
||||
labels: [t`High Priority`, t`Medium Priority`, t`Low Priority`],
|
||||
},
|
||||
{
|
||||
id: "software-dev",
|
||||
name: t`Software Development`,
|
||||
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
||||
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "content-creation",
|
||||
name: t`Content Creation`,
|
||||
lists: [
|
||||
t`Brainstorming`,
|
||||
t`Writing`,
|
||||
t`Editing`,
|
||||
t`Design`,
|
||||
t`Approval`,
|
||||
t`Publishing`,
|
||||
t`Done`,
|
||||
],
|
||||
labels: [t`Blog Post`, t`Social Media`, t`Video`, t`Newsletter`, t`Urgent`],
|
||||
},
|
||||
{
|
||||
id: "customer-support",
|
||||
name: t`Customer Support`,
|
||||
lists: [
|
||||
t`New Ticket`,
|
||||
t`Triaging`,
|
||||
t`In Progress`,
|
||||
t`Awaiting Customer`,
|
||||
t`Resolution`,
|
||||
t`Done`,
|
||||
],
|
||||
labels: [
|
||||
t`Bug Report`,
|
||||
t`Feature Request`,
|
||||
t`Question`,
|
||||
t`Urgent`,
|
||||
t`Billing`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "recruitment",
|
||||
name: t`Recruitment`,
|
||||
lists: [
|
||||
t`Applicants`,
|
||||
t`Screening`,
|
||||
t`Interviewing`,
|
||||
t`Offer`,
|
||||
t`Onboarding`,
|
||||
t`Hired`,
|
||||
],
|
||||
labels: [t`Remote`, t`Full-time`, t`Part-time`, t`Senior`, t`Junior`],
|
||||
},
|
||||
{
|
||||
id: "personal-project",
|
||||
name: t`Personal Project`,
|
||||
lists: [
|
||||
t`Ideas`,
|
||||
t`Research`,
|
||||
t`Planning`,
|
||||
t`Execution`,
|
||||
t`Review`,
|
||||
t`Next Steps`,
|
||||
t`Complete`,
|
||||
],
|
||||
labels: [t`Important`, t`Quick Win`, t`Long-term`, t`Learning`, t`Fun`],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TemplateBoards({
|
||||
currentBoard,
|
||||
setCurrentBoard,
|
||||
showTemplates,
|
||||
}: {
|
||||
currentBoard: Template | null;
|
||||
setCurrentBoard: (board: Template | null) => void;
|
||||
showTemplates: boolean;
|
||||
}) {
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const templates = getTemplates();
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
|
||||
const isAtTop = scrollTop <= 5;
|
||||
|
||||
setShowFade(!isAtBottom);
|
||||
setShowTopFade(!isAtTop);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
handleScroll();
|
||||
|
||||
scrollElement.addEventListener("scroll", handleScroll);
|
||||
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
||||
}, [showTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTemplates && currentBoard && scrollRef.current) {
|
||||
const selectedElement = scrollRef.current.querySelector(
|
||||
`[data-template-id="${currentBoard.id}"]`,
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, [showTemplates, currentBoard]);
|
||||
|
||||
const handleBoardSelect = (boardId: string) => {
|
||||
if (currentBoard?.id === boardId) {
|
||||
setCurrentBoard(null);
|
||||
} else {
|
||||
setCurrentBoard(
|
||||
templates.find((template) => template.id === boardId) ?? null,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!showTemplates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 pt-4">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scroll-container -mr-2 flex max-h-[200px] flex-col gap-3 overflow-y-auto pr-2 pt-0.5"
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<label
|
||||
key={template.id}
|
||||
data-template-id={template.id}
|
||||
onClick={() => handleBoardSelect(template.id)}
|
||||
className={`scroll-container relative flex cursor-pointer rounded-lg border p-3 transition-all hover:bg-light-100 dark:hover:bg-dark-200 ${
|
||||
currentBoard?.id === template.id
|
||||
? "border-light-700 bg-light-100 ring-1 ring-inset ring-light-700 dark:border-dark-700 dark:bg-dark-200 dark:ring-dark-700"
|
||||
: "border-light-600 dark:border-dark-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{template.name}
|
||||
</h4>
|
||||
<p className="text-xs text-light-950 dark:text-dark-900">
|
||||
{template.lists.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{currentBoard?.id === template.id && (
|
||||
<div className="absolute right-3 top-3 text-light-1000 dark:text-dark-1000">
|
||||
<HiCheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{showTopFade && (
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 h-6 bg-gradient-to-b from-white/80 to-transparent dark:from-dark-100/80" />
|
||||
)}
|
||||
{showFade && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-white/80 to-transparent dark:from-dark-100/80" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -19,42 +21,37 @@ export default function BoardsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-8 flex w-full justify-between">
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Boards`}
|
||||
</h1>
|
||||
<div className="flex">
|
||||
<button
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-dark-3000 mr-2 flex items-center gap-x-1.5 rounded-md border-[1px] border-light-600 px-3 py-2 text-sm text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-dark-600 dark:text-dark-1000"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center">
|
||||
<HiArrowDownTray
|
||||
className="-mr-0.5 h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{t`Import`}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
|
||||
variant="primary"
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
<div className="h-5 w-5 items-center">
|
||||
<HiOutlinePlusSmall
|
||||
className="-mr-0.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{t`New`}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal>
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_BOARD" && <NewBoardForm />}
|
||||
{modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
|
||||
@@ -145,7 +145,7 @@ const Comment = ({
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function LabelSelector({
|
||||
</Menu.Button>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Add label`}
|
||||
</Menu.Button>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function ListSelector({
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedList?.value}
|
||||
</Menu.Button>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function MemberSelector({
|
||||
createNewItemLabel={t`Invite member`}
|
||||
asChild
|
||||
>
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
|
||||
@@ -51,7 +51,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-full max-w-[1000px] flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -32,29 +33,18 @@ interface FormValues {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function CardPage() {
|
||||
export function CardRightPanel() {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { modalContentType, entityId } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery({
|
||||
const { data: card } = api.card.byId.useQuery({
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const refetchCard = async () => {
|
||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||
};
|
||||
|
||||
const board = card?.list.board;
|
||||
const boardId = board?.publicId;
|
||||
const labels = board?.labels;
|
||||
const activities = card?.activities;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
const selectedLabels = card?.labels;
|
||||
const selectedMembers = card?.members;
|
||||
@@ -109,6 +99,59 @@ export default function CardPage() {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
|
||||
<ListSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
lists={formattedLists}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Labels`}</p>
|
||||
<LabelSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
labels={formattedLabels}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CardPage() {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { modalContentType, entityId } = 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({
|
||||
@@ -138,8 +181,6 @@ export default function CardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const description = watch("description");
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
return (
|
||||
@@ -148,9 +189,9 @@ export default function CardPage() {
|
||||
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
|
||||
/>
|
||||
<div className="flex h-full flex-1 flex-row">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
||||
<div className="mb-8 flex w-full items-center justify-between">
|
||||
<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">
|
||||
{!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" />
|
||||
@@ -196,7 +237,7 @@ export default function CardPage() {
|
||||
</div>
|
||||
{card && (
|
||||
<>
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
@@ -210,7 +251,7 @@ export default function CardPage() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
{t`Activity`}
|
||||
</h2>
|
||||
@@ -230,34 +271,9 @@ 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">{t`List`}</p>
|
||||
<ListSelector
|
||||
cardPublicId={cardId}
|
||||
lists={formattedLists}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full">
|
||||
<p className="my-2 w-[100px] text-sm">{t`Labels`}</p>
|
||||
<LabelSelector
|
||||
cardPublicId={cardId}
|
||||
labels={formattedLabels}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<p className="my-2 w-[100px] text-sm">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal>
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_LABEL" && (
|
||||
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} />
|
||||
)}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function HomeView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
{/* <div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://news.ycombinator.com/item?id=44157177"
|
||||
@@ -58,7 +58,7 @@ export default function HomeView() {
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
|
||||
@@ -2,9 +2,12 @@ import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -26,6 +29,7 @@ export default function MembersPage() {
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
memberImage,
|
||||
@@ -35,6 +39,7 @@ export default function MembersPage() {
|
||||
showSkeleton,
|
||||
}: {
|
||||
memberPublicId?: string;
|
||||
memberId?: string | null | undefined;
|
||||
memberName?: string | null | undefined;
|
||||
memberEmail?: string | null | undefined;
|
||||
memberImage?: string | null | undefined;
|
||||
@@ -43,13 +48,19 @@ export default function MembersPage() {
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
}) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}>
|
||||
<div className="flex items-center p-4">
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-full sm:w-[65%]",
|
||||
isLastRow ? "rounded-bl-lg" : "",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center p-2 sm:p-4">
|
||||
<div className="flex-shrink-0">
|
||||
{showSkeleton ? (
|
||||
<div className="h-9 w-9 animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||
<div className="h-8 w-8 animate-pulse rounded-full bg-light-200 dark:bg-dark-200 sm:h-9 sm:w-9" />
|
||||
) : (
|
||||
<Avatar
|
||||
name={memberName ?? ""}
|
||||
@@ -63,7 +74,7 @@ export default function MembersPage() {
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={twMerge(
|
||||
"mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000",
|
||||
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -73,7 +84,7 @@ export default function MembersPage() {
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-sm text-dark-900",
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -86,15 +97,15 @@ export default function MembersPage() {
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-[35%] min-w-[150px]",
|
||||
"w-auto min-w-[120px] sm:w-[35%] sm:min-w-[150px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-3">
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between px-2 sm:px-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20",
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -103,7 +114,7 @@ export default function MembersPage() {
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
</span>
|
||||
{memberStatus === "invited" && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
|
||||
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
|
||||
{t`Pending`}
|
||||
</span>
|
||||
)}
|
||||
@@ -114,24 +125,26 @@ export default function MembersPage() {
|
||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||
)}
|
||||
>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Remove member`,
|
||||
action: () =>
|
||||
openModal(
|
||||
"REMOVE_MEMBER",
|
||||
memberPublicId,
|
||||
memberEmail ?? "",
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={25}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</Dropdown>
|
||||
{session?.user.id !== memberId && (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Remove member`,
|
||||
action: () =>
|
||||
openModal(
|
||||
"REMOVE_MEMBER",
|
||||
memberPublicId,
|
||||
memberEmail ?? "",
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -142,7 +155,7 @@ export default function MembersPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto max-w-[1600px] px-28 py-12">
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="mb-8 flex w-full justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Members`}
|
||||
@@ -159,21 +172,21 @@ export default function MembersPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="-mx-4 -my-2 overflow-x-visible sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full px-4 py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[65%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
|
||||
className="w-full rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[65%] sm:pl-6"
|
||||
>
|
||||
{t`User`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[35%] rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
className="w-auto whitespace-nowrap rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[35%]"
|
||||
>
|
||||
{t`Role`}
|
||||
</th>
|
||||
@@ -185,6 +198,7 @@ export default function MembersPage() {
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
@@ -208,7 +222,8 @@ export default function MembersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal>
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
|
||||
{modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}
|
||||
|
||||
@@ -105,13 +105,15 @@ export default function PublicBoardView() {
|
||||
{data?.name}
|
||||
</h1>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filters
|
||||
labels={data?.labels ?? []}
|
||||
members={[]}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{data && (
|
||||
<div className="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">
|
||||
|
||||
@@ -3,6 +3,8 @@ import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
@@ -58,7 +60,7 @@ export default function Avatar({
|
||||
}
|
||||
|
||||
const fileExt = file.name.split(".").pop();
|
||||
const fileName = `${userId}/avatar.${fileExt}`;
|
||||
const fileName = `${userId}/avatar-${generateUID()}.${fileExt}`;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import { LanguageSelector } from "~/components/LanguageSelector";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
@@ -27,6 +29,9 @@ export default function SettingsPage() {
|
||||
const { workspace } = useWorkspace();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
|
||||
@@ -59,6 +64,24 @@ export default function SettingsPage() {
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
router.query.edit === "workspace_url" &&
|
||||
workspaceUrlSectionRef.current &&
|
||||
scrollContainerRef.current
|
||||
) {
|
||||
const element = workspaceUrlSectionRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
|
||||
container.scrollTop = element.offsetTop - 40;
|
||||
|
||||
const input = element.querySelector('input[type="text"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
}, [router.query.edit]);
|
||||
|
||||
const { mutateAsync: disconnectTrello } =
|
||||
api.integration.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -104,9 +127,12 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]"
|
||||
>
|
||||
<PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="px-28 py-12">
|
||||
<div className="m-auto max-w-[1100px] px-5 py-6 md:px-28 md:py-12">
|
||||
<div className="mb-8 flex w-full justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Settings`}
|
||||
@@ -134,14 +160,16 @@ export default function SettingsPage() {
|
||||
workspaceName={workspace.name}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace URL`}
|
||||
</h2>
|
||||
<UpdateWorkspaceUrlForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceUrl={workspace.slug ?? ""}
|
||||
workspacePlan={workspace.plan ?? "free"}
|
||||
/>
|
||||
<div ref={workspaceUrlSectionRef}>
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace URL`}
|
||||
</h2>
|
||||
<UpdateWorkspaceUrlForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceUrl={workspace.slug ?? ""}
|
||||
workspacePlan={workspace.plan ?? "free"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace description`}
|
||||
@@ -273,7 +301,8 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal>
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
{modalContentType === "DELETE_WORKSPACE" && (
|
||||
<DeleteWorkspaceConfirmation />
|
||||
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"@kan/stripe": "workspace:^",
|
||||
"@trpc/server": "catalog:",
|
||||
"superjson": "2.2.1",
|
||||
"trpc-to-openapi": "^2.1.0",
|
||||
"trpc-to-openapi": "^2.3.2",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,8 +4,10 @@ import { z } from "zod";
|
||||
import * as boardRepo from "@kan/db/repository/board.repo";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as activityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { colours } from "@kan/shared/constants";
|
||||
import { generateSlug, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
@@ -149,8 +151,10 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
workspacePublicId: z.string().min(12),
|
||||
lists: z.array(z.string().min(1)),
|
||||
labels: z.array(z.string().min(1)),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
@@ -199,6 +203,30 @@ export const boardRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
if (input.lists.length) {
|
||||
const listInputs = input.lists.map((list, index) => ({
|
||||
publicId: generateUID(),
|
||||
name: list,
|
||||
boardId: result.id,
|
||||
createdBy: userId,
|
||||
index,
|
||||
}));
|
||||
|
||||
await listRepo.bulkCreate(ctx.db, listInputs);
|
||||
}
|
||||
|
||||
if (input.labels.length) {
|
||||
const labelInputs = input.labels.map((label, index) => ({
|
||||
publicId: generateUID(),
|
||||
name: label,
|
||||
boardId: result.id,
|
||||
createdBy: userId,
|
||||
colourCode: colours[index % colours.length]?.code ?? "#0d9488",
|
||||
}));
|
||||
|
||||
await labelRepo.bulkCreate(ctx.db, labelInputs);
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
@@ -248,6 +276,21 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
|
||||
if (input.slug) {
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
ctx.db,
|
||||
input.slug,
|
||||
board.workspaceId,
|
||||
);
|
||||
|
||||
if (!isBoardSlugAvailable) {
|
||||
throw new TRPCError({
|
||||
message: `Board slug ${input.slug} is not available`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await boardRepo.update(ctx.db, {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
@@ -352,40 +395,52 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
checkSlugAvailability: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Check if a board slug is available",
|
||||
method: "GET",
|
||||
path: "/boards/{boardPublicId}/check-slug-availability",
|
||||
description: "Checks if a board slug is available",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
isReserved: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
ctx.db,
|
||||
input.boardSlug,
|
||||
input.boardPublicId,
|
||||
);
|
||||
return {
|
||||
isReserved: !isBoardSlugAvailable,
|
||||
};
|
||||
checkSlugAvailability: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Check if a board slug is available",
|
||||
method: "GET",
|
||||
path: "/boards/{boardPublicId}/check-slug-availability",
|
||||
description: "Checks if a board slug is available",
|
||||
tags: ["Boards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
isReserved: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const board = await boardRepo.getWorkspaceAndBoardIdByBoardPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
);
|
||||
|
||||
if (!board)
|
||||
throw new TRPCError({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
ctx.db,
|
||||
input.boardSlug,
|
||||
board.workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
isReserved: !isBoardSlugAvailable,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ export const importRouter = createTRPCRouter({
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
||||
.query(async ({ ctx }) => {
|
||||
const apiKey = apiKeys.trello;
|
||||
|
||||
@@ -15,22 +15,47 @@ export const apiKeys = {
|
||||
};
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
providers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = ctx.user;
|
||||
providers: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get integration providers",
|
||||
method: "GET",
|
||||
path: "/integration/providers",
|
||||
description: "Retrieves all integration providers for the user",
|
||||
tags: ["Integration"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
expiresAt: z.date(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date().nullable(),
|
||||
userId: z.string(),
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().nullable(),
|
||||
provider: z.string(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const integrations = await integrationsRepo.getProvidersForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
);
|
||||
const integrations = await integrationsRepo.getProvidersForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
);
|
||||
|
||||
return integrations;
|
||||
}),
|
||||
return integrations;
|
||||
}),
|
||||
disconnect: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
|
||||
@@ -8,6 +8,12 @@ import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
|
||||
const labelSchema = z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
colourCode: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const labelRouter = createTRPCRouter({
|
||||
byPublicId: protectedProcedure
|
||||
.meta({
|
||||
@@ -21,7 +27,7 @@ export const labelRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ labelPublicId: z.string().min(12) }))
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.getByPublicId>>>())
|
||||
.output(labelSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -52,7 +58,11 @@ export const labelRouter = createTRPCRouter({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
return result;
|
||||
return {
|
||||
publicId: result.publicId,
|
||||
name: result.name,
|
||||
colourCode: result.colourCode,
|
||||
};
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
@@ -72,7 +82,7 @@ export const labelRouter = createTRPCRouter({
|
||||
colourCode: z.string().length(7),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.create>>>())
|
||||
.output(labelSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -108,7 +118,11 @@ export const labelRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
return {
|
||||
publicId: result.publicId,
|
||||
name: result.name,
|
||||
colourCode: result.colourCode,
|
||||
};
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
@@ -128,7 +142,7 @@ export const labelRouter = createTRPCRouter({
|
||||
colourCode: z.string().length(7),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof labelRepo.update>>>())
|
||||
.output(labelSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -153,7 +167,17 @@ export const labelRouter = createTRPCRouter({
|
||||
|
||||
const result = await labelRepo.update(ctx.db, input);
|
||||
|
||||
return result;
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update label`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return {
|
||||
publicId: result.publicId,
|
||||
name: result.name,
|
||||
colourCode: result.colourCode,
|
||||
};
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
@@ -78,16 +77,22 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
const { status } = await ctx.auth.api.signInMagicLink({
|
||||
email: input.email,
|
||||
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
|
||||
});
|
||||
|
||||
if (error)
|
||||
if (!status) {
|
||||
console.error("Failed to send magic link invitation:", {
|
||||
email: input.email,
|
||||
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
message: `Failed to send magic link to user with email ${input.email}`,
|
||||
message: `Failed to send magic link invitation to user with email ${input.email}.`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return invite;
|
||||
}),
|
||||
|
||||
@@ -20,58 +20,73 @@ export interface User {
|
||||
stripeCustomerId?: string | null | undefined;
|
||||
}
|
||||
|
||||
const createAuthWithHeaders = (
|
||||
auth: ReturnType<typeof initAuth>,
|
||||
headers: Headers,
|
||||
) => {
|
||||
return {
|
||||
api: {
|
||||
getSession: () => auth.api.getSession({ headers }),
|
||||
signInMagicLink: (input: { email: string; callbackURL: string }) =>
|
||||
auth.api.signInMagicLink({
|
||||
headers,
|
||||
body: { email: input.email, callbackURL: input.callbackURL },
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface CreateContextOptions {
|
||||
user: User | null | undefined;
|
||||
db: dbClient;
|
||||
auth: ReturnType<typeof createAuthWithHeaders>;
|
||||
}
|
||||
|
||||
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
|
||||
return {
|
||||
user: opts.user,
|
||||
db: opts.db,
|
||||
auth: opts.auth,
|
||||
};
|
||||
};
|
||||
|
||||
export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
|
||||
const db = createDrizzleClient();
|
||||
const auth = initAuth(db);
|
||||
const baseAuth = initAuth(db);
|
||||
const headers = new Headers(req.headers as Record<string, string>);
|
||||
const auth = createAuthWithHeaders(baseAuth, headers);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
// @ts-expect-error
|
||||
headers: new Headers(req.headers),
|
||||
});
|
||||
const session = await auth.api.getSession();
|
||||
|
||||
return createInnerTRPCContext({ db, user: session?.user });
|
||||
return createInnerTRPCContext({ db, user: session?.user, auth });
|
||||
};
|
||||
|
||||
export const createNextApiContext = async (req: NextApiRequest) => {
|
||||
const db = createDrizzleClient();
|
||||
const auth = initAuth(db);
|
||||
const baseAuth = initAuth(db);
|
||||
const headers = new Headers(req.headers as Record<string, string>);
|
||||
const auth = createAuthWithHeaders(baseAuth, headers);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
// @ts-expect-error
|
||||
headers: new Headers(req.headers),
|
||||
});
|
||||
const session = await auth.api.getSession();
|
||||
|
||||
return createInnerTRPCContext({ db, user: session?.user });
|
||||
return createInnerTRPCContext({ db, user: session?.user, auth });
|
||||
};
|
||||
|
||||
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||
const db = createDrizzleClient();
|
||||
const auth = initAuth(db);
|
||||
const baseAuth = initAuth(db);
|
||||
const headers = new Headers(req.headers as Record<string, string>);
|
||||
const auth = createAuthWithHeaders(baseAuth, headers);
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await auth.api.getSession({
|
||||
// @ts-expect-error
|
||||
headers: new Headers(req.headers),
|
||||
});
|
||||
session = await auth.api.getSession();
|
||||
} catch (error) {
|
||||
console.error("Error getting session, ", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return createInnerTRPCContext({ db, user: session?.user });
|
||||
return createInnerTRPCContext({ db, user: session?.user, auth });
|
||||
};
|
||||
|
||||
const t = initTRPC
|
||||
|
||||
@@ -84,7 +84,7 @@ export const socialProvidersPlugin = () => ({
|
||||
method: "GET",
|
||||
},
|
||||
async (ctx) =>
|
||||
ctx.json(ctx.context.socialProviders.map((p) => p.name.toLowerCase())),
|
||||
ctx.json(ctx.context.socialProviders.map((p) => p.id.toLowerCase())),
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -166,9 +166,19 @@ export const initAuth = (db: dbClient) => {
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before() {
|
||||
async before(user) {
|
||||
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
|
||||
return Promise.resolve(false);
|
||||
const pendingInvitation = await memberRepo.getByEmailAndStatus(
|
||||
db,
|
||||
user.email,
|
||||
"invited",
|
||||
);
|
||||
|
||||
if (!pendingInvitation) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
@@ -181,6 +191,7 @@ export const initAuth = (db: dbClient) => {
|
||||
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") ?? "",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||
@@ -481,30 +481,18 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
||||
export const isBoardSlugAvailable = async (
|
||||
db: dbClient,
|
||||
boardSlug: string,
|
||||
boardPublicId: string,
|
||||
workspaceId: number,
|
||||
) => {
|
||||
const result = await db
|
||||
.select({ id: boards.id })
|
||||
.from(boards)
|
||||
.where(
|
||||
and(
|
||||
eq(boards.publicId, boardPublicId),
|
||||
exists(
|
||||
db
|
||||
.select({ id: boards.id })
|
||||
.from(boards)
|
||||
.where(
|
||||
and(
|
||||
eq(boards.slug, boardSlug),
|
||||
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const result = await db.query.boards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
where: and(
|
||||
eq(boards.slug, boardSlug),
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
});
|
||||
|
||||
return result.length === 0;
|
||||
return result === undefined;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,12 @@ export const create = async (
|
||||
createdBy: labelInput.createdBy,
|
||||
boardId: labelInput.boardId,
|
||||
})
|
||||
.returning({ id: labels.id });
|
||||
.returning({
|
||||
id: labels.id,
|
||||
publicId: labels.publicId,
|
||||
name: labels.name,
|
||||
colourCode: labels.colourCode,
|
||||
});
|
||||
|
||||
if (labelInput.cardId && result) {
|
||||
await db.insert(cardsToLabels).values({
|
||||
@@ -91,6 +96,7 @@ export const update = async (
|
||||
.where(eq(labels.publicId, labelInput.labelPublicId))
|
||||
.returning({
|
||||
id: labels.id,
|
||||
publicId: labels.publicId,
|
||||
name: labels.name,
|
||||
colourCode: labels.colourCode,
|
||||
});
|
||||
|
||||
@@ -68,6 +68,20 @@ export const create = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const bulkCreate = async (
|
||||
db: dbClient,
|
||||
listInput: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
boardId: number;
|
||||
index: number;
|
||||
importId?: number;
|
||||
}[],
|
||||
) => {
|
||||
return db.insert(lists).values(listInput).returning();
|
||||
};
|
||||
|
||||
export const getByPublicId = async (db: dbClient, listPublicId: string) => {
|
||||
return db.query.lists.findFirst({
|
||||
columns: {
|
||||
|
||||
@@ -41,6 +41,20 @@ export const getByPublicId = async (db: dbClient, publicId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getByEmailAndStatus = async (
|
||||
db: dbClient,
|
||||
email: string,
|
||||
status: MemberStatus,
|
||||
) => {
|
||||
return db.query.workspaceMembers.findFirst({
|
||||
where: and(
|
||||
eq(workspaceMembers.email, email),
|
||||
eq(workspaceMembers.status, status),
|
||||
isNull(workspaceMembers.deletedAt),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const acceptInvite = async (
|
||||
db: dbClient,
|
||||
args: { memberId: number; userId: string },
|
||||
|
||||
156
pnpm-lock.yaml
generated
@@ -10,14 +10,14 @@ catalogs:
|
||||
specifier: ^5.59.15
|
||||
version: 5.62.7
|
||||
'@trpc/client':
|
||||
specifier: ^11.0.0-rc.477
|
||||
version: 11.0.0-rc.660
|
||||
specifier: ^11.4.3
|
||||
version: 11.4.3
|
||||
'@trpc/react-query':
|
||||
specifier: ^11.0.0-rc.477
|
||||
version: 11.0.0-rc.660
|
||||
specifier: ^11.4.3
|
||||
version: 11.4.3
|
||||
'@trpc/server':
|
||||
specifier: ^11.0.0-rc.477
|
||||
version: 11.0.0-rc.660
|
||||
specifier: ^11.4.3
|
||||
version: 11.4.3
|
||||
eslint:
|
||||
specifier: ^9.12.0
|
||||
version: 9.16.0
|
||||
@@ -140,16 +140,16 @@ importers:
|
||||
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
||||
'@trpc/client':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2)
|
||||
version: 11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2)
|
||||
'@trpc/next':
|
||||
specifier: ^11.0.0-rc.660
|
||||
version: 11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2))(@trpc/react-query@11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(next@15.3.4(@babel/core@7.24.5)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)
|
||||
version: 11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2))(@trpc/react-query@11.4.3(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.4.3(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(@trpc/server@11.4.3(typescript@5.7.2))(next@15.3.4(@babel/core@7.24.5)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)
|
||||
'@trpc/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)
|
||||
version: 11.4.3(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.4.3(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)
|
||||
'@trpc/server':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.0-rc.660(typescript@5.7.2)
|
||||
version: 11.4.3(typescript@5.7.2)
|
||||
aws-sdk:
|
||||
specifier: ^2.1692.0
|
||||
version: 2.1692.0
|
||||
@@ -291,13 +291,13 @@ importers:
|
||||
version: link:../stripe
|
||||
'@trpc/server':
|
||||
specifier: 'catalog:'
|
||||
version: 11.0.0-rc.660(typescript@5.7.2)
|
||||
version: 11.4.3(typescript@5.7.2)
|
||||
superjson:
|
||||
specifier: 2.2.1
|
||||
version: 2.2.1
|
||||
trpc-to-openapi:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(zod-openapi@4.1.0(zod@3.24.0))(zod@3.24.0)
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2(@trpc/server@11.4.3(typescript@5.7.2))(zod-openapi@4.1.0(zod@3.24.0))(zod@3.24.0)
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.24.0
|
||||
@@ -2818,11 +2818,11 @@ packages:
|
||||
'@tootallnate/quickjs-emscripten@0.23.0':
|
||||
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
|
||||
|
||||
'@trpc/client@11.0.0-rc.660':
|
||||
resolution: {integrity: sha512-bNpkZEfyMGKHynYFxdLpY8nJ1n7E3JHKcd4Pe2cagmpkzOEF9tFT3kzNf+eLI8XMG8196lTRR0J0W2/1Q8/cug==}
|
||||
'@trpc/client@11.4.3':
|
||||
resolution: {integrity: sha512-i2suttUCfColktXT8bqex5kHW5jpT15nwUh0hGSDiW1keN621kSUQKcLJ095blqQAUgB+lsmgSqSMmB4L9shQQ==}
|
||||
peerDependencies:
|
||||
'@trpc/server': 11.0.0-rc.660+74625d5e4
|
||||
typescript: '>=5.6.2'
|
||||
'@trpc/server': 11.4.3
|
||||
typescript: '>=5.7.2'
|
||||
|
||||
'@trpc/next@11.0.0-rc.660':
|
||||
resolution: {integrity: sha512-LQHdiVED8h+CJEAy/X9PDmY3fM8VPdZ+3bVkbmEiP4xoNnSKRTbSbknFyRzG9M2AogfIafF4zAZVwOvCxexyGw==}
|
||||
@@ -2841,20 +2841,20 @@ packages:
|
||||
'@trpc/react-query':
|
||||
optional: true
|
||||
|
||||
'@trpc/react-query@11.0.0-rc.660':
|
||||
resolution: {integrity: sha512-U2BHtYVt+8jt0a8Nrrk5cep8O1UZRxtTCBHtXie9kmJyQWWml43KfHxL5ssnFywaFrDZQz6Ec7kIoOxR/CQNfg==}
|
||||
'@trpc/react-query@11.4.3':
|
||||
resolution: {integrity: sha512-z+jhAiOBD22NNhHtvF0iFp9hO36YFA7M8AiUu/XtNmMxyLd3Y9/d1SMjMwlTdnGqxEGPo41VEWBrdhDUGtUuHg==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^5.59.15
|
||||
'@trpc/client': 11.0.0-rc.660+74625d5e4
|
||||
'@trpc/server': 11.0.0-rc.660+74625d5e4
|
||||
'@tanstack/react-query': ^5.80.3
|
||||
'@trpc/client': 11.4.3
|
||||
'@trpc/server': 11.4.3
|
||||
react: '>=18.2.0'
|
||||
react-dom: '>=18.2.0'
|
||||
typescript: '>=5.6.2'
|
||||
typescript: '>=5.7.2'
|
||||
|
||||
'@trpc/server@11.0.0-rc.660':
|
||||
resolution: {integrity: sha512-QUapcZCNOpHT7ng9LceGc9ImkboWd0Go9ryrduZpL+p4jdfaC6409AQ3x4XEW6Wu3yBmZAn4CywCsDrDhjDy/w==}
|
||||
'@trpc/server@11.4.3':
|
||||
resolution: {integrity: sha512-wnWq3wiLlMOlYkaIZz+qbuYA5udPTLS4GVVRyFkr6aT83xpdCHyVtURT+u4hSoIrOXQM9OPCNXSXsAujWZDdaw==}
|
||||
peerDependencies:
|
||||
typescript: '>=5.6.2'
|
||||
typescript: '>=5.7.2'
|
||||
|
||||
'@tsconfig/node10@1.0.11':
|
||||
resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==}
|
||||
@@ -3575,10 +3575,6 @@ packages:
|
||||
config-chain@1.1.13:
|
||||
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
||||
|
||||
consola@3.2.3:
|
||||
resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0}
|
||||
|
||||
constant-case@2.0.0:
|
||||
resolution: {integrity: sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ==}
|
||||
|
||||
@@ -3629,8 +3625,8 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crossws@0.3.1:
|
||||
resolution: {integrity: sha512-HsZgeVYaG+b5zA+9PbIPGq4+J/CJynJuearykPsXx4V/eMhyQ5EDVg3Ak2FBZtVXCiOLu/U7IiwDHTr9MA+IKw==}
|
||||
crossws@0.3.5:
|
||||
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
|
||||
|
||||
css-box-model@1.2.1:
|
||||
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
|
||||
@@ -4444,8 +4440,8 @@ packages:
|
||||
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
||||
h3@1.13.0:
|
||||
resolution: {integrity: sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==}
|
||||
h3@1.15.1:
|
||||
resolution: {integrity: sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA==}
|
||||
|
||||
handlebars@4.7.8:
|
||||
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
|
||||
@@ -4977,9 +4973,6 @@ packages:
|
||||
lodash.castarray@4.4.0:
|
||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
||||
|
||||
lodash.clonedeep@4.5.0:
|
||||
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||
|
||||
lodash.get@4.4.2:
|
||||
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
||||
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
||||
@@ -5340,11 +5333,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@3.0.0:
|
||||
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-fn@2.1.0:
|
||||
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5497,9 +5485,6 @@ packages:
|
||||
no-case@2.3.2:
|
||||
resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==}
|
||||
|
||||
node-fetch-native@1.6.4:
|
||||
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -5513,6 +5498,9 @@ packages:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
node-mock-http@1.0.1:
|
||||
resolution: {integrity: sha512-0gJJgENizp4ghds/Ywu2FCmcRsgBTmRQzYPZm61wy+Em2sBarSka0OhQS5huLBg6od1zkNpnWMCZloQDFVvOMQ==}
|
||||
|
||||
node-plop@0.26.3:
|
||||
resolution: {integrity: sha512-Cov028YhBZ5aB7MdMWJEmwyBig43aGL5WT4vdoB28Oitau1zZAcHUn8Sgfk9HM33TqhtLJ9PlM/O0Mv+QpV/4Q==}
|
||||
engines: {node: '>=8.9.4'}
|
||||
@@ -5576,9 +5564,6 @@ packages:
|
||||
obuf@1.1.2:
|
||||
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
||||
|
||||
ohash@1.1.4:
|
||||
resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5597,8 +5582,8 @@ packages:
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
openapi3-ts@4.3.3:
|
||||
resolution: {integrity: sha512-LKkzBGJcZ6wdvkKGMoSvpK+0cbN5Xc3XuYkJskO+vjEQWJgs1kgtyUk0pjf8KwPuysv323Er62F5P17XQl96Qg==}
|
||||
openapi3-ts@4.4.0:
|
||||
resolution: {integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
@@ -6736,12 +6721,12 @@ packages:
|
||||
trough@2.2.0:
|
||||
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
|
||||
|
||||
trpc-to-openapi@2.1.0:
|
||||
resolution: {integrity: sha512-LODbI2a9LQamLpEz9hsqeMJBxpNT8KqfyICJhTVe8JBPZM8DvUkoICPlsdHf1KJVteoem/LlbwkD7loKYPWP1Q==}
|
||||
trpc-to-openapi@2.3.2:
|
||||
resolution: {integrity: sha512-uDZou+5a+H77yhiqfzVRBXzTSX8/mQUGeFpr8+VtZmpzUBOmv5XYbyEtWpG7fOdGWZkB99wuuKeWUwNwwOGaXQ==}
|
||||
peerDependencies:
|
||||
'@trpc/server': ^11.0.0-rc.648
|
||||
'@trpc/server': ^11.1.0
|
||||
zod: ^3.23.8
|
||||
zod-openapi: ^4.1.0
|
||||
zod-openapi: 4.2.4
|
||||
|
||||
ts-api-utils@1.4.3:
|
||||
resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==}
|
||||
@@ -6874,9 +6859,6 @@ packages:
|
||||
undici-types@6.19.8:
|
||||
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
|
||||
|
||||
unenv@1.10.0:
|
||||
resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==}
|
||||
|
||||
unified@10.1.2:
|
||||
resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==}
|
||||
|
||||
@@ -9709,33 +9691,33 @@ snapshots:
|
||||
|
||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||
|
||||
'@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2)':
|
||||
'@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@trpc/server': 11.0.0-rc.660(typescript@5.7.2)
|
||||
'@trpc/server': 11.4.3(typescript@5.7.2)
|
||||
typescript: 5.7.2
|
||||
|
||||
'@trpc/next@11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2))(@trpc/react-query@11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(next@15.3.4(@babel/core@7.24.5)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)':
|
||||
'@trpc/next@11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2))(@trpc/react-query@11.4.3(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.4.3(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2))(@trpc/server@11.4.3(typescript@5.7.2))(next@15.3.4(@babel/core@7.24.5)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@trpc/client': 11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2)
|
||||
'@trpc/server': 11.0.0-rc.660(typescript@5.7.2)
|
||||
'@trpc/client': 11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2)
|
||||
'@trpc/server': 11.4.3(typescript@5.7.2)
|
||||
next: 15.3.4(@babel/core@7.24.5)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
typescript: 5.7.2
|
||||
optionalDependencies:
|
||||
'@tanstack/react-query': 5.62.7(react@18.3.1)
|
||||
'@trpc/react-query': 11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)
|
||||
'@trpc/react-query': 11.4.3(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.4.3(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)
|
||||
|
||||
'@trpc/react-query@11.0.0-rc.660(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)':
|
||||
'@trpc/react-query@11.4.3(@tanstack/react-query@5.62.7(react@18.3.1))(@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2))(@trpc/server@11.4.3(typescript@5.7.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.7.2)':
|
||||
dependencies:
|
||||
'@tanstack/react-query': 5.62.7(react@18.3.1)
|
||||
'@trpc/client': 11.0.0-rc.660(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(typescript@5.7.2)
|
||||
'@trpc/server': 11.0.0-rc.660(typescript@5.7.2)
|
||||
'@trpc/client': 11.4.3(@trpc/server@11.4.3(typescript@5.7.2))(typescript@5.7.2)
|
||||
'@trpc/server': 11.4.3(typescript@5.7.2)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
typescript: 5.7.2
|
||||
|
||||
'@trpc/server@11.0.0-rc.660(typescript@5.7.2)':
|
||||
'@trpc/server@11.4.3(typescript@5.7.2)':
|
||||
dependencies:
|
||||
typescript: 5.7.2
|
||||
|
||||
@@ -10615,8 +10597,6 @@ snapshots:
|
||||
ini: 1.3.8
|
||||
proto-list: 1.2.4
|
||||
|
||||
consola@3.2.3: {}
|
||||
|
||||
constant-case@2.0.0:
|
||||
dependencies:
|
||||
snake-case: 2.1.0
|
||||
@@ -10668,7 +10648,7 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crossws@0.3.1:
|
||||
crossws@0.3.5:
|
||||
dependencies:
|
||||
uncrypto: 0.1.3
|
||||
|
||||
@@ -11642,18 +11622,17 @@ snapshots:
|
||||
section-matter: 1.0.0
|
||||
strip-bom-string: 1.0.0
|
||||
|
||||
h3@1.13.0:
|
||||
h3@1.15.1:
|
||||
dependencies:
|
||||
cookie-es: 1.2.2
|
||||
crossws: 0.3.1
|
||||
crossws: 0.3.5
|
||||
defu: 6.1.4
|
||||
destr: 2.0.3
|
||||
iron-webcrypto: 1.2.1
|
||||
ohash: 1.1.4
|
||||
node-mock-http: 1.0.1
|
||||
radix3: 1.1.2
|
||||
ufo: 1.5.4
|
||||
uncrypto: 0.1.3
|
||||
unenv: 1.10.0
|
||||
|
||||
handlebars@4.7.8:
|
||||
dependencies:
|
||||
@@ -12195,8 +12174,6 @@ snapshots:
|
||||
|
||||
lodash.castarray@4.4.0: {}
|
||||
|
||||
lodash.clonedeep@4.5.0: {}
|
||||
|
||||
lodash.get@4.4.2: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
@@ -12938,8 +12915,6 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime@3.0.0: {}
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
||||
mini-svg-data-uri@1.4.4: {}
|
||||
@@ -13076,8 +13051,6 @@ snapshots:
|
||||
dependencies:
|
||||
lower-case: 1.1.4
|
||||
|
||||
node-fetch-native@1.6.4: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
@@ -13085,6 +13058,8 @@ snapshots:
|
||||
node-gyp-build@4.8.4:
|
||||
optional: true
|
||||
|
||||
node-mock-http@1.0.1: {}
|
||||
|
||||
node-plop@0.26.3:
|
||||
dependencies:
|
||||
'@babel/runtime-corejs3': 7.26.0
|
||||
@@ -13155,8 +13130,6 @@ snapshots:
|
||||
|
||||
obuf@1.1.2: {}
|
||||
|
||||
ohash@1.1.4: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
once@1.4.0:
|
||||
@@ -13175,7 +13148,7 @@ snapshots:
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
openapi3-ts@4.3.3:
|
||||
openapi3-ts@4.4.0:
|
||||
dependencies:
|
||||
yaml: 2.6.1
|
||||
|
||||
@@ -14518,13 +14491,12 @@ snapshots:
|
||||
|
||||
trough@2.2.0: {}
|
||||
|
||||
trpc-to-openapi@2.1.0(@trpc/server@11.0.0-rc.660(typescript@5.7.2))(zod-openapi@4.1.0(zod@3.24.0))(zod@3.24.0):
|
||||
trpc-to-openapi@2.3.2(@trpc/server@11.4.3(typescript@5.7.2))(zod-openapi@4.1.0(zod@3.24.0))(zod@3.24.0):
|
||||
dependencies:
|
||||
'@trpc/server': 11.0.0-rc.660(typescript@5.7.2)
|
||||
'@trpc/server': 11.4.3(typescript@5.7.2)
|
||||
co-body: 6.2.0
|
||||
h3: 1.13.0
|
||||
lodash.clonedeep: 4.5.0
|
||||
openapi3-ts: 4.3.3
|
||||
h3: 1.15.1
|
||||
openapi3-ts: 4.4.0
|
||||
zod: 3.24.0
|
||||
zod-openapi: 4.1.0(zod@3.24.0)
|
||||
optionalDependencies:
|
||||
@@ -14668,14 +14640,6 @@ snapshots:
|
||||
|
||||
undici-types@6.19.8: {}
|
||||
|
||||
unenv@1.10.0:
|
||||
dependencies:
|
||||
consola: 3.2.3
|
||||
defu: 6.1.4
|
||||
mime: 3.0.0
|
||||
node-fetch-native: 1.6.4
|
||||
pathe: 1.1.2
|
||||
|
||||
unified@10.1.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
@@ -5,9 +5,9 @@ packages:
|
||||
|
||||
catalog:
|
||||
"@tanstack/react-query": ^5.59.15
|
||||
"@trpc/client": ^11.0.0-rc.477
|
||||
"@trpc/react-query": ^11.0.0-rc.477
|
||||
"@trpc/server": ^11.0.0-rc.477
|
||||
"@trpc/client": ^11.4.3
|
||||
"@trpc/react-query": ^11.4.3
|
||||
"@trpc/server": ^11.4.3
|
||||
eslint: ^9.12.0
|
||||
prettier: ^3.3.3
|
||||
tailwindcss: ^3.4.14
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"S3_ACCESS_KEY_ID",
|
||||
"S3_SECRET_ACCESS_KEY",
|
||||
"S3_ENDPOINT",
|
||||
"S3_FORCE_PATH_STYLE",
|
||||
"PORT",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"BETTER_AUTH_TRUSTED_ORIGINS"
|
||||
|
||||