Compare commits

...

247 Commits

Author SHA1 Message Date
Henry
6f4157a108 feat: support SMTP via nodemailer 2025-06-04 10:16:42 +01:00
Henry
3625d702e3 fix: bypass custom URL checkout for self hosted (#16)
* fix: bypass custom URL checkout for self hosted

* Update error code
2025-06-04 07:34:19 +01:00
Sergio Guillen
2047f5a9ea Add step to run db:migrate as it is a required step to have a functional local setup (#14) 2025-06-04 06:04:18 +01:00
Henry
fcb97908e0 fix: revert to GH token 2025-06-03 23:12:04 +01:00
Henry
348d81a705 fix: use PAT instead of github token 2025-06-03 22:44:53 +01:00
Henry
cef670dc11 fix: update build context to root 2025-06-03 21:48:17 +01:00
Henry
23ae7a375b feat: add docker-publish.yml 2025-06-03 21:35:51 +01:00
Henry
0b06f80181 feat: remove NEXT_PUBLIC env vars from docker build args (#13)
* feat: add next public envs to runtime

* Commit env vars
2025-06-03 21:25:57 +01:00
Krisztiaan
37d2ebccf7 fix: modal forms not submitting on Enter key (#9)
* fix: add type="button" to modal close buttons to fix Enter key form submission

Previously, pressing Enter in modal form inputs would dismiss the modal instead
of submitting the form because close buttons defaulted to type="submit". This
adds explicit type="button" to all modal close buttons to ensure Enter key
properly submits forms.

Fixes:
- New board creation via Enter key
- New workspace creation via Enter key
- New card creation via Enter key
- New list creation via Enter key
- Member invitation via Enter key
- Label creation/editing via Enter key
- Board URL updates via Enter key
- Import boards forms via Enter key

* fix: submit forms on enter key submission

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-03 16:07:12 +01:00
Henry
1027b45cdb feat: update image upload API with user authentication and validation 2025-06-03 13:45:26 +01:00
Henry
f590937ddb chore: add env variable table to readme 2025-06-03 13:07:13 +01:00
Tiago Manuel
a29a2f6e16 Update .env.example (#4)
remove double S3_ENDPOINT entry
2025-06-02 16:20:10 +01:00
Henry
7e7ba2c0da chore: add screenshot to README 2025-06-02 15:22:59 +01:00
Henry
c976647b86 fix: update license link in footer 2025-06-02 14:37:09 +01:00
Henry
cd31da151f feat: update license to AGPLv3 2025-06-02 13:43:20 +01:00
Henry
b2760c9893 feat: add beta notice on pricing 2025-06-02 09:33:31 +01:00
Henry
06de61ce68 feat: add faqs answers 2025-06-02 09:22:18 +01:00
Henry
ee5cbd1e7f fix: change casing for apiKey schema 2025-06-01 23:07:21 +01:00
Henry
90b928bd51 docs: link to repo for self hosting 2025-06-01 22:57:14 +01:00
Henry
c2718a8038 docs: add trello import guide 2025-06-01 22:42:00 +01:00
Henry
0d6fa50bc3 fix: show warning icon for delete member popup 2025-06-01 22:29:07 +01:00
Henry
d602c1f960 fix: prevent duplicate indices when creating a new list 2025-06-01 22:28:46 +01:00
Henry
64c5488f0c fix: prevent soft deleting lists with no cards from throwing an error 2025-06-01 22:27:21 +01:00
Henry
c2a0bb827f fix: remove duplicate apiKey prefix 2025-06-01 21:08:00 +01:00
Henry
6279138c04 fix: set correct urls in docs 2025-06-01 21:05:09 +01:00
Henry
4d4a19ad7b chore: upgrade better-auth 2025-06-01 21:02:41 +01:00
Henry
84a9712d66 chore: upgrade stripe version 2025-06-01 20:59:14 +01:00
Henry
6b7f3cd497 chore: update stripe api version 2025-06-01 00:15:12 +01:00
Henry
fb319cec38 feat: add prefix to api key 2025-06-01 00:14:58 +01:00
Henry
471f36a9c9 fix: disable api key hashing 2025-06-01 00:12:16 +01:00
Henry
1d00aa78b4 feat: create and delete api keys 2025-05-31 23:59:48 +01:00
Henry
e61dd4e27e feat: show new workspace modal when user is not a member of any workspaces 2025-05-30 14:09:44 +01:00
Henry
db19a8dc45 feat: only show billing for cloud 2025-05-29 23:37:22 +01:00
Henry
14f55e5939 chore: update package licences and migration script 2025-05-29 23:18:25 +01:00
Henry
9d467529e1 fix: set stripe price id to env variable 2025-05-29 21:41:08 +01:00
Henry
6a8ff0dc71 chore: update readme headers 2025-05-29 13:17:20 +01:00
Henry
c1b38fadef feat: update readme add contributing.md 2025-05-29 13:13:40 +01:00
Henry
ffb3cec6a8 fix: set ellipsis colour in CheckboxDropdown component 2025-05-28 21:55:27 +01:00
Henry
47ddf0a048 fix: show email and avatar for pending members 2025-05-28 21:51:41 +01:00
Henry
feeb2f675b fix: display avatar image on cards 2025-05-28 21:23:52 +01:00
Henry
236aafa26c fix: prevent undefined workspace slug 2025-05-27 22:21:59 +01:00
Henry
a0e2d45f74 fix: update board and workspace slug queries to filter by visibility 2025-05-27 22:17:58 +01:00
Henry
b1bb8a6539 fix: create card.updated.list activity when changing list 2025-05-27 22:09:12 +01:00
Henry
b07e473745 fix: reenable switching lists from list dropdown 2025-05-27 22:00:20 +01:00
Henry
59eadfc694 fix: soft delete labels 2025-05-27 21:49:01 +01:00
Henry
09211e60c7 fix: display correct colour for create new label text on dark mode 2025-05-27 21:31:33 +01:00
Henry
8a81d1ff17 feat: add workspace member assertion to remaining routers 2025-05-26 22:03:39 +01:00
Henry
76b65acc0f feat: add workspace member assertion to card router 2025-05-26 21:30:09 +01:00
Henry
a6c6524749 feat: add workspace member assertion to list router 2025-05-23 13:43:51 +01:00
Henry
2e97e2be53 feat: assert user is member of workspace for board routes 2025-05-22 22:12:33 +01:00
Henry
71c92eea27 fix: reenable member invites 2025-05-22 12:18:56 +01:00
Henry
82ec18e95e feat: add pino-pretty to dev logs 2025-05-20 23:11:45 +01:00
Henry
b3c4f0b956 fix: reenable member and label filters 2025-05-20 23:11:12 +01:00
Henry
4936d4215d fix: pass raw request body to stripe constructEvent 2025-05-20 21:37:04 +01:00
Henry
395c3acb81 feat: update s3 upload to use put 2025-05-16 23:27:32 +01:00
Henry
702af24a25 fix: enable internal connections on dokploy 2025-05-16 10:55:29 +01:00
Henry
bb9877c899 feat: add pino logging to next 2025-05-15 22:56:01 +01:00
Henry
d065ae73e1 refactor: remove edge compatibility 2025-05-15 22:27:03 +01:00
Henry
66de4b6c42 chore: flatten migrations 2025-05-15 21:53:33 +01:00
Henry
321e0b6837 fix: update container port 2025-05-15 12:47:20 +01:00
Henry
68a5c270a6 fix: init auth for auth api route 2025-05-08 15:36:11 +01:00
Henry
d602daa8e5 fix: remove github workflows 2025-05-08 13:20:30 +01:00
Henry
d17e60efc5 fix: remove image tag 2025-05-08 13:15:22 +01:00
Henry
cbb5b0e06a feat: update docker compose 2025-05-08 13:13:09 +01:00
Henry
7cfaee2511 Update docker-image.yml 2025-05-08 13:05:17 +01:00
Henry
e5aacfb5dd Create docker-image.yml 2025-05-08 12:56:24 +01:00
Henry
59d93a5a99 perf: init auth client 2025-05-08 12:25:56 +01:00
Henry
9bee003d2b perf: test edge speed 2025-05-08 12:12:46 +01:00
Henry
a5399fcd46 feat: dockerfile 2025-05-07 23:35:13 +01:00
Henry
0918320102 fix: set valid domain in next config 2025-05-06 15:10:46 +01:00
Henry
9c9107e5eb fix: uncomment better auth plugins 2025-05-06 13:50:26 +01:00
Henry
5d46eb4c6b fix: prevent empty trusted origin from throwing an error 2025-05-06 13:46:23 +01:00
Henry
284c00002f fix: add trusted domains to better auth 2025-05-06 11:17:37 +01:00
Henry
414af74dc1 fix: set better auth baseUrl 2025-05-06 10:38:35 +01:00
Henry
69004df19e fix: missing secret env 2025-05-06 09:54:06 +01:00
Henry
731136facf fix: pnpm lock 2025-05-06 09:45:26 +01:00
Henry
65eca3ebbb feat: s3 avatar uploads 2025-05-06 09:22:25 +01:00
Henry
8ae7eeb1fe fix: fix build 2025-05-05 15:19:09 +01:00
Henry
b6daaa41de refactor: remove supabase auth 2025-05-05 15:16:30 +01:00
Henry
091fea6f38 feat: setup better-auth 2025-05-02 23:40:34 +01:00
Henry
5996ad19c3 refactor: move shift index into transaction 2025-05-02 12:27:14 +01:00
Henry
73d8799c75 chore: remove unused repo func 2025-04-30 00:03:47 +01:00
Henry
88110b0b8f refactor: move reorder card to update handler 2025-04-30 00:01:45 +01:00
Henry
d99e95826f refactor: move reorder lists to update list 2025-04-29 08:58:07 +01:00
Henry
d40760d2ab fix: cleanup drizzle refactor 2025-04-24 22:53:22 +01:00
Henry
0c3ab8b890 refactor: update card repo to use drizzle 2025-04-24 22:36:29 +01:00
Henry
520a376b0f refactor: update board repo to use drizzle 2025-04-24 21:47:36 +01:00
Henry
d83e9d4dc5 refactor: update repo funcs to drizzle 2025-04-24 14:07:41 +01:00
Henry
c1348cf455 refactor: reinstate drizzle 2025-04-23 13:11:00 +01:00
Henry
0c25fe3864 chore: move policies to drizzle 2025-04-17 17:22:53 +01:00
Henry
297408c1fe feat: update doc links 2025-04-17 17:20:28 +01:00
Henry
2bd4051fa5 fix: missing light mode colours for delete workspace modal 2025-04-17 14:16:37 +01:00
Henry
e9211464d6 feat: create label from new card modal 2025-04-17 14:09:56 +01:00
Henry
fa16c7ccbd fix: prevent filters from refreshing on selection 2025-04-09 09:07:58 +01:00
Henry
e4d3eb137e fix: update openapi meta 2025-04-08 13:25:27 +01:00
Henry
fb8ba09a32 fix: update openstatus url 2025-04-08 13:24:45 +01:00
Henry
f1600793b2 feat: restrict inviting and removing users to workspace admins 2025-03-24 19:10:47 +00:00
Henry
05c4de153e feat: enhance layout for larger viewports 2025-03-24 14:44:47 +00:00
Henry
89e0a1ef27 feat: allow only workspace admins to update board visibility 2025-03-24 14:43:56 +00:00
Henry
5476668eb3 fix: various tweaks 2025-03-05 19:40:51 +00:00
Henry
a55a5c0a57 chore: add made with to readme 2025-03-05 19:38:06 +00:00
Henry
13c36a1770 chore: add header and quick links to readme 2025-03-05 19:15:11 +00:00
Henry
0e4bdbe0d0 chore: add header image to README 2025-03-05 18:53:49 +00:00
Henry
57c811683a feat: add discord server 2025-03-04 11:12:30 +00:00
Henry
418735724b fix: prevent provider error for public boards 2025-02-26 22:32:13 +00:00
Henry
f3fb71fe50 feat: redirect to boards on login 2025-02-26 22:22:01 +00:00
Henry
00ee2a9a89 feat: terms page 2025-02-26 22:10:44 +00:00
Henry
597565d488 feat: add page title to home and privacy 2025-02-24 21:46:24 +00:00
Henry
08a736b210 feat: privacy policy 2025-02-24 21:36:03 +00:00
Henry
3608ec0e31 feat: empty boards and lists state 2025-02-21 09:10:27 +00:00
Henry
cf31c50f46 feat: delete comments 2025-02-20 21:45:32 +00:00
Henry
95aa23056b feat: responsive hero image 2025-02-19 21:26:46 +00:00
Henry
169275d9ca feat: add cta to landing page 2025-02-19 21:22:42 +00:00
Henry
d4d3e41384 feat: setup faqs 2025-02-19 08:30:42 +00:00
Henry
333d713114 feat: landing page enhancements 2025-02-17 21:24:12 +00:00
Henry
c5d35f9985 fix: remove drizzle from CreateContextOptions 2025-02-15 18:08:09 +00:00
Henry
a89c381171 feat: responsive auth page 2025-02-15 18:00:29 +00:00
Henry
85f6dd1ff8 feat: responsive landing page 2025-02-15 15:46:40 +00:00
Henry
3a2942886d fix: set currentListPublicId and currentIndex as optional params for card.reorder 2025-02-07 17:50:55 +00:00
Henry
3eebb7485d feat: reintroduce drizzle connection 2025-02-07 17:41:42 +00:00
Henry
328a3064bb perf: improve optimistic queries 2025-02-07 17:35:06 +00:00
Henry
fe45c31795 perf: improve board optimistic updates 2025-02-06 22:45:55 +00:00
Henry
b1fa8a529d perf: improve optimistic updates 2025-02-05 22:22:36 +00:00
Henry
3ba600bc41 fix: allow assigning cards to any member in a user's workspace 2025-02-03 22:27:28 +00:00
Henry
eba5d9335c feat: optimisic card updates 2025-02-02 10:33:32 +00:00
Henry
14b4d73fb6 feat: prevent list dropdown from closing after selection 2025-02-01 22:06:59 +00:00
Henry
ee45d692c2 feat: prevent member dropdown from closing after selection 2025-02-01 21:46:49 +00:00
Henry
adeeab13a4 feat: prevent label dropdown from closing after selection 2025-02-01 16:01:30 +00:00
Henry
2882a3da2a feat: inline setting input fields with buttons 2025-02-01 11:51:21 +00:00
Henry
e321aa2f5d feat: update display name 2025-02-01 11:37:16 +00:00
Henry
bee45aab3f fix: show selected labels on new card form 2025-02-01 11:06:52 +00:00
Henry
4e68b47d98 fix: reduce avatar size 2025-02-01 10:44:50 +00:00
Henry
c4c60916f7 fix: multiple labels should wrap on cards 2025-02-01 10:43:31 +00:00
Henry
e421b4326c feat: display formatted email if member has not set display name 2025-01-31 16:47:45 +00:00
Henry
7db21596f1 feat: show loading spinner when creating or updating a label 2025-01-30 22:26:20 +00:00
Henry
0f26e7615b fix: create stripe customer when inviting new team member 2025-01-30 22:20:13 +00:00
Henry
d27b6e6184 fix: public cards show all labels when filtered 2025-01-30 21:39:49 +00:00
Henry
818c7bffb9 fix: prevent filters from hiding other labels or members on cards 2025-01-30 09:24:45 +00:00
Henry
e4311cfc1d feat: order lists by index 2025-01-29 22:18:03 +00:00
Henry
2ad67c2511 fix: export source files from shared 2025-01-29 22:00:09 +00:00
Henry
80067767ce feat: create and assign labels on trello import 2025-01-29 18:00:44 +00:00
Henry
34f8ad4def chore: rename utils package to shared 2025-01-29 16:21:30 +00:00
Henry
8addf952a9 feat: feedback form 2025-01-29 12:09:40 +00:00
Henry
0521ad5e87 fix: highlight custom URLs 2025-01-23 22:23:55 +00:00
Henry
f78dd07c38 feat: custom workspace slugs 2025-01-23 22:22:29 +00:00
Henry
0bd23d24ac feat: display profile picture 2025-01-22 13:35:44 +00:00
Henry
ef8f42a460 feat: profile picture 2025-01-21 22:41:04 +00:00
Henry
a162624eb8 fix: update roadmap link 2025-01-18 22:47:06 +00:00
Henry
be57292c5e feat: set board visibility 2025-01-18 22:41:28 +00:00
Henry
3270038dbc fix: alter RLS policies 2025-01-18 21:36:39 +00:00
Henry
cec69565df feat: board visibility 2025-01-17 17:41:35 +00:00
Henry
3d34b6bfcf feat: public card modal 2025-01-17 15:35:50 +00:00
Henry
67339af6ca fix: build error 2025-01-12 09:49:30 +00:00
Henry
f5217764e1 fix: correct generateSlug input args 2025-01-12 09:42:17 +00:00
Henry
691056550a fix: generate board slug for imports 2025-01-12 09:40:17 +00:00
Henry
765db90570 fix: generate board slug for new boards 2025-01-12 09:32:31 +00:00
Henry
2df54f03bb feat: public boards 2025-01-11 15:22:49 +00:00
Henry
d98df1d2c6 feat: board slugs 2025-01-05 16:21:11 +00:00
Henry
fa9be9d280 feat: workspace description 2025-01-05 14:27:19 +00:00
Henry
f1b0860734 feat: public workspace 2025-01-04 18:25:05 +00:00
Henry
34cabb5f16 fix: enable droppable in strict mode 2025-01-03 17:26:20 +00:00
Henry
3ee0c0039c feat: billing portal 2025-01-03 15:12:31 +00:00
Henry
67e714b8b0 feat: premium workspace usernames 2025-01-02 20:40:29 +00:00
Henry
b5dc9433db feat: update workspace slug 2025-01-01 14:42:16 +00:00
Henry
d05655ac9e chore: fix lockfile 2024-12-30 15:24:49 +00:00
Henry
f97b93c786 fix: reinstigate sendEmail 2024-12-30 15:22:21 +00:00
Henry
8189d4cf39 feat: update docs 2024-12-28 23:20:24 +00:00
Henry
fa0b7cf539 feat: setup docs 2024-12-28 12:15:45 +00:00
Henry
0aca729737 fix: prevent lottie from ssr 2024-12-13 08:32:21 +00:00
Henry
4e0ab2d00f fix: add @kan/db package to web 2024-12-12 14:40:23 +00:00
Henry
0c8d17dce5 feat: monorepo 2024-12-12 14:34:10 +00:00
Henry
b8eed7a90c feat: landing page footer 2024-12-03 21:24:44 +00:00
Henry
be184efee3 feat: features breakdown 2024-12-02 21:21:15 +00:00
Henry
24702f5e86 feat: fixed header 2024-11-27 23:12:07 +00:00
Henry
b47c0d5963 fix: adjust monthly pricing 2024-11-27 21:52:48 +00:00
Henry
5e7ae01c89 feat: pricing breakdown 2024-11-27 21:41:12 +00:00
Henry
1fd7900f24 feat: hero image theme 2024-11-25 22:59:25 +00:00
Henry
8984790b5e feat: landing page hero 2024-11-25 22:39:14 +00:00
Henry
88e4b4ebb0 feat: setup landing page 2024-11-25 22:04:39 +00:00
Henry
ff4c4863ad fix: dashboard component filepath 2024-11-22 16:54:52 +00:00
Henry
db80839f98 feat: dropdown enhancements 2024-11-20 22:35:20 +00:00
Henry
da061430b3 fix: types 2024-11-20 18:23:17 +00:00
Henry
ac993b448a feat: dynamic arrow direction for card list movement based on index 2024-11-20 18:21:55 +00:00
Henry
b7c88e844b feat: rollback card/list if duplicate index 2024-11-20 18:18:57 +00:00
Henry
ce9a1f5148 feat: update comments 2024-11-19 23:33:41 +00:00
Henry
d7074814ae feat: dynamic page title 2024-11-19 18:14:37 +00:00
Henry
6bd5a68a76 fix: prevent create another from throwing an error 2024-11-19 17:49:37 +00:00
Henry
87e4ff143c fix: removing labels 2024-11-18 22:06:57 +00:00
Henry
a5734cd86c feat: card comments 2024-11-18 21:34:07 +00:00
Henry
e5927cd205 feat: display card activity 2024-11-12 22:30:25 +00:00
Henry
a25c7e8ba6 feat: fetch activities for a given card 2024-11-07 08:44:46 +00:00
Henry
85b9cfcb21 feat: setup card activity 2024-11-06 22:20:41 +00:00
Henry
cdaa991098 chore: update openapi meta tags 2024-11-03 21:29:04 +00:00
Henry
23a35a831d fix: downgrade next 2024-11-03 21:10:56 +00:00
Henry
d1e27b5b83 feat: openapi enhancements 2024-11-03 21:05:58 +00:00
Henry
82b682b400 fix: set trpc server version 2024-11-01 08:51:01 +00:00
Henry
d5eece3ab8 feat: setup openapi compliant rest api 2024-11-01 08:23:39 +00:00
Henry
8a7613382f bug: update list names when switching boards 2024-10-31 13:25:03 +00:00
Henry
6a0add079f feat: members skeleton loader 2024-10-30 22:14:00 +00:00
Henry
4759d460ac feat: remove members 2024-10-29 13:17:44 +00:00
Henry
f9997a0645 feat: accept workspace member invite 2024-10-25 21:18:59 +01:00
Henry
508d35a0cb fix: magic login url 2024-10-18 07:41:18 +01:00
Henry
00e04f4001 fix: magic login url 2024-10-18 07:40:28 +01:00
Henry
5bd03e83f6 chore: fix build 2024-10-17 22:09:19 +01:00
Henry
c979b2bb19 chore: fix types 2024-10-17 21:39:42 +01:00
Henry
936e554e93 feat: invite members 2024-10-17 21:37:51 +01:00
Henry
9291ad183d fix: fix types 2024-09-27 15:55:13 +01:00
Henry
1e0918fee4 fix: secondary button text colour on light mode 2024-09-27 15:49:38 +01:00
Henry
6ddee3f1ef fix: button text colour on light mode 2024-09-27 15:48:26 +01:00
Henry
5306bb310d feat: clear all filters 2024-09-27 15:42:02 +01:00
Henry
50b08e2e85 fix: fix height of header 2024-09-27 15:01:25 +01:00
Henry
f1dfe8fb00 fix: persist filters when refetching boards 2024-09-27 14:27:17 +01:00
Henry
028239d63b feat: card filters 2024-09-24 22:49:44 +01:00
Henry
233e19276e feat: update workspace name 2024-09-17 21:54:03 +01:00
Henry
a06500a949 feat: delete workspace 2024-08-30 22:28:07 +01:00
Henry
5c8a1c5069 feat: delete labels 2024-08-28 21:18:20 +01:00
Henry
677981bfef fix: remove whitespace 2024-08-28 10:58:07 +01:00
Henry
74283a96f8 chore: fix types 2024-08-28 10:55:43 +01:00
Henry
583256a46b feat: update label 2024-08-27 23:34:52 +01:00
Henry
2adcac85a4 feat: setup RLS 2024-08-27 21:26:13 +01:00
Henry
f53549ecdd chore: fix types 2024-08-24 07:28:25 +01:00
Henry
8c6e50ed2f chore: remove formik 2024-08-24 07:25:37 +01:00
Henry
015020185f chore: fix types 2024-08-15 23:15:03 +01:00
Henry
f5a3d5089d chore: cleanup components 2024-08-15 23:14:21 +01:00
Henry
31128119ac chore: update modal design 2024-08-15 21:38:10 +01:00
Henry
918acf8b99 fix: tweak form spacing and refactor to react hook form 2024-08-13 23:19:38 +01:00
Henry
04c6576228 fix: ensure correct label colour when creating a new label 2024-08-13 23:13:38 +01:00
Henry
ccdbd9fa14 feat: add description on create new card 2024-08-13 22:57:04 +01:00
Henry
18de6f03d8 feat: create new list immediately adds to the board 2024-08-13 14:53:21 +01:00
Henry
1e3a903211 feat: create new card immediately displays new card 2024-08-13 14:26:24 +01:00
Henry
ff5802b953 bug: enable creating a new card with mulitple labels 2024-08-12 21:29:20 +01:00
Henry
250d23d2ae feat: add popup error messages 2024-08-07 22:27:12 +01:00
Henry
ee5f418360 chore: fix type errors 2024-07-30 22:41:59 +01:00
Henry
2033f37e72 feat: setup basic error handling on server 2024-07-30 22:36:39 +01:00
Henry
9931a585a5 feat: select position when creating a new card 2024-07-26 09:59:48 +01:00
Henry
d334607a9c chore: clean up type errors and warnings 2024-07-24 16:05:40 +01:00
Henry
bfd23406dd chore: move interactions to repo directory 2024-07-24 15:50:39 +01:00
Henry
e50991f72f chore: user router types 2024-07-24 15:39:14 +01:00
Henry
eff5654492 fix: move board header behind modal background 2024-06-10 10:21:56 +01:00
Henry
0994fc8baf fix: remove redirect when authenticated 2024-05-07 22:58:02 +01:00
Henry
3cf20cfb38 feat: google oauth 2024-05-07 22:33:36 +01:00
Henry
23faa66d48 feat: extend skeleton loaders 2024-05-05 15:12:20 +01:00
Henry
8da27d3484 feat: skeleton loaders 2024-05-04 18:22:57 +01:00
Henry
12c4943f90 feat: create user on initial signup 2024-04-29 19:30:43 +01:00
Henry
9581006463 fix: set magic link redirect from env 2024-04-28 16:59:01 +01:00
Henry
0035aa6500 feat: switch TRPC router to edge 2024-04-27 17:02:02 +01:00
Henry
d15870118d feat: switch next auth and drizzle to supabase (#1)
* feat: add supabase

* feat: setup auth

* feat: test refactoring board queries

* feat: convert to pages router

* feat: convert board router to supabase

* feat: swap out drizzle for supabase in label, list and workspace routers

* feat: swap out drizzle for supabase on the import router

* feat: swap drizzle for supabase on create new card

* feat: switch card router to use supabase

* feat: finish swapping drizzle for supabase

* chore: lint all router files

* fix: signout

* chore: fix types
2024-04-23 11:57:34 +01:00
481 changed files with 100113 additions and 34851 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
.env
docker-compose.override.yml
Dockerfile
./**/*/Dockerfile
.dockerignore
node_modules
./**/*/node_modules
pnpm-debug.log
./**/*/pnpm-debug.log
README.md
.next
.git

View File

@@ -1,26 +1,23 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
POSTGRES_URL=
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
EMAIL_FROM=
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
# Drizzle
# Get the Database URL from the "prisma" dropdown selector in PlanetScale.
# Change the query params at the end of the URL to "?ssl={"rejectUnauthorized":true}"
DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?ssl={"rejectUnauthorized":true}'
S3_REGION=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
# Next Auth
# You can generate a new secret on the command line with:
# openssl rand -base64 32
# https://next-auth.js.org/configuration/options#secret
# NEXTAUTH_SECRET=""
NEXTAUTH_URL="http://localhost:3000"
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
BETTER_AUTH_TRUSTED_ORIGINS=
# Next Auth Discord Provider
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

View File

@@ -1,36 +0,0 @@
/** @type {import("eslint").Linter.Config} */
const config = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
plugins: ["@typescript-eslint"],
extends: [
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
],
rules: {
// These opinionated rules are enabled in stylistic-type-checked above.
// Feel free to reconfigure them to your own preference.
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-misused-promises": [
2,
{
checksVoidReturn: { attributes: false },
},
],
},
};
module.exports = config;

97
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,97 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
schedule:
- cron: "21 21 * * *"
push:
branches: ["main"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
with:
cosign-release: "v2.2.4"
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: apps/web/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

30
.gitignore vendored
View File

@@ -1,24 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
**/node_modules/
/.pnp
node_modules
.pnp
.pnp.js
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
coverage
# next.js
/.next/
/out/
.next/
out/
next-env.d.ts
# nitro
.nitro/
.output/
# production
/build
build
# misc
.DS_Store
@@ -31,7 +31,6 @@ yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
@@ -39,4 +38,11 @@ yarn-error.log*
.vercel
# typescript
*.tsbuildinfo
dist/
.cache
# turbo
.turbo
# docker
docker-compose.override.yml

4
.npmrc Normal file
View File

@@ -0,0 +1,4 @@
node-linker=hoisted
link-workspace-packages=true
shamefully-hoist=true
hoist=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.18

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
"bradlc.vscode-tailwindcss"
]
}

17
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"cwd": "${workspaceFolder}/apps/nextjs",
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"sourceMapPathOverrides": {
"/turbopack/[project]/*": "${webRoot}/*"
}
}
]
}

28
.vscode/settings.json vendored
View File

@@ -1,3 +1,29 @@
{
"spellright.addToSystemDictionary": true
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript,typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true,
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
"eslint.runtime": "node",
"eslint.workingDirectories": [
{ "pattern": "apps/*/" },
{ "pattern": "packages/*/" },
{ "pattern": "tooling/*/" }
],
"prettier.ignorePath": ".gitignore",
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"tailwindCSS.experimental.configFile": "./tooling/tailwind/web.ts",
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.preferences.autoImportFileExcludePatterns": [
"next/router.d.ts",
"next/dist/client/router.d.ts"
],
"typescript.tsdk": "node_modules/typescript/lib"
}

52
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,52 @@
# Contributing to Kan
Thank you for your interest in contributing to Kan! This document provides guidelines and instructions for contributing to the project.
## Getting Started
1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/kan.git`
3. Create a new branch: `git checkout -b feat/your-feature-name`
4. Install dependencies: `pnpm install`
5. Copy `.env.example` to `.env` and configure your environment variables
6. Make your changes
7. Commit your changes: `git commit -m "feat: description of changes"`
8. Push to your fork: `git push origin feat/your-feature-name`
9. Open a Pull Request
## Development Guidelines
### Code Style
- Follow the existing code style
- Use meaningful variable and function names
- Add comments for complex logic
- Keep functions focused and concise
### Commits
- Use clear and descriptive commit messages
- Reference issue numbers when applicable
- Keep commits focused on single changes
### Pull Requests
- Provide a clear description of the changes
- Include screenshots for UI changes
- Keep PRs focused on a single feature/fix
## Need Help?
- Join our [Discord server](https://discord.gg/e6ejRb6CmT) for questions
- Check existing issues and pull requests
- Email [henry@kan.bn](mailto:henry@kan.bn) for major concerns
## Code of Conduct
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
We aim to foster an inclusive and welcoming community. Harassment and abusive behavior will not be tolerated.
## License
By contributing to Kan, you agree that your contributions will be licensed under the AGPLv3 License.

143
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

112
README.md
View File

@@ -1,3 +1,111 @@
# Kan
![github-background](https://github.com/user-attachments/assets/f728f52e-bf67-4357-9ba2-c24c437488e3)
The open source Trello alternative.
<div align="center">
<h3 align="center">Kan</h3>
<p>The open-source project management alternative to Trello.</p>
</div>
<p align="center">
<a href="https://kan.bn/kan/roadmap">Roadmap</a>
·
<a href="https://kan.bn">Website</a>
·
<a href="https://docs.kan.bn">Docs</a>
·
<a href="https://discord.gg/e6ejRb6CmT">Discord</a>
</p>
<div align="center">
<a href="https://github.com/kanbn/kan/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPLv3-purple"></a>
</div>
## Features 💫
- 👁️ **Board Visibility**: Control who can view and edit your boards
- 🤝 **Workspace Members**: Invite members and collaborate with your team
- 🚀 **Trello Imports**: Easily import your Trello boards
- 🔍 **Labels & Filters**: Organise and find cards quickly
- 💬 **Comments**: Discuss and collaborate with your team
- 📝 **Activity Log**: Track all card changes with detailed activity history
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Screenshot 👁️
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
## Made With 🛠️
- [Next.js](https://nextjs.org/?ref=kan.bn)
- [tRPC](https://trpc.io/?ref=kan.bn)
- [Better Auth](https://better-auth.com/?ref=kan.bn)
- [Tailwind CSS](https://tailwindcss.com/?ref=kan.bn)
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
- [React Email](https://react.email/?ref=kan.bn)
## Local Development 🧑‍💻
1. Clone the repository (or fork)
```bash
git clone https://github.com/kanbn/kan.git
```
2. Install dependencies
```bash
pnpm install
```
3. Copy `.env.example` to `.env` and configure your environment variables
4. Migrate database
```bash
pnpm db:migrate
```
5. Start the development server
```bash
pnpm dev
```
## Environment Variables 🔐
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | Yes | `465` |
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `storage.kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
See `.env.example` for a complete list of supported environment variables.
## Contributing 🤝
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE).
## Contact 📧
For support or to get in touch, please email [henry@kan.bn](mailto:henry@kan.bn) or join our [Discord server](https://discord.gg/e6ejRb6CmT).

32
apps/docs/README.md Normal file
View File

@@ -0,0 +1,32 @@
# Mintlify Starter Kit
Click on `Use this template` to copy the Mintlify starter kit. The starter kit contains examples including
- Guide pages
- Navigation
- Customizations
- API Reference pages
- Use of popular components
### Development
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
```
npm i -g mintlify
```
Run the following command at the root of your documentation (where mint.json is)
```
mintlify dev
```
### Publishing Changes
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
#### Troubleshooting
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`

View File

@@ -0,0 +1,33 @@
---
title: "Introduction"
description: "Learn how to integrate with Kan's REST API."
---
## Base URL
```
https://kan.bn/api/v1
```
## Authentication
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
```
'x-api-key': kan_123456789
```
## Response codes
Kan uses standard HTTP codes to indicate the success or failure of your requests.
In general, `2xx` HTTP codes correspond to success, `4xx` codes are for user-related failures, and `5xx` codes are for infrastructure issues.
| Status | Description |
| ------ | ------------------------------- |
| `200` | Success |
| `400` | Invalid parameters |
| `401` | Missing token |
| `403` | Invalid token |
| `404` | Resource not found |
| `500` | Error with Kan's infrastructure |

BIN
apps/docs/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

View File

@@ -0,0 +1,42 @@
---
title: Trello
description: "Import your Trello boards to Kan."
---
## Getting your Trello API key and token
Before you can import your Trello boards, you need to get your Trello API key and token. Follow these steps (only takes a couple of minutes):
1. **Sign in to your Trello account**
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
2. **Create a new integration**
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
- **Name**: Choose a name for your integration (e.g. "Kan Import")
- **Workspace**: Select the workspace containing the boards you want to import
- **Email**: Enter your email address
- **Support email**: Enter your email address
- **Author**: Add your name
3. **Get a Trello API Key**
Now click **Generate a new API key** to create an API key.
4. **Generate a Trello Token**
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
## Importing your Trello boards
Once you have your API key and token, importing your Trello boards is easy. Follow these steps:
1. **Create a new import in Kan**
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
2. **Select Trello as the Import Source**
Choose **Trello** from the list of import options and click **Select source**.
3. **Enter Your Trello API Key and Token**
Paste your API key and token into the provided fields and click **Fetch boards** (don't worry, we don't store your key or token).
4. **Start the Import**
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.

View File

@@ -0,0 +1,32 @@
---
title: Introduction
description: "Kan is the open source alternative to Trello."
---
<Frame>
<img
className="block rounded-lg border border-gray-200 dark:hidden"
src="/images/hero-light.png"
alt="Hero Light"
/>
<img
className="hidden rounded-lg dark:block"
src="/images/hero-dark.png"
alt="Hero Dark"
/>
</Frame>
## Getting Started
Get started with Kan by choosing your preferred deployment option below.
<CardGroup cols={2}>
<Card title="Cloud" icon="cloud" href="https://kan.bn/signup">
Get started instantly with our hosted solution - we'll handle all the
hosting, scaling, and maintenance for you.
</Card>
<Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
For full data ownership and control, you can self-host Kan on your
infrastructure.
</Card>
</CardGroup>

5
apps/docs/logo/dark.svg Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 3.6.14 -->
<svg width="2400" height="900" viewBox="0 0 2400 900" xmlns="http://www.w3.org/2000/svg">
<text id="kanbn" xml:space="preserve" x="239" y="681" font-family="Plus Jakarta Sans" font-size="598" font-weight="800" fill="#ededed" letter-spacing="-29.9">kan.bn</text>
</svg>

After

Width:  |  Height:  |  Size: 356 B

5
apps/docs/logo/light.svg Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 3.6.14 -->
<svg width="2400" height="900" viewBox="0 0 2400 900" xmlns="http://www.w3.org/2000/svg">
<text id="kanbn" xml:space="preserve" x="239" y="681" font-family="Plus Jakarta Sans" font-size="598" font-weight="800" fill="#161616" letter-spacing="-29.9">kan.bn</text>
</svg>

After

Width:  |  Height:  |  Size: 356 B

60
apps/docs/mint.json Normal file
View File

@@ -0,0 +1,60 @@
{
"$schema": "https://mintlify.com/schema.json",
"name": "kan.bn",
"logo": {
"dark": "/logo/dark.svg",
"light": "/logo/light.svg",
"href": "https://kan.bn"
},
"favicon": "./favicon.png",
"colors": {
"primary": "#000000",
"light": "#EDEDED",
"dark": "#161616",
"anchors": {
"from": "#000000",
"to": "#161616"
}
},
"topbarLinks": [
{
"name": "Sign in",
"url": "https://kan.bn/login"
}
],
"topbarCtaButton": {
"name": "Go to app",
"url": "https://kan.bn/boards"
},
"anchors": [
{
"name": "API Reference",
"openapi": "https://kan.bn/api/v1/openapi.json",
"url": "api-reference",
"icon": "square-terminal"
},
{
"name": "GitHub",
"icon": "github",
"url": "https://github.com/kanbn"
}
],
"navigation": [
{
"group": "Get Started",
"pages": ["introduction"]
},
{
"group": "Import",
"pages": ["imports/trello"]
},
{
"group": "API Documentation",
"pages": ["api-reference/introduction"]
}
],
"footerSocials": {
"discord": "https://discord.gg/e6ejRb6CmT",
"github": "https://github.com/kanbn"
}
}

19
apps/docs/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "@kan/docs",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env mint build",
"clean": "git clean -xdf .cache .turbo node_modules",
"dev": "pnpm with-env mintlify dev --port 3001",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"mintlify": "^3.0.0"
},
"prettier": "@kan/prettier-config"
}

79
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,79 @@
ARG NODE_VERSION=20
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# 1. Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update
RUN apk add --no-cache libc6-compat python3 make g++
# Setup pnpm and turbo on the alpine base
FROM alpine AS base
RUN corepack enable
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN npm install turbo@2.1.3 --global
RUN npm install turbo@2.3.1 --global
RUN pnpm config set store-dir ~/.pnpm-store
# 2. Prune projects
FROM base AS pruner
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
# RUN export VERSION=$(npm run version)
ARG PROJECT
# Set working directory
WORKDIR /app
# It might be the path to <ROOT> turborepo
COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
RUN turbo prune --scope=${PROJECT} --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
# Environment to skip .env validation on build
ENV CI=true
WORKDIR /app
# Copy lockfile and package.json's of isolated subworkspace
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
# First install the dependencies (as they change less often)
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
RUN pnpm build --filter=${PROJECT}
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
WORKDIR /app
ENV NODE_ENV=production
COPY --chown=nextjs:nodejs --from=builder /app/ ./
WORKDIR /app/apps/${APP_DIRNAME}
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
CMD ["pnpm", "start"]

14
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,14 @@
import baseConfig, { restrictEnvAccess } from "@kan/eslint-config/base";
import nextjsConfig from "@kan/eslint-config/nextjs";
import reactConfig from "@kan/eslint-config/react";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [".next/**"],
},
...baseConfig,
...reactConfig,
...nextjsConfig,
...restrictEnvAccess,
];

36
apps/web/next.config.js Normal file
View File

@@ -0,0 +1,36 @@
import { fileURLToPath } from "url";
import createJiti from "jiti";
import { env } from "next-runtime-env";
import { configureRuntimeEnv } from "next-runtime-env/build/configure.js";
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
createJiti(fileURLToPath(import.meta.url))("./src/env");
configureRuntimeEnv();
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@kan/api",
"@kan/db",
"@kan/shared",
"@kan/auth",
"@kan/stripe",
],
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
images: {
domains: [env("NEXT_PUBLIC_STORAGE_DOMAIN") ?? ""],
},
experimental: {
instrumentationHook: true,
},
};
export default config;

72
apps/web/package.json Normal file
View File

@@ -0,0 +1,72 @@
{
"name": "@kan/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev | pino-pretty",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4",
"@kan/api": "workspace:*",
"@kan/auth": "workspace:*",
"@kan/db": "workspace:^",
"@kan/shared": "workspace:^",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"next": "^14.2.15",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1",
"react-contenteditable": "^3.3.7",
"react-dom": "catalog:react18",
"react-hook-form": "^7.51.1",
"react-icons": "^4.12.0",
"react-lottie-player": "^1.5.5",
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"zod": "catalog:"
},
"devDependencies": {
"@kan/eslint-config": "workspace:*",
"@kan/prettier-config": "workspace:*",
"@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.17.7",
"@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7",
"@types/react-dom": "catalog:react18",
"dotenv-cli": "^7.4.4",
"eslint": "catalog:",
"jiti": "^1.21.6",
"pino-pretty": "^13.0.0",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
},
};

1
apps/web/public/__ENV.js Normal file
View File

@@ -0,0 +1 @@
window.__ENV = {"NEXT_PUBLIC_UMAMI_ID":"aaed1f55-25e2-4223-b918-e429c390be35","NEXT_PUBLIC_KAN_ENV":"cloud","NEXT_PUBLIC_BASE_URL":"http://localhost:3000","NEXT_PUBLIC_AVATAR_BUCKET_NAME":"avatars","NEXT_PUBLIC_STORAGE_DOMAIN":"storage.kanbn.com","NEXT_PUBLIC_STORAGE_URL":"https://storage.kanbn.com"};

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { FaGoogle } from "react-icons/fa";
import { z } from "zod";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
interface FormValues {
email: string;
}
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
}
const EmailSchema = z.object({ email: z.string().email() });
export function Auth({ setIsMagicLinkSent }: AuthProps) {
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
useState(false);
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(EmailSchema),
});
const handleLoginWithEmail = async (email: string) => {
setIsLoginWithEmailPending(true);
setLoginError(null);
const { error } = await authClient.signIn.magicLink({
email,
callbackURL: "/boards",
});
setIsLoginWithEmailPending(false);
if (error) {
setLoginError(
"Something went wrong, please try again later or contact customer support.",
);
} else {
setIsMagicLinkSent(true, email);
}
};
const handleLoginWithGoogle = async () => {
setIsLoginWithGooglePending(true);
setLoginError(null);
const { error } = await authClient.signIn.social({
provider: "google",
callbackURL: "/boards",
});
setIsLoginWithGooglePending(false);
if (error) {
setLoginError("Failed to login with Google. Please try again.");
}
};
const onSubmit = async (values: FormValues) => {
await handleLoginWithEmail(values.email);
};
return (
<div className="space-y-6">
<div>
<Button
onClick={handleLoginWithGoogle}
isLoading={isLoginWithGooglePending}
iconLeft={<FaGoogle />}
fullWidth
size="lg"
>
Continue with Google
</Button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">or</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
<Input
{...register("email", { required: true })}
placeholder="Enter your email address"
/>
{errors.email && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid email address
</p>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
<div className="mt-[1.5rem]">
<Button
isLoading={isLoginWithEmailPending}
fullWidth
size="lg"
variant="secondary"
>
Continue with email
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import Image from "next/image";
import { twMerge } from "tailwind-merge";
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
const sizeMap = {
xs: 20,
sm: 24,
md: 36,
lg: 48,
} as const;
const Avatar = ({
size = "md",
name,
email,
icon,
imageUrl,
isLoading,
}: {
size?: "xs" | "sm" | "md" | "lg";
name: string;
email: string;
imageUrl?: string;
icon?: React.ReactNode;
isLoading?: boolean;
}) => {
const initials = name
? getInitialsFromName(name)
: inferInitialsFromEmail(email);
return (
<>
{imageUrl ? (
<Image
src={imageUrl}
className="rounded-full bg-gray-50"
width={sizeMap[size]}
height={sizeMap[size]}
alt=""
/>
) : (
<span
className={twMerge(
"inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400",
isLoading && "animate-pulse bg-light-200 dark:bg-dark-200",
size === "xs" && "h-5 w-5",
size === "sm" && "h-6 w-6",
size === "lg" && "h-12 w-12",
)}
>
{icon ? (
<span className="text-[12px] text-white">{icon}</span>
) : (
<span
className={twMerge(
"text-sm font-medium leading-none text-white",
size === "xs" && "text-[8px]",
size === "sm" && "text-[10px]",
size === "lg" && "text-md",
)}
>
{initials}
</span>
)}
</span>
)}
</>
);
};
export default Avatar;

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
const Badge = ({ value, iconLeft }: { value: string; iconLeft: ReactNode }) => (
<span className="mt-1 inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[10px] font-medium text-neutral-600 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800">
{iconLeft}
<div>{value}</div>
</span>
);
export default Badge;

View File

@@ -0,0 +1,90 @@
import Link from "next/link";
import { twMerge } from "tailwind-merge";
import LoadingSpinner from "./LoadingSpinner";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
href?: string;
fullWidth?: boolean;
openInNewTab?: boolean;
}
const Button = ({
children,
size = "md",
iconLeft,
iconRight,
isLoading,
variant = "primary",
href,
fullWidth,
openInNewTab,
...props
}: ButtonProps) => {
const classes = twMerge(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
size === "sm" && "text-xs",
size === "lg" && "py-[0.65rem]",
fullWidth && "w-full",
variant === "primary" &&
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
variant === "secondary" &&
"border-[1px] border-light-600 bg-light-50 text-light-1000 dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000",
variant === "danger" &&
"dark:text-red-1000 border-[1px] border-red-600 bg-red-500 dark:border-red-600 dark:bg-red-500",
variant === "ghost" &&
"bg-none text-light-1000 shadow-none hover:bg-light-300 dark:text-dark-1000 dark:hover:bg-dark-200",
props.disabled && "opacity-60",
);
const content = (
<span className="relative flex items-center justify-center">
{isLoading && (
<span className="absolute">
<LoadingSpinner size={size} />
</span>
)}
<div
className={twMerge(
"flex items-center",
isLoading ? "invisible" : "visible",
)}
>
{iconLeft && <span className="mr-2">{iconLeft}</span>}
{children}
{iconRight && <span className="ml-1">{iconRight}</span>}
</div>
</span>
);
if (href) {
return (
<Link
href={href}
className={classes}
target={openInNewTab ? "_blank" : undefined}
rel={openInNewTab ? "noopener noreferrer" : undefined}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{content}
</Link>
);
}
return (
<button
className={classes}
disabled={isLoading ?? props.disabled}
{...props}
>
{content}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,180 @@
import { Menu, Transition } from "@headlessui/react";
import { Fragment, useState } from "react";
import { HiEllipsisHorizontal, HiMiniPlus } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
interface Item {
key: string;
value: string;
selected: boolean;
leftIcon?: React.ReactNode;
}
interface Group {
key: string;
label: string;
icon: React.ReactNode;
items: Item[];
}
interface CheckboxDropdownProps {
children: React.ReactNode;
items?: Item[];
groups?: Group[];
createNewItemLabel?: string;
menuSpacing?: "sm" | "md" | "lg";
position?: "left" | "right";
handleSelect: (
groupKey: string | null,
item: { key: string; value: string },
) => void;
handleEdit?: (key: string) => void;
handleCreate?: () => void;
asChild?: boolean;
}
export default function CheckboxDropdown({
children,
items,
groups,
createNewItemLabel = "Create new",
menuSpacing = "sm",
position = "left",
handleSelect,
handleEdit,
handleCreate,
asChild = true,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const menuSpacingClass = {
sm: "top-[26px]",
md: "top-[32px]",
lg: "top-[38px]",
};
const renderMenuItems = (items: Item[], groupKey: string | null) => (
<>
{items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(groupKey, { key: item.key, value: item.value })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">{item.leftIcon}</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
{item.value}
</label>
{handleEdit && (
<button
className="invisible ml-auto group-hover:visible"
onClick={(event) => {
event.stopPropagation();
handleEdit(item.key);
}}
>
<HiEllipsisHorizontal size={20} className="text-dark-900" />
</button>
)}
</div>
</Menu.Item>
))}
{handleCreate && (
<button
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => handleCreate()}
>
<HiMiniPlus size={20} className="pr-1.5" />
{createNewItemLabel}
</button>
)}
</>
);
return (
<Menu
as="div"
className="relative flex w-full flex-wrap items-center text-left"
>
<>
<Menu.Button
as={asChild ? "div" : undefined}
className="h-full w-full cursor-pointer focus-visible:outline-none"
>
{children}
</Menu.Button>
<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"
afterLeave={() => setSelectedGroup(null)}
>
<Menu.Items
className={twMerge(
"mt-2s absolute z-50 w-56 origin-top-left rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200",
position === "left" ? "left-0" : "right-0",
menuSpacingClass[menuSpacing],
)}
>
<div className="max-h-[350px] overflow-y-auto p-1">
{!selectedGroup ? (
<>
{items && renderMenuItems(items, null)}
{groups?.map((group) => (
<Menu.Item key={group.key}>
<div
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
setSelectedGroup(group.key);
}}
>
<span className="mr-2 text-dark-900">{group.icon}</span>
<span className="pointer-events-none text-[12px] text-dark-900">
{group.label}
</span>
</div>
</Menu.Item>
))}
</>
) : (
<>
{groups?.find((g) => g.key === selectedGroup)?.items &&
renderMenuItems(
groups.find((g) => g.key === selectedGroup)?.items || [],
selectedGroup,
)}
</>
)}
</div>
</Menu.Items>
</Transition>
</>
</Menu>
);
}

View File

@@ -0,0 +1,40 @@
import Link from "next/link";
import { api } from "~/utils/api";
import FeedbackButton from "./FeedbackButton";
import SideNavigation from "./SideNavigation";
export default function Dashboard(props: { children: React.ReactNode }) {
const { data, isLoading } = api.user.getUser.useQuery();
return (
<>
<style jsx global>{`
html {
height: 100vh;
overflow: hidden;
}
`}</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>
<div className="flex h-full w-full">
<SideNavigation
user={{ email: data?.email, image: data?.image }}
isLoading={isLoading}
/>
<div className="w-full overflow-hidden">{props.children}</div>
</div>
</div>
</>
);
}

View File

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

View File

@@ -0,0 +1,51 @@
import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
export default function Dropdown({
items,
children,
disabled,
}: {
items: { label: string; action: () => void; icon?: React.ReactNode }[];
children: React.ReactNode;
disabled?: boolean;
}) {
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button
disabled={disabled}
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200"
>
{children}
</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 className="absolute right-0 z-30 mt-2 w-56 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">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label}>
<button
onClick={item.action}
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
>
{item.icon}
{item.label}
</button>
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

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

View File

@@ -0,0 +1,105 @@
import React, { forwardRef, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
contentEditable?: boolean;
prefix?: string;
iconRight?: React.ReactNode;
minHeight?: number;
value?: string;
errorMessage?: string;
className?: string;
onChange?: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
type?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
contentEditable,
errorMessage,
prefix,
value,
onChange,
onKeyDown,
iconRight,
className,
type = "text",
...props
},
ref,
) => {
const [showPassword, setShowPassword] = useState(false);
if (contentEditable) {
return (
<ContentEditable
placeholder={props.placeholder}
html={value ?? ""}
onChange={onChange}
onKeyDown={onKeyDown}
className={twMerge(
"block min-h-[70px] w-full cursor-text overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-1.5 text-light-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 focus-visible:outline-none dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6",
className && className,
)}
/>
);
}
return (
<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">
{prefix}
</div>
)}
<input
ref={ref}
value={value}
onChange={onChange}
type={type === "password" && showPassword ? "text" : type}
className={twMerge(
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
prefix && "rounded-l-none",
className && className,
)}
onKeyDown={onKeyDown}
{...props}
/>
{type === "password" && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
tabIndex={-1}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? (
<HiOutlineEyeSlash className="h-4 w-4" />
) : (
<HiOutlineEye className="h-4 w-4" />
)}
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{iconRight}
</div>
)}
</div>
{errorMessage && (
<div className="text-xs text-red-500">{errorMessage}</div>
)}
</div>
);
},
);
Input.displayName = "Input";
export default Input;

View File

@@ -0,0 +1,238 @@
import { Listbox, Transition } from "@headlessui/react";
import { Fragment, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
import { colours } from "@kan/shared/constants";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
interface LabelFormInput {
name: string;
colour: Colour;
isCreateAnotherEnabled?: boolean;
}
interface Colour {
name: string;
code: string;
}
export function LabelForm({
boardPublicId,
refetch,
isEdit,
}: {
boardPublicId: string;
refetch: () => void;
isEdit?: boolean;
}) {
const { closeModal, entityId, openModal } = useModal();
const label = api.label.byPublicId.useQuery(
{
labelPublicId: entityId,
},
{
enabled: isEdit && !!entityId,
},
);
const { control, register, reset, handleSubmit, setValue, watch } =
useForm<LabelFormInput>({
values: {
name: isEdit && label.data?.name ? label.data.name : "",
colour: (isEdit && label.data?.colourCode
? colours.find((c) => c.code === label.data?.colourCode)
: colours[0]) as Colour,
isCreateAnotherEnabled: false,
},
});
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const createLabel = api.label.create.useMutation({
onSuccess: () => {
const currentColourIndex = colours.findIndex(
(c) => c.code === watch("colour").code,
);
try {
refetch();
if (!isCreateAnotherEnabled) closeModal();
reset({
name: "",
colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
});
} catch (e) {
console.log(e);
}
},
});
const updateLabel = api.label.update.useMutation({
onSuccess: () => {
refetch();
closeModal();
reset({
name: "",
colour: colours[0],
});
},
});
const onSubmit = (values: LabelFormInput) => {
if (!values.colour.code) return;
if (isEdit) {
updateLabel.mutate({
labelPublicId: label.data?.publicId ?? "",
name: values.name,
colourCode: values.colour.code,
});
} else {
createLabel.mutate({
name: values.name,
colourCode: values.colour.code,
boardPublicId,
});
}
};
useEffect(() => {
const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#label-name");
if (nameElement) nameElement.focus();
}, []);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">
{isEdit ? "Edit label" : "New label"}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 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="label-name"
placeholder="Name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<Controller
name="colour"
control={control}
render={({ field }) => (
<Listbox {...field}>
{({ open }) => (
<>
<div className="relative mt-4">
<Listbox.Button className="block w-full rounded-md border-0 bg-white/5 px-4 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
<span className="flex items-center">
<span
style={{ backgroundColor: field.value.code }}
className={`inline-block h-2 w-2 flex-shrink-0 rounded-full`}
/>
<span className="ml-3 block truncate">
{field.value.name}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<HiChevronUpDown
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-dark-300 sm:text-sm">
{colours.map((colour, index) => (
<Listbox.Option
key={`colours_${index}`}
className="relative cursor-default select-none px-2 text-neutral-900 dark:text-dark-1000"
value={colour}
>
{() => (
<>
<div className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-400">
<span
style={{ backgroundColor: colour.code }}
className="ml-2 inline-block h-2 w-2 flex-shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="ml-3 block truncate font-normal">
{colour.name}
</span>
</div>
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</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">
{!isEdit && (
<Toggle
label="Create another"
isChecked={!!isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
}
/>
)}
<div className="space-x-2">
{isEdit && (
<Button
variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)}
>
Delete
</Button>
)}
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
>
{isEdit ? "Update label" : "Create label"}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,12 @@
const LabelIcon = ({ colourCode }: { colourCode: string | null }) => (
<svg
fill={colourCode ?? "#3730a3"}
className="h-2 w-2"
viewBox="0 0 6 6"
aria-hidden="true"
>
<circle cx={3} cy={3} r={3} />
</svg>
);
export default LabelIcon;

View File

@@ -0,0 +1,36 @@
import { twMerge } from "tailwind-merge";
const LoadingSpinner = ({ size = "md" }: { size?: "sm" | "md" | "lg" }) => {
return (
<svg
className={twMerge(
"animate-spin",
size === "sm" && "h-3 w-3",
size === "md" && "h-4 w-4",
size === "lg" && "h-5 w-5",
)}
viewBox="0 0 100 100"
>
<circle
fill="none"
stroke-width="10"
className="stroke-current opacity-40"
cx="50"
cy="50"
r="40"
/>
<circle
fill="none"
stroke-width="10"
className="stroke-current"
stroke-dasharray="280"
stroke-dashoffset="210"
cx="50"
cy="50"
r="40"
/>
</svg>
);
};
export default LoadingSpinner;

View File

@@ -1,6 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import Lottie from "react-lottie-player";
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
type IconProps = {
isPlaying: boolean;

View File

@@ -0,0 +1,96 @@
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 { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
interface FormValues {
name: string;
}
export function NewWorkspaceForm() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { switchWorkspace } = useWorkspace();
const { register, handleSubmit } = useForm<FormValues>();
const createWorkspace = api.workspace.create.useMutation({
onSuccess: (values) => {
if (values.publicId && values.name) {
switchWorkspace({
publicId: values.publicId,
name: values.name,
description: values.description,
slug: values.slug,
plan: values.plan,
});
closeModal();
}
},
onError: () => {
showPopup({
header: "Unable to create workspace",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
});
useEffect(() => {
const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#workspace-name");
if (nameElement) nameElement.focus();
}, []);
const onSubmit = (values: FormValues) => {
createWorkspace.mutate({
name: values.name,
});
};
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">
New workspace
</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="workspace-name"
placeholder="Workspace name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createWorkspace.isPending}>
Create workspace
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,9 @@
import Head from "next/head";
export const PageHead = ({ title }: { title: string }) => {
return (
<Head>
<title>{title}</title>
</Head>
);
};

View File

@@ -0,0 +1,25 @@
const PatternedBackground = () => (
<div className="absolute inset-0 h-full w-full">
<svg className="h-full w-full">
<pattern
id="pattern"
x="0.034759358288862785"
y="3.335370511841166"
width="14.423223834988539"
height="14.423223834988539"
patternUnits="userSpaceOnUse"
patternTransform="translate(-0.45072574484339184,-0.45072574484339184)"
>
<circle
cx="0.45072574484339184"
cy="0.45072574484339184"
r="0.45072574484339184"
fill="#3e3e3e"
></circle>
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill="url(#pattern)"></rect>
</svg>
</div>
);
export default PatternedBackground;

View File

@@ -0,0 +1,89 @@
import { Transition } from "@headlessui/react";
import { useEffect } from "react";
import {
HiOutlineCheckCircle,
HiOutlineExclamationCircle,
HiXMark,
} from "react-icons/hi2";
import { usePopup } from "~/providers/popup";
const Popup: React.FC = () => {
const { isOpen, popupHeader, popupMessage, popupIcon, hidePopup } =
usePopup();
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => {
hidePopup();
}, 5000);
return () => clearTimeout(timer);
}
}, [isOpen, hidePopup]);
return (
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
>
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
<Transition
show={isOpen}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
{popupIcon === "success" && (
<HiOutlineCheckCircle
aria-hidden="true"
className="h-6 w-6 text-green-400"
/>
)}
{popupIcon === "error" && (
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-6 w-6 text-red-400"
/>
)}
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
{popupHeader}
</p>
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
{popupMessage}
</p>
</div>
<div className="ml-4 flex flex-shrink-0">
<button
type="button"
onClick={() => {
hidePopup();
}}
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
>
<span className="sr-only">Close</span>
<HiXMark
aria-hidden="true"
className="h-5 w-5 text-dark-900"
/>
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
);
};
export default Popup;

View File

@@ -1,9 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useState } from "react";
import LottieIcon from "~/app/components/LottieIcon";
import LottieIcon from "~/components/LottieIcon";
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
@@ -28,8 +26,8 @@ const Button: React.FC<{
href={href}
onMouseEnter={handleMouseEnter}
className={classNames(
current ? "bg-light-200 dark:bg-dark-200" : " dark:bg-dark-50",
"hover:bg-light-200 group flex items-center gap-x-3 rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 dark:text-dark-1000 dark:hover:bg-dark-200",
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",
)}
>
<LottieIcon index={index} json={json} isPlaying={isHovered} />

View File

@@ -0,0 +1,80 @@
import { useRouter } from "next/router";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
import membersIconDark from "~/assets/members-dark.json";
import membersIconLight from "~/assets/members-light.json";
import settingsIconDark from "~/assets/settings-dark.json";
import settingsIconLight from "~/assets/settings-light.json";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useTheme } from "~/providers/theme";
interface SideNavigationProps {
user: UserType;
isLoading: boolean;
}
interface UserType {
email?: string | null | undefined;
image?: string | null | undefined;
}
export default function SideNavigation({
user,
isLoading,
}: SideNavigationProps) {
const router = useRouter();
const { pathname } = router;
const { activeTheme } = useTheme();
const isDarkMode = activeTheme === "dark";
const navigation = [
{
name: "Boards",
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
},
{
name: "Members",
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
},
{
name: "Settings",
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
},
];
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">
<div>
<WorkspaceMenu />
<ul role="list" className="space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<ReactiveButton
href={item.href}
current={pathname.includes(item.href)}
name={item.name}
json={item.icon}
/>
</li>
))}
</ul>
</div>
<UserMenu
email={user.email ?? ""}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
/>
</nav>
</>
);
}

View File

@@ -0,0 +1,18 @@
import type { DroppableProps } from "react-beautiful-dnd";
import { useEffect, useState } from "react";
import { Droppable } from "react-beautiful-dnd";
export const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
return <Droppable {...props}>{children}</Droppable>;
};

View File

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

View File

@@ -0,0 +1,37 @@
import { Switch } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
const Toggle = ({
isChecked,
onChange,
label,
}: {
isChecked: boolean;
onChange: () => void;
label: string;
}) => (
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
<Switch
checked={isChecked}
onChange={onChange}
className={twMerge(
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
isChecked && "bg-indigo-600 dark:bg-indigo-600",
)}
>
<span className="sr-only">{label}</span>
<span
aria-hidden="true"
className={twMerge(
"pointer-events-none inline-block h-3 w-3 translate-x-0 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
isChecked && "translate-x-2",
)}
/>
</Switch>
</div>
);
export default Toggle;

View File

@@ -0,0 +1,144 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { authClient } from "@kan/auth/client";
import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
email: string;
isLoading: boolean;
}
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
export default function UserMenu({
imageUrl,
email,
isLoading,
}: UserMenuProps) {
const router = useRouter();
const { themePreference, switchTheme } = useTheme();
const handleLogout = async () => {
await authClient.signOut();
router.push("/login");
};
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="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>
) : (
<Menu.Button className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000">
{avatarUrl ? (
<Image
src={avatarUrl}
className="rounded-full bg-gray-50"
width={24}
height={24}
alt=""
/>
) : (
<span className="inline-block h-6 w-6 overflow-hidden rounded-full bg-light-400 dark:bg-dark-400">
<svg
className="h-full w-full text-dark-700"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
)}
<span className="mx-2 truncate text-sm">{email}</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 className="absolute bottom-[40px] left-0 z-10 mt-2 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
<div className="p-1">
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
<span>Theme</span>
</div>
<Menu.Item>
<button
onClick={() => switchTheme("system")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
<span
className={classNames(
themePreference === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
System
</button>
</Menu.Item>
<Menu.Item>
<button
onClick={() => switchTheme("dark")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
<span
className={classNames(
themePreference === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Dark
</button>
</Menu.Item>
<Menu.Item>
<button
onClick={() => switchTheme("light")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
<span
className={classNames(
themePreference === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Light
</button>
</Menu.Item>
</div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<button
onClick={handleLogout}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
Logout
</button>
</Menu.Item>
</div>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -1,30 +1,40 @@
"use client";
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { useModal } from "~/app/providers/modal";
import { useWorkspace } from "~/app/providers/workspace";
import { Fragment } from "react";
import { HiCheck } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
export default function WorkspaceMenu() {
const { workspace, availableWorkspaces, switchWorkspace } = useWorkspace();
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
return (
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div>
<Menu.Button className="hover:bg-light-200 mb-1 flex w-full items-center rounded-[5px] p-1.5 dark:hover:bg-dark-200">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-bold leading-none text-white">
{workspace?.name.charAt(0).toUpperCase()}
{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>
) : (
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-bold leading-none text-white">
{workspace.name.charAt(0).toUpperCase()}
</span>
</span>
</span>
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
{workspace?.name}
</span>
</Menu.Button>
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
{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">
Pro
</span>
)}
</Menu.Button>
)}
</div>
<Transition
@@ -36,26 +46,26 @@ export default function WorkspaceMenu() {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="bg-light-50 border-light-600 absolute left-0 z-10 w-full origin-top-left rounded-md border shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
<Menu.Items className="absolute left-0 z-10 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
<div className="p-1">
{availableWorkspaces.map((availableWorkspace) => (
<div key={availableWorkspace.publicId} className="flex">
<Menu.Item>
<button
onClick={() => switchWorkspace(availableWorkspace)}
className="hover:bg-light-200 flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 dark:text-dark-1000 dark:hover:bg-dark-400"
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
<div>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-medium leading-none text-white">
{availableWorkspace?.name.charAt(0).toUpperCase()}
{availableWorkspace.name.charAt(0).toUpperCase()}
</span>
</span>
<span className="ml-2 text-xs font-medium">
{availableWorkspace?.name}
{availableWorkspace.name}
</span>
</div>
{workspace?.name === availableWorkspace?.name && (
{workspace.name === availableWorkspace.name && (
<span>
<HiCheck className="h-4 w-4" aria-hidden="true" />
</span>
@@ -65,11 +75,11 @@ export default function WorkspaceMenu() {
</div>
))}
</div>
<div className="border-light-600 border-t-[1px] p-1 dark:border-dark-500">
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => openModal("NEW_WORKSPACE")}
className="hover:bg-light-200 flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 dark:text-dark-1000 dark:hover:bg-dark-400"
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
Create workspace
</button>

View File

@@ -1,17 +1,33 @@
"use client";
import { Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { useModal } from "~/app/providers/modal";
import { useModal } from "~/providers/modal";
interface Props {
children: React.ReactNode;
modalSize?: "sm" | "md" | "lg";
positionFromTop?: "sm" | "md" | "lg";
}
const Modal: React.FC<Props> = ({ children }) => {
const Modal: React.FC<Props> = ({
children,
modalSize = "sm",
positionFromTop = "md",
}) => {
const { isOpen, closeModal } = useModal();
const modalSizeMap = {
sm: "max-w-[400px]",
md: "max-w-[550px]",
lg: "max-w-[800px]",
};
const positionFromTopMap = {
sm: "mt-[12vh]",
md: "mt-[25vh]",
lg: "mt-[50vh]",
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
@@ -24,7 +40,7 @@ const Modal: React.FC<Props> = ({ children }) => {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="bg-light-50 fixed inset-0 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
<div className="fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
</Transition.Child>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
@@ -38,7 +54,9 @@ const Modal: React.FC<Props> = ({ children }) => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="shadow-3xl-light dark:shadow-3xl-dark bg-light-50 border-light-600 relative mt-[25vh] transform rounded-lg border px-4 pb-4 pt-5 text-left transition-all dark:border-dark-600 dark:bg-dark-100/20 dark:backdrop-blur-lg sm:mb-8 sm:w-full sm:max-w-sm sm:p-6">
<Dialog.Panel
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
>
{children}
</Dialog.Panel>
</Transition.Child>

59
apps/web/src/env.ts Normal file
View File

@@ -0,0 +1,59 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { vercel } from "@t3-oss/env-nextjs/presets";
import { z } from "zod";
export const env = createEnv({
extends: [vercel()],
shared: {
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
},
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_URL: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
POSTGRES_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
EMAIL_FROM: z.string(),
EMAIL_URL: z.string(),
EMAIL_TOKEN: z.string(),
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string(),
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
experimental__runtimeEnv: {
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
});

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
/**
* A hook that delays updating a value until a specified delay has passed
* @param value The value to debounce
* @param delay The delay in milliseconds
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number): [T] {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timeoutId);
};
}, [value, delay]);
return [debouncedValue];
}

View File

@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await require("pino");
await require("next-logger");
}
}

View File

@@ -0,0 +1,5 @@
import PublicBoardView from "~/views/public/board";
export default function PublicBoardsPage() {
return <PublicBoardView />;
}

View File

@@ -0,0 +1,5 @@
import PublicBoardsView from "~/views/public/boards";
export default function PublicBoardsPage() {
return <PublicBoardsView />;
}

View File

@@ -0,0 +1,55 @@
import "~/styles/globals.css";
import type { AppType } from "next/app";
import { Plus_Jakarta_Sans } from "next/font/google";
import { env } from "next-runtime-env";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "~/providers/theme";
import { api } from "~/utils/api";
const jakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
display: "swap",
});
export const metadata = {
title: "Kan",
description: "The open source Trello alternative",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<>
<style jsx global>{`
html {
font-family: ${jakarta.style.fontFamily};
}
body {
position: relative;
}
`}</style>
{env("NEXT_PUBLIC_UMAMI_ID") && (
<script
defer
src="https://cloud.umami.is/script.js"
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
/>
)}
<script src="/__ENV.js" />
<main className="font-sans">
<ThemeProvider>
<ModalProvider>
<PopupProvider>
<Component {...pageProps} />
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</main>
</>
);
};
export default api.withTRPC(MyApp);

View File

@@ -0,0 +1,10 @@
import { toNodeHandler } from "better-auth/node";
import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
export const config = { api: { bodyParser: false } };
const auth = initAuth(createDrizzleClient());
export default toNodeHandler(auth.handler);

View File

@@ -0,0 +1,34 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating portal session" });
}
}

View File

@@ -0,0 +1,91 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env";
import { z } from "zod";
import { createNextApiContext } from "@kan/api/trpc";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createStripeClient } from "@kan/stripe";
const workspaceSlugSchema = z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest {
successUrl: string;
cancelUrl: string;
slug: string;
workspacePublicId: string;
stripeCustomerId: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const body = req.body as CheckoutSessionRequest;
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
if (!successUrl || !cancelUrl || !slug || !workspacePublicId) {
return res.status(400).json({ error: "Missing required fields" });
}
const slugResult = workspaceSlugSchema.safeParse(slug);
if (!slugResult.success) {
return new Response(JSON.stringify({ error: "Invalid workspace slug" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
const isMemberOfWorkspace = workspace.some(
({ workspace }) => workspace.publicId === body.workspacePublicId,
);
if (!isMemberOfWorkspace) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: process.env.STRIPE_PRO_PLAN_PRICE_ID,
quantity: 1,
},
],
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
customer: user.stripeCustomerId ?? undefined,
metadata: {
workspaceSlug: slug,
workspacePublicId,
},
});
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating checkout session" });
}
}

View File

@@ -0,0 +1,73 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createStripeClient } from "@kan/stripe";
async function buffer(readable: Readable) {
const chunks = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
return res.status(400).json({ message: "No signature found" });
}
try {
const buf = await buffer(req);
const rawBody = buf.toString("utf8");
const event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
const { db } = await createNextApiContext(req);
switch (event.type) {
case "checkout.session.completed": {
const checkoutSession = event.data.object;
const metaData = checkoutSession.metadata;
if (metaData?.workspacePublicId && metaData.workspaceSlug) {
await workspaceRepo.update(db, metaData.workspacePublicId, {
slug: metaData.workspaceSlug,
plan: "pro",
});
}
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return res.status(200).json({ received: true });
} catch (err) {
console.error("Webhook error:", err);
return res.status(400).json({ message: "Webhook handler failed" });
}
}
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -0,0 +1,27 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter } from "@kan/api/root";
import { createTRPCContext } from "@kan/api/trpc";
const nextApiHandler = createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "OPTIONS") {
res.writeHead(200);
return res.end();
}
return nextApiHandler(req, res);
}

View File

@@ -0,0 +1,66 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { filename, contentType } = req.body as {
filename: string;
contentType: string;
};
// Specific to avatar uploads for now
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
if (!filenameRegex.test(filename)) {
return res.status(400).json({ error: "Invalid filename" });
}
if (
typeof contentType !== "string" ||
!allowedContentTypes.includes(contentType)
) {
return res.status(400).json({ error: "Invalid content type" });
}
const client = new S3Client({
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
},
});
const signedUrl = await getSignedUrl(
// @ts-ignore
client,
new PutObjectCommand({
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
Key: filename,
}),
);
return res.status(200).json({ url: signedUrl, key: filename });
} catch (error) {
return res.status(500).json({ error: (error as Error).message });
}
}

View File

@@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from "next";
import cors from "nextjs-cors";
import { createOpenApiNextHandler } from "trpc-to-openapi";
import { appRouter } from "@kan/api";
import { createRESTContext } from "@kan/api/trpc";
import { env } from "~/env";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
await cors(req, res);
const openApiHandler = createOpenApiNextHandler({
router: appRouter,
createContext: createRESTContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
return await openApiHandler(req, res);
}

View File

@@ -0,0 +1,9 @@
import { type NextApiRequest, type NextApiResponse } from "next";
import { openApiDocument } from "@kan/api/openapi";
const handler = (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};
export default handler;

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import BoardView from "~/views/board";
export default function BoardPage() {
return (
<WorkspaceProvider>
<Dashboard>
<BoardView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import BoardsView from "~/views/boards";
export default function BoardsPage() {
return (
<WorkspaceProvider>
<Dashboard>
<BoardsView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,15 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/Dashboard";
import Popup from "~/components/Popup";
import CardView from "~/views/card";
export default function CardPage() {
return (
<WorkspaceProvider>
<Dashboard>
<CardView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -0,0 +1,5 @@
import HomeView from "~/views/home";
export default function Home() {
return <HomeView />;
}

View File

@@ -0,0 +1,5 @@
import LoginView from "~/views/auth/login";
export default function LoginPage() {
return <LoginView />;
}

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