Compare commits

...

350 Commits

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

* feat: scaffold checklist router

* feat: add create new checklist modal

* feat: create new checklist item

* feat: toggle checklist item completed state

* feat: update checklist name

* feat: delete checklist

* feat: show checklists progress on cards

* feat: tweak light mode styling

* feat: focus item form on creation of new checklist

* feat: tweak new checklist item form styling

* feat: add checklist activity

* feat: show checklist progress on public board page

* feat: display checklist items on public card modal

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

* chore: tiptap mention package

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

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

* feat: add view only badge to public boards

* feat: prevent card title from overlapping close button

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

* feat: show not found message for public boards

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

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

* feat: setup dashboard 2.0

* fix: prevent patterned background from overlaying buttons

* feat: set max width for main card view

* feat: adjust colour scheme for card view

* feat: adjust dashboard border colour scheme and radius

* fix: use correct colour for description placeholder

* fix: use getLayout to persist layout state across pages

* feat: reduce max width for settings and members pages

* feat: add feedback button and modal

* feat: adjust page max width

* feat: extend mobile layout support

* fix: hide pro badge when collapsed

* feat: tweak nav styling for light mode

* feat: tweak skeleton loaders for board list

* feat: extend list max height

* fix: hide side nav right border on desktop

* chore: update hero images

* chore: update hero images for docs

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

* feat: add edit workspace url button

* feat: add loading state to update board slug button

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

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

* fix: display theme background and set dynamic viewport height

* fix: prevent zooming on ios

* fix: enable scroll on card page
2025-07-03 09:12:06 +01:00
LovelessCodes
ec1b5d5acb feat: basic mobile support (#64)
* feat: add mobile navigation with responsive layout and theme controls

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

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

* chore: improve mobile UI styling

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

* fix: standardize layout containers and improve mobile navigation styling

* feat: update card view layout

* feat: add responsive layout to board view

* feat: readjust boards page layout

* feat: enhance table view on mobile for members page

* feat: extend mobile support with sliding side panels

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

* feat: close panels when clicking outside

* fix: adjust font-sizing for import source

---------

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

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

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

---------

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

* Fix hydration and locale issues

* Add missing translations and tweak selector styling

* Extend language support

* Update lang support

* Extend lang support

* Fix build and add dyanmic imports
2025-06-25 21:30:24 +01:00
Henry
014fdbb7d8 feat: render URLs as links in card description (#93) 2025-06-21 23:15:07 +01:00
Henry
500fe8af95 feat: make SMTP auth optional and add SMTP_SECURE config (#92)
* feat: add SMTP_SECURE option to email config

* feat: allow unauthenticated SMTP servers
2025-06-19 22:58:13 +01:00
Henry
e7383ec670 feat: add debug logs to sendEmail (#91) 2025-06-19 21:55:01 +01:00
Henry
5586c26c12 fix: use default output mode for next 2025-06-18 23:18:12 +01:00
Henry
2d7097eb83 chore: cleanup test dockerfile 2025-06-18 23:11:29 +01:00
Henry
2aa898da00 feat: enable starting in standalone mode 2025-06-18 22:57:12 +01:00
Henry
a01a826bac fix: build with env vars 2025-06-18 22:49:09 +01:00
Henry
a29ea76024 fix: update build context in compose 2025-06-18 22:34:13 +01:00
Henry
e1c6bfd6cd fix: add test compose file for cloud 2025-06-18 22:27:46 +01:00
Henry
c272c38f6b feat: reduce docker image size by using next standalone output (#89) 2025-06-18 22:16:50 +01:00
khalt00
02d3d300fb change volume name (#88)
Co-authored-by: Le Trong Kha <kha.le@ao360.com.vn>
2025-06-17 23:20:15 +01:00
Henry
0bd28f9f95 fix: set container name env var 2025-06-17 23:02:24 +01:00
Henry
d0c2314ebf feat: add posthog analytics 2025-06-17 22:45:06 +01:00
Henry
268871c348 fix: bump compose 2025-06-17 11:00:28 +01:00
Henry
2e11199764 fix: add old docker compose and revert entrypoint 2025-06-17 10:57:54 +01:00
Henry
fc6bb22400 fix: copy static files and use explicit shell in Docker (#86) (#87)
* fix: copy static files to standalone dir

* fix: update Dockerfile to use /bin/sh for entrypoint execution

* fix: update Dockerfile to copy static files from apps/web directory

* fix: copy from root

* fix: set workdir

* fix: copy files from inside the container

* fix: add logs to debug

* fix: remove workdir

* fix: log contents of build

* fix: copy static files

* fix: remove log

* chore: remove spacing
2025-06-16 22:50:21 +01:00
Henry
1c75a948f9 feat: setup postgres with docker compose (#85)
* feat: add postgres to docker compose

* feat: run migrations via entrypoint

* chore: tidy .env.example

* fix: allow empty strings for optional envs

* fix: remove external from kan-network

* chore: update readme
2025-06-14 22:52:27 +01:00
Henry
3a48c797b7 feat: set web port with env variable 2025-06-13 22:03:57 +01:00
Henry
b838e8040a fix: remove version from docker compose (#81) 2025-06-13 21:43:05 +01:00
Henry
f0e3c4c3e2 fix: render comments inside contenteditable (#79) 2025-06-13 09:23:55 +01:00
Henry
d6b0b5cd80 Revert "chore: optimize Docker build with multi-stage process and add standalone Next.js output (#75)"s
This reverts commit 1707557bea.
2025-06-12 22:26:59 +01:00
LovelessCodes
1707557bea chore: optimize Docker build with multi-stage process and add standalone Next.js output (#75) 2025-06-12 22:12:24 +01:00
Henry
f6114a56af chore: patch migration order (#74) 2025-06-10 21:15:47 +01:00
LovelessCodes
c1d0bb3c93 fix: make onChange prop optional in Editor and rename markdown prop to content (#72) 2025-06-10 19:26:38 +01:00
LovelessCodes
09ae4292ac refactor: replace router.push with permanentRedirect for disabled sig… (#73)
* refactor: replace router.push with permanentRedirect for disabled signup flow

* feat: replace signup redirect with custom disabled signup UI
2025-06-10 19:25:48 +01:00
LovelessCodes
75e89f118f feat: implement Trello integration with OAuth and user fields (#48)
* feat: implement Trello integration with OAuth and user fields

* feat: migrate Trello integration to new integrations table

* refactor: migrate Trello integration to use new integration system

* refactor: migrate Trello integration to use new integrations table

* fix: add loading check for Trello integration and update VSCode settings

* feat: enhance import boards UI with dynamic integration providers and local storage config

* feat: add select/unselect all buttons and loading state to board import form

* feat: add Trello import env vars on README

* docs: update Trello import guide to use OAuth flow instead of API keys

* feat: add integrations table for OAuth token management

* feat: connect trello from import modal

* refactor: consolidate Trello integration endpoints into unified integration router

* fix: trello import again

* refactor: generalize integration authorization flow and improve error handling

* refactor: update Trello API endpoints and tags for better organization

* feat: add Integrations tag to OpenAPI specification

* refactor: update Trello auth endpoint to use generic integration provider param

* refactor: move Trello integration logic from trello.ts to import.ts router

* refactor: consolidate Trello integration endpoints and fix API URL

* chore: remove debug logs

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-10 19:24:18 +01:00
LovelessCodes
dd6cdfc208 refactor: simplify auth configuration by using NEXT_PUBLIC_BASE_URL instead of BETTER_AUTH_URL (#66) 2025-06-09 21:37:53 +01:00
Ahmed Semih Erkan
7758bd77ea fix: add dark prefix to custom scrollbar styles (#63) 2025-06-09 21:33:08 +01:00
LovelessCodes
294ed2ffc2 feat: enhance board visibility controls with dropdown menu and success notifications (#65)
* feat: enhance board visibility controls with dropdown menu and success notifications

* feat: tweak design

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-09 21:29:52 +01:00
LovelessCodes
e2786f31b0 chore: remove runtime env file and add to ignore (#59) 2025-06-09 13:49:57 +01:00
LovelessCodes
bfc69996c6 Feat: Markdown editor for description on cards (#43)
* feat: integrate MDXEditor for card description with markdown support

* feat: replace textarea with markdown editor for card descriptions and improve styling

* refactor: replace MDXEditor with Tiptap for rich text editing

* fix: prevent menu item from taking focus and submitting

* feat: replace editor menu with bubble menu and add placeholder text

* feat: implement slash commands menu in editor with suggestion API

* chore: update editor styling

* chore: update editor placeholder text

* fix: navigate command list by keyboard and tweak styling

* fix: prevent editor onBlur from submitting NewCardForm

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-09 13:47:00 +01:00
LovelessCodes
96f2835bab Feat: allow email password sign in/up for selfhosters (#54)
* feat: add password reset functionality with email template and credentials config

* feat: add authentication configuration options and improve development setup

* feat: add password-based authentication and signup control flags

* feat: update auth form to support name field

* fix: prevent password icon from overlaying the input text

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-08 21:25:37 +01:00
LovelessCodes
53426bf155 feat: add account deletion functionality with cascade delete for work… (#33)
* feat: add account deletion functionality with cascade delete for workspace relations

* fix: add cascade delete to workspace user foreign key constraints

* fix: change onDelete action to set null for user references in schema

* feat: tweak design

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
2025-06-08 20:54:47 +01:00
LovelessCodes
17c2a1b8ec refactor: add relation names to schema (#56) 2025-06-08 16:32:44 +01:00
Henry
d203f834cc fix: close self hosting docs loop (#58) 2025-06-08 16:15:21 +01:00
Henry
75ad0d8f98 chore: set trusted origins to required in readme 2025-06-08 16:11:36 +01:00
Henry
4066e6bba7 chore: add postgres setup to docs 2025-06-08 10:58:32 +01:00
Henry
8aa4aa2a7c chore: update self hosting docs in readme 2025-06-08 00:17:58 +01:00
Henry
43164a0d8a chore: fix image tags 2025-06-08 00:05:28 +01:00
Henry
8474601b2e chore: prevent sig from taking the latest tag 2025-06-07 23:46:10 +01:00
Henry
d768b1e6ba chore: update dockerfile to run migration on start 2025-06-07 22:59:26 +01:00
Mathieu Andrade
6a7f2f148d fix: prevent list buttons from stacking (#49)
* Fix: Improve list header

* fix: prevent list buttons from stacking

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-07 21:50:30 +01:00
Henry
cbc90c3002 feat: multi-arch docker builds with latest tag 2025-06-07 14:24:47 +01:00
LovelessCodes
efe83ec8a9 refactor: replace js-cookie auth with new auth client and remove unused packages from web package (#50) 2025-06-07 12:44:32 +01:00
Henry
d1d37840fc chore: tweak HN logo 2025-06-06 14:03:35 +01:00
Henry
4135e316f4 feat: add hacker news link to landing page 2025-06-06 13:43:34 +01:00
LovelessCodes
805ef8cf43 refactor: update board slug prefix to use dynamic base URL from env (#38)
* refactor: update board slug prefix to use dynamic base URL from env

* feat: add board slug availability check with real-time validation UI

* refactor: consolidate board slug lookup by removing boardSlug repo and updating environment variables

* feat: allow board slug reuse across different workspaces

* chore: update environment settings and remove unused imports

* refactor: update board slug availability check to use boardPublicId instead of workspaceSlug

* refactor: simplify board slug availability check with direct SQL query
2025-06-06 12:55:00 +01:00
LovelessCodes
e590de80c0 chore: update local development configuration (#45) 2025-06-06 12:52:13 +01:00
LovelessCodes
f64549f743 feat: add not found states for board and card views with conditional … (#41)
* feat: add not found states for board and card views with conditional rendering

* refactor: move out of nested ternary
2025-06-05 23:40:37 +01:00
LovelessCodes
ce2ef1b1d4 feat: add S3 avatar upload during user creation from social provider (#28)
* feat: add S3 avatar upload during user creation from social provider

* chore: remove console.log and update storage config for local development

* refactor: move avatar upload to after user creation and use user ID for filename

* feat: update avatar storage path
2025-06-05 23:29:56 +01:00
LovelessCodes
56b0161c6f refactor: move form reset logic to onSuccess and improve error handli… (#40)
* refactor: move form reset logic to onSuccess and improve error handling in NewCardForm

* feat: disable create card button when title is empty or form is submitting
2025-06-05 23:21:04 +01:00
LovelessCodes
314053fa95 feat: add support for 15 new OAuth providers including Apple, Microsoft, and TikTok (#34)
* feat: add support for 15 new OAuth providers including Apple, Microsoft, and TikTok

* chore: remove unused OAuth providers from docs
2025-06-05 23:15:00 +01:00
LovelessCodes
f41736c1aa feat: implement auth session check and redirect on login page (#39) 2025-06-05 23:06:11 +01:00
LovelessCodes
e53efe80c5 feat: add user authentication check for activity list self-identifica… (#36)
* feat: add user authentication check for activity list self-identification

* refactor: replace api.user with authClient for session management in ActivityList
2025-06-05 22:42:09 +01:00
LovelessCodes
2043a8f98c fix: add cascade delete for card-related foreign keys and invalidate … (#31)
* fix: add cascade delete for card-related foreign keys and invalidate workspace cache after deletion

* fix: use refetch instead of invalidate for workspace deletion to ensure immediate UI update

* feat: add cascade delete to card relations and activity tables
2025-06-05 21:20:31 +01:00
Henry
116fefcaf3 chore: add contributors to readme 2025-06-05 13:18:25 +01:00
Lonneke
f88bd82044 fix: handle external URLs in avatar rendering (#23) (#32)
* fix: issue 23, handle external URLs in avatar rendering

* fix: add google to the remote patterns

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-06-05 13:10:38 +01:00
LovelessCodes
6eb17529f1 feat(auth): added discord & github authentication methods (#18)
* feat(auth): added discord & github authentication methods

* chore(format): formatted edited files

* docs: add Discord and GitHub OAuth environment variables to README

* feat: add Discord and GitHub OAuth client credentials to environment config

* refactor: migrate social provider types to better-auth package

* feat: return configured social provider names from auth endpoint

* refactor: simplify socialProvidersPlugin by removing nested function and condensing return
2025-06-05 12:00:07 +01:00
Henry
4b7f58aeeb fix: missing s3 env variables 2025-06-05 10:54:36 +01:00
LovelessCodes
86a01074b9 refactor: remove unused EMAIL_ environment variables (#24)
* refactor: remove unused EMAIL_TOKEN environment variable

* refactor: remove unused EMAIL_URL environment variable

* chore: remove unused EMAIL_URL environment variable from docker compose

* chore: add SMTP environment variables
2025-06-05 10:41:08 +01:00
LovelessCodes
c9a9779e79 feat: include column name in board repository query response to use in board PageHead (#20) 2025-06-05 06:08:20 +01:00
LovelessCodes
356e35964b feat: allow public card access without authentication (#19) 2025-06-05 06:05:03 +01:00
Henry
1698b744e4 chore: fix labels on issue templates 2025-06-05 05:41:21 +01:00
Henry
27c0c0a5e8 chore: update issue templates 2025-06-05 05:37:31 +01:00
Henry
2129a60d22 chore: setup github issue templates 2025-06-05 05:22:22 +01:00
Henry
84dd9a9b6d feat: support SMTP via nodemailer (#17) 2025-06-04 12:33:24 +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
547 changed files with 134351 additions and 34989 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,78 @@
# 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`.
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
# 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.
# Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
# When adding additional environment variables, the schema in "/src/env.mjs"
# should be updated accordingly.
# Fill if you want to use an external database
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
# 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}'
# Only required if deploying from compose
POSTGRES_PASSWORD=
# 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"
# SMTP (optional)
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_ISSUER=
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
KICK_CLIENT_ID=
KICK_CLIENT_SECRET=
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
DROPBOX_CLIENT_ID=
DROPBOX_CLIENT_SECRET=
VK_CLIENT_ID=
VK_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
ROBLOX_CLIENT_ID=
ROBLOX_CLIENT_SECRET=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
TIKTOK_CLIENT_ID=
TIKTOK_CLIENT_SECRET=
TIKTOK_CLIENT_KEY=
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=
# Next Auth Discord Provider
DISCORD_CLIENT_ID=""
DISCORD_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;

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,37 @@
---
name: "\U0001F41B Bug Report"
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
## 🐛 Bug Report
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. macOS, Windows, Linux]
- Browser [e.g. chrome, safari]
- Node version [e.g. 20]
- App version/commit [if applicable]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,30 @@
---
name: "✨ Feature Request"
about: Suggest an idea for this project
title: ''
labels: feature
assignees: ''
---
## ✨ Feature Request
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
**Implementation ideas (optional)**
If you have any ideas about how this feature could be implemented, please share them here.
**Would you like to work on this feature?**
- [ ] Yes, I'd like to help implement this feature
- [ ] No, I'm just suggesting the feature

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

@@ -0,0 +1,105 @@
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 }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
# 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' }}
platforms: linux/amd64,linux/arm64
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}

37
.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,18 @@ yarn-error.log*
.vercel
# typescript
*.tsbuildinfo
dist/
.cache
# turbo
.turbo
# docker
docker-compose.override.yml
# runtime env
__ENV.js
i18n.cache
# pgdata
/apps/web/pgdata

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/web",
"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"
}

121
CHANGELOG.md Normal file
View File

@@ -0,0 +1,121 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.4...HEAD)
## [0.2.4](https://github.com/kanbn/kan/compare/v0.2.3...v0.2.4) - 2025-01-14
### Added
- Button to view and update board public URL directly from board page
- Redirect from root to login page for self-hosted instances
### Changed
- Updated all label router endpoints to return consistent structure
- Added verbose error logging to magic link invitation process
### Fixed
- Updated tRPC dependencies and added OpenAPI meta to providers endpoint
## [0.2.3](https://github.com/kanbn/kan/compare/v0.2.2...v0.2.3) - 2025-07-07
### Added
- Mobile support with responsive navigation and layout
- Template selection when creating new boards with predefined lists and labels
- S3_FORCE_PATH_STYLE configuration for MinIO compatibility
### Changed
- Improved mobile UI styling and layout containers
- Enhanced iOS mobile support with viewport fixes and zoom prevention
- Improved card view layout for mobile devices
### Fixed
- Mobile dashboard min-width issues
- Dynamic viewport height on mobile devices
- Scroll functionality on card pages
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28
### Added
- Localization and multi-language support
- URLs are now rendered as clickable links in card descriptions
### Changed
- Improved email configuration with optional SMTP authentication
## [0.2.1](https://github.com/kanbn/kan/compare/v0.2.0...v0.2.1) - 2025-06-19
### Added
- PostHog analytics integration
- Debug logging for email functionality
- Standalone mode support for Next.js
- Environment variable for configuring web port
- Docker Compose setup with PostgreSQL
### Changed
- SMTP authentication is now optional
- Added `SMTP_SECURE` configuration option
- Improved Docker build process with better context handling
### Fixed
- Default output mode for Next.js builds
- Container environment variable configuration
- Docker Compose volume naming
- Build context and static file copying in Docker
- Comment rendering inside contenteditable elements
## [0.2.0](https://github.com/kanbn/kan/compare/v0.1.0...v0.2.0) - 2025-06-10
### Added
- Trello integration with OAuth authentication and user field mapping
- Board visibility controls with dropdown menu and success notifications
- Markdown editor for card descriptions
- Email/password authentication for self-hosters
- Account deletion functionality with cascade delete for workspaces
- Enhanced scrollbar styles for dark mode
### Changed
- Simplified auth configuration to use `NEXT_PUBLIC_BASE_URL` instead of `BETTER_AUTH_URL`
- Updated self-hosting documentation
- Improved database schema with relation names
### Fixed
- List buttons stacking issue
- Editor onChange prop is now optional
- Renamed markdown prop to content in Editor component
- Router navigation for disabled signup redirects
## [0.1.0](https://github.com/kanbn/kan/releases/tag/v0.1.0) - 2025-06-02
### Added
- Initial release of Kan
- Core project management features
- Board, list, and card management
- User authentication system
- Workspace collaboration
- Comment system
- Activity logging
- Docker deployment support
- Self-hosting capabilities
### Changed
- Updated license link in footer

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>.

192
README.md
View File

@@ -1,3 +1,191 @@
# 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)
## Self Hosting 🐳
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
1. Create a new file called `docker-compose.yml` and paste the following configuration:
```yaml
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
networks:
- kan-network
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
volumes:
kan_postgres_data:
```
2. Start the containers in detached mode:
```bash
docker compose up -d
```
3. Access Kan at http://localhost:3000
The application will be running in the background. You can manage the containers using these commands:
- To stop the containers: `docker compose down`
- To view logs: `docker compose logs -f`
- To restart the containers: `docker compose restart`
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
## 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 | To use external database | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
See `.env.example` for a complete list of supported environment variables.
## Contributing 🤝
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
## Contributors 👥
<a href="https://github.com/kanbn/kan/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## 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: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -0,0 +1,33 @@
---
title: Trello
description: "Import your Trello boards to Kan."
---
## Authorizing Kan.bn for Trello
Before you can import your Trello boards, you need to authorize Kan.bn for Trello. Follow these steps (only takes a couple of minutes):
1. **Authorize Kan.bn for Trello**
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**.
2. **Log in to your Trello account**
You will be redirected to Trello to log in to your Trello account.
3. **Authorize Kan.bn for Trello**
Approve the permissions and you will be redirected back to Kan.bn.
4. **Verify the connection**
You should see a **Disconnect Trello** button.
## Importing your Trello boards
Once you have authorized Kan.bn for Trello, 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. **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,36 @@
---
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?tab=readme-ov-file#self-hosting-"
>
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"
}

84
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,84 @@
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} --scope=@kan/db --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}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 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 ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]

23
apps/web/entrypoint.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/sh
# Run migrations from the Turborepo root context
echo "Running database migrations..."
# Navigate to the Turborepo root to run the db:migrate command
pnpm --prefix /app db:migrate
# Check if migration was successful
if [ $? -ne 0 ]; then
echo "\nDatabase migration failed! Exiting."
exit 1
fi
echo "\nStarting Next.js application..."
# Check if we should use standalone mode
if [ "$NEXT_PUBLIC_USE_STANDALONE_OUTPUT" = "true" ]; then
echo "Starting in standalone mode..."
exec node .next/standalone/apps/web/server.js
else
echo "Starting in standard mode..."
exec pnpm start
fi

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,
];

13
apps/web/i18n.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 0,
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl"]
},
"buckets": {
"po": {
"include": ["src/locales/[locale]/messages.po"]
}
},
"$schema": "https://lingo.dev/schema/i18n.json"
}

437
apps/web/i18n.lock Normal file
View File

@@ -0,0 +1,437 @@
version: 1
checksums:
8033ed03a24d7c4898c90a5365297c20:
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
Activity/singular: 1948763de8e531483a798b68195e297e
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
Add%20an%20item.../singular: f8e7993980420698700b07f56b3a38af
Add%20checklist/singular: 05d73200b56cfea75fb3a0f94c9bfb74
Add%20description...%20(type%20'%2F'%20to%20open%20commands%20or%20'%40'%20to%20mention)/singular: c6eb35a796dd7ac0e3dbc66244b71248
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
Add%20label/singular: 0be732d46df263265935fda342097a08
Add%20member/singular: 11979625770516ca287e929381778e02
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
Approval/singular: 2f5426445800ecd5a3102aac940d459c
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
assigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
Board/singular: 101ff39aab674c033c15ab978d0d8bac
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
Board%20URL%20must%20be%20at%20least%203%20characters%20long/singular: 105f5b4f3b606baf7b5302117022bd68
Board%20visibility/singular: b4205f3058297833a626156057c24b26
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
Boards/singular: 8fff51c5872e051beb9b718336665e80
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
Bug/singular: 4509fffdb5931f8905063c80cf802d71
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
Comments/singular: 502237cd1044491c48494aa60967a985
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
Contact/singular: 9afa39bc47019ee6dec6c74b6273967c
Contact%20us/singular: 694a45bc5c96f6f3a27873c1cf5a7707
Content%20Creation/singular: 4e6be22ade0188f408471325fe363575
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
Create%20card/singular: 32792935dd5837a9433909b04021c22b
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
Create%20list/singular: 858c3d514aa95765ec82114393199144
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
Critical/singular: eb327cd411b50aee954f8d1d215d003a
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
Custom%20URLs%20are%20a%20premium%20feature.%20You'll%20be%20directed%20to%20upgrade%20your%20account./singular: b52e4f6122cf739c9551d29beb89b25e
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
deleted%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 60f9107197e756487d3f95a1083846b5
deleted%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 8b7e270f612f9e91bda7f5cfb853d843
Design/singular: 991b75727b6784c1a063a7462b76186d
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
Display%20name/singular: 725f331065e9d594d720d07b1ae51a90
Display%20name%20cannot%20exceed%20280%20characters/singular: 426ddbcc87590e9d028bdca4e585c549
Display%20name%20must%20be%20at%20least%203%20characters%20long/singular: 84178b79845a70937af5e26020403052
Display%20name%20updated/singular: 4e27503ca24ef5f009713951ca9e5b97
Do%20you%20offer%20a%20free%20plan%3F/singular: 3ef16b0994f0d9ae17067782ca9bed1b
docs/singular: 307bff5678ada918da1f1ec3edc76cac
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
email/singular: f31eb214738e037d58e26149797739df
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
Error%20inviting%20member/singular: 91553b606a6ebed3534cd2e5635ce004
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
Execution/singular: cbac4a3c721123cbc6a883560bf29800
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
Features/singular: 341ff316a339b106a178f0b8d362951b
Feedback/singular: 6fac88806e0c269a30777b283988c61c
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
Filter/singular: 626325a05e4c8800f7ede7012b0cadaf
Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
Free/singular: 0326365539c004f6088656f692602078
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
Full-time/singular: fe6e201f6676ae354111435cf6c76910
Fun/singular: 395bdc48e943762b6cde649f4a96771d
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
Get%20started%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
Get%20started%20on%20Cloud/singular: ed926f526266ad2063283c58e6f4284a
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
How%20do%20I%20get%20a%20custom%20URL%3F/singular: 5b28fb95969c0dd19e1b9c9ab8a5fb29
How%20do%20I%20import%20my%20Trello%20boards%3F/singular: 44b30a727150cb93364af0a10346012a
How%20do%20I%20invite%20team%20members%3F/singular: e620622060a06288e86ccda35395db87
How%20do%20I%20self-host%3F/singular: ec0b6afc8ad2f581b512c0fd82926b75
How%20is%20the%20project%20funded%3F/singular: ba7e936f7b1b5faa47df59a0ef990092
I%20acknowledge%20that%20all%20of%20my%20account%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: 2ffc824d0b1f9e883c2e87ed4b8b40b6
I%20acknowledge%20that%20all%20of%20the%20workspace%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: fe1183a7439299210d8ab7379b8f4d01
Ideas/singular: 27c24b56895defecd10b0171b39bc93f
Ideas%20to%20improve%20this%20page.../singular: 285decc2cc28403fae043d9b4de97a00
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
Invite/singular: 181884cea804cbde665f160811ee7ad0
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
Labels/singular: 6f15627a90002323eac018274b6922d6
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
Legal/singular: 00d3f08da61a21887009819cf93fb414
License/singular: ac2bcac75151e2fcd9eb768cea275a77
Light/singular: bb78ea2c6edd8662676c81a8a13ecb93
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
Monthly/singular: 818f1192e32bb855597f930d3e78806e
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
Name/singular: 9368b5a047572b6051f334af5aa76819
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
New/singular: 126d036fae5fb6b629728ecb97e6195b
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
New%20card/singular: a33f6219a756127f91c2523cfe845a19
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
New%20label/singular: 890040da52a88c16c249a4ac78980f81
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
Planning/singular: 353f58c75248275fe091740607501610
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
Private/singular: d740d0660e68c61db1853489356030ae
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
Public/singular: c834f11dab0881632aded3f01d4f8fa9
Publishing/singular: d9c8862c0180d9b7bd1272d5dc0d3ac3
Question/singular: 0576462ce60d4263d7c482463fcc9547
Questions%3F/singular: c7c3a47f8b230ce7a43f3c090f21d683
Quick%20Win/singular: a03ff4c0896f14b87455510bab203511
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
Research/singular: 3368e9638d1619babd6df9fad592274f
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
Resources/singular: ec7fb05ed963bb6781a35782b3475502
Review/singular: 299f75db25382980b2895622d7712927
Revoke/singular: be57685a85b6dfeaeb6eab4e9560b520
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
Role/singular: 53743bbb6ca938f5b893552e839d067f
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
Senior/singular: 3fff865dc00435f82896fc302ea45630
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
Sign%20up%20with%20/singular: 103e5d75506e6835b471695d799ff580
Simple%20pricing/singular: 5aa2bece93a71947b268498a58062937
Simple%2C%20visual%20task%20management%20that%20just%20works.%20Drag%20and%20drop%20cards%2C%20collaborate%20with%20your%20team%2C%20and%20get%20more%20done./singular: f4f138f83017d43dab2882cc08e07bdb
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
Success/singular: c43827becada6750f7a25890905f38b9
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
System/singular: 803281c327a02a2feba182f4a2b38311
Teams/singular: b63448c05270497973ac4407047dae02
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
Theme/singular: 21fe00b7a518089576fb83c08631107a
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
To%20Do/singular: d60813ea824f373462471e092d136eed
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20No%20credit%20card%20required./singular: 8d9733137deb26664d76b55ad561c42c
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
Update/singular: 079fc039262fd31b10532929685c2d1b
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
User/singular: 61073457a5c3901084b557d065f876be
User%20is%20already%20a%20member%20of%20this%20workspace/singular: bc246386f0975f750f2133222bb4735e
Video/singular: 8050c90e4289b105a0780f0fdda6ff66
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
View%20only/singular: 21f5f7ba3b49050c4f406113479d6a7a
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20time%20(and%20atlassification)%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 60a55e9bd16c3d1beff1a6c7633c5911
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
Workspace%20deleted/singular: bc5c267ce3f04de94ff1379cf2ca48a3
Workspace%20description/singular: 7159d620b7ef5c5afef734989a5be08b
Workspace%20description%20cannot%20exceed%20280%20characters/singular: d0e83b9a0304d7c36cf8c85d98783ce2
Workspace%20description%20must%20be%20at%20least%203%20characters%20long/singular: 5ebac407715e6569adc381ebbb4f32d4
Workspace%20description%20updated/singular: 19ddb6ee9ead26982a90dee426f3ee1f
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
Workspace%20name%20cannot%20exceed%2024%20characters/singular: c8b8f79fb83abf8329cc34c1f51fcfc9
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
Yearly/singular: 87f43e016c19cb25860f456549a2f431
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
Your%20workspace%20description%20has%20been%20updated./singular: 98de6f65c567c6654cc0adce3c9d5656
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167

15
apps/web/lingui.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { LinguiConfig } from "@lingui/conf";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl"],
sourceLocale: "en",
catalogs: [
{
path: "src/locales/{locale}/messages",
include: ["src"],
exclude: ["**/node_modules/**"],
},
],
};
export default config;

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

@@ -0,0 +1,57 @@
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 = {
output:
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
? "standalone"
: undefined,
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: {
remotePatterns: [
{
protocol: "https",
hostname: process.env.S3_FORCE_PATH_STYLE === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
],
},
experimental: {
// instrumentationHook: true,
swcPlugins: [["@lingui/swc-plugin", {}]],
},
};
export default config;

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

@@ -0,0 +1,90 @@
{
"name": "@kan/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env 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 --",
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile --typescript",
"lingui:extract:watch": "lingui extract --watch"
},
"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:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",
"@lingui/macro": "^5.3.2",
"@lingui/react": "^5.3.2",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:",
"@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
"@tiptap/suggestion": "^2.14.0",
"@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",
"next": "^15.3.4",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"posthog-js": "^1.254.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": "^5.5.0",
"react-lottie-player": "^1.5.5",
"react-markdown": "^10.1.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"zod": "catalog:"
},
"devDependencies": {
"@kan/eslint-config": "workspace:*",
"@kan/prettier-config": "workspace:*",
"@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2",
"@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@types/node": "^20.17.7",
"@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7",
"@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: {},
},
};

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 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,344 @@
import type { SocialProvider } from "better-auth/social-providers";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
FaApple,
FaDiscord,
FaDropbox,
FaFacebook,
FaGithub,
FaGitlab,
FaGoogle,
FaLinkedin,
FaMicrosoft,
FaReddit,
FaSpotify,
FaTiktok,
FaTwitch,
FaTwitter,
FaVk,
} from "react-icons/fa";
import { SiRoblox, SiZoom } from "react-icons/si";
import { TbBrandKick } from "react-icons/tb";
import { z } from "zod";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
interface FormValues {
name?: string;
email: string;
password?: string;
}
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
}
const EmailSchema = z.object({
name: z.string().optional(),
email: z.string().email(),
password: z.string().optional(),
});
const availableSocialProviders = {
google: {
id: "google",
name: "Google",
icon: FaGoogle,
},
github: {
id: "github",
name: "GitHub",
icon: FaGithub,
},
discord: {
id: "discord",
name: "Discord",
icon: FaDiscord,
},
apple: {
id: "apple",
name: "Apple",
icon: FaApple,
},
microsoft: {
id: "microsoft",
name: "Microsoft",
icon: FaMicrosoft,
},
facebook: {
id: "facebook",
name: "Facebook",
icon: FaFacebook,
},
spotify: {
id: "spotify",
name: "Spotify",
icon: FaSpotify,
},
twitch: {
id: "twitch",
name: "Twitch",
icon: FaTwitch,
},
twitter: {
id: "twitter",
name: "Twitter",
icon: FaTwitter,
},
dropbox: {
id: "dropbox",
name: "Dropbox",
icon: FaDropbox,
},
linkedin: {
id: "linkedin",
name: "LinkedIn",
icon: FaLinkedin,
},
gitlab: {
id: "gitlab",
name: "GitLab",
icon: FaGitlab,
},
tiktok: {
id: "tiktok",
name: "TikTok",
icon: FaTiktok,
},
reddit: {
id: "reddit",
name: "Reddit",
icon: FaReddit,
},
roblox: {
id: "roblox",
name: "Roblox",
icon: SiRoblox,
},
vk: {
id: "vk",
name: "VK",
icon: FaVk,
},
kick: {
id: "kick",
name: "Kick",
icon: TbBrandKick,
},
zoom: {
id: "zoom",
name: "Zoom",
icon: SiZoom,
},
};
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | SocialProvider>(null);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormValues>({
resolver: zodResolver(EmailSchema),
});
const { data: socialProviders } = useQuery({
queryKey: ["social_providers"],
queryFn: () => authClient.getSocialProviders(),
});
const handleLoginWithEmail = async (
email: string,
password?: string,
name?: string,
) => {
setIsLoginWithEmailPending(true);
setLoginError(null);
if (password) {
if (isSignUp && name) {
await authClient.signUp.email(
{
name,
email,
password,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: t`Success`,
message: t`You have been signed up successfully.`,
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
},
);
} else {
await authClient.signIn.email(
{
email,
password,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: t`Success`,
message: t`You have been logged in successfully.`,
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
},
);
}
} else {
await authClient.signIn.magicLink(
{
email,
callbackURL: "/boards",
},
{
onSuccess: () => setIsMagicLinkSent(true, email),
onError: ({ error }) => setLoginError(error.message),
},
);
}
setIsLoginWithEmailPending(false);
};
const handleLoginWithProvider = async (provider: SocialProvider) => {
setIsLoginWithProviderPending(provider);
setLoginError(null);
const { error } = await authClient.signIn.social({
provider,
callbackURL: "/boards",
});
setIsLoginWithProviderPending(null);
if (error) {
setLoginError(
t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
);
}
};
const onSubmit = async (values: FormValues) => {
await handleLoginWithEmail(values.email, values.password, values.name);
};
const password = watch("password");
return (
<div className="space-y-6">
{socialProviders?.length !== 0 && (
<div className="space-y-2">
{Object.entries(availableSocialProviders).map(([key, provider]) => {
if (!socialProviders?.includes(key)) {
return null;
}
return (
<Button
key={key}
onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
<Trans>Continue with {provider.name}</Trans>
</Button>
);
})}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
{t`or`}
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("name", { required: true })}
placeholder={t`Enter your name`}
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid name`}
</p>
)}
</div>
)}
<div>
<Input
{...register("email", { required: true })}
placeholder={t`Enter your email address`}
/>
{errors.email && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`}
</p>
)}
</div>
{isCredentialsEnabled && (
<div>
<Input
type="password"
{...register("password", { required: true })}
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid password`}
</p>
)}
</div>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
<div className="mt-[1.5rem] flex items-center gap-4">
<Button
isLoading={isLoginWithEmailPending}
fullWidth
size="lg"
variant="secondary"
>
{isSignUp ? t`Sign up with ` : t`Continue with `}
{!isCredentialsEnabled || (password && password.length !== 0)
? t`email`
: t`magic link`}
</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,91 @@
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?: "xs" | "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 === "xs" && "text-xs px-2 py-1",
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,186 @@
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
type="button"
className="invisible ml-auto group-hover:visible"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleEdit(item.key);
}}
>
<HiEllipsisHorizontal size={20} className="text-dark-900" />
</button>
)}
</div>
</Menu.Item>
))}
{handleCreate && (
<button
type="button"
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
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,58 @@
import { twMerge } from "tailwind-merge";
interface CircularProgressProps {
progress: number; // 0-100
size?: "sm" | "md" | "lg";
className?: string;
}
const CircularProgress = ({
progress,
size = "md",
className,
}: CircularProgressProps) => {
const radius = 40;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<div className={twMerge("relative", className)}>
<svg
className={twMerge(
"-rotate-90 transform",
size === "sm" && "h-4 w-4",
size === "md" && "h-5 w-5",
size === "lg" && "h-8 w-8",
)}
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="14"
className="text-light-300 dark:text-dark-400"
/>
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="14"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={twMerge(
"transition-all duration-300 ease-in-out",
progress === 100 ? "text-green-500" : "text-blue-500",
)}
/>
</svg>
</div>
);
};
export default CircularProgress;

View File

@@ -0,0 +1,185 @@
import { useRef, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
TbLayoutSidebarRightCollapse,
TbLayoutSidebarRightExpand,
} from "react-icons/tb";
import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { useTheme } from "~/providers/theme";
import { WorkspaceProvider } from "~/providers/workspace";
import FeedbackModal from "./FeedbackModal";
import Modal from "./modal";
import SideNavigation from "./SideNavigation";
interface DashboardProps {
children: React.ReactNode;
rightPanel?: React.ReactNode;
hasRightPanel?: boolean;
}
export function getDashboardLayout(
page: React.ReactElement,
rightPanel?: React.ReactNode,
hasRightPanel = false,
) {
return (
<WorkspaceProvider>
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
{page}
</Dashboard>
</WorkspaceProvider>
);
}
export default function Dashboard({
children,
rightPanel,
hasRightPanel = false,
}: DashboardProps) {
const theme = useTheme();
const { modalContentType } = useModal();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const sideNavRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const sideNavButtonRef = useRef<HTMLButtonElement>(null);
const rightPanelButtonRef = useRef<HTMLButtonElement>(null);
const toggleSideNav = () => {
setIsSideNavOpen(!isSideNavOpen);
if (!isSideNavOpen) {
setIsRightPanelOpen(false);
}
};
const closeSideNav = () => {
setIsSideNavOpen(false);
};
const toggleRightPanel = () => {
setIsRightPanelOpen(!isRightPanelOpen);
if (!isRightPanelOpen) {
setIsSideNavOpen(false);
}
};
useClickOutside(sideNavRef, (event) => {
if (sideNavButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isSideNavOpen) {
setIsSideNavOpen(false);
}
});
useClickOutside(rightPanelRef, (event) => {
if (rightPanelButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isRightPanelOpen) {
setIsRightPanelOpen(false);
}
});
const isDarkMode = theme.activeTheme === "dark";
return (
<>
<style jsx global>{`
html {
height: 100vh;
overflow: hidden;
min-width: 320px;
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
}
`}</style>
<div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
{/* Mobile Header */}
<div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
<button
ref={sideNavButtonRef}
onClick={toggleSideNav}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
>
{isSideNavOpen ? (
<TbLayoutSidebarLeftCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarLeftExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
{hasRightPanel && (
<button
ref={rightPanelButtonRef}
onClick={toggleRightPanel}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
>
{isRightPanelOpen ? (
<TbLayoutSidebarRightCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarRightExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
)}
</div>
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
<div
ref={sideNavRef}
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
>
<SideNavigation
user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading}
onCloseSideNav={closeSideNav}
/>
</div>
<div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
<div className="relative flex h-full min-h-0 w-full overflow-hidden">
<div className="h-full w-full overflow-y-auto">{children}</div>
{/* Mobile Right Panel */}
{hasRightPanel && rightPanel && (
<div
ref={rightPanelRef}
className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="h-full">{rightPanel}</div>
</div>
)}
{/* Desktop Right Panel */}
{hasRightPanel && rightPanel && (
<div className="hidden md:block">{rightPanel}</div>
)}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,55 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export function DeleteLabelConfirmation({
labelPublicId,
refetch,
}: {
labelPublicId: string;
refetch: () => void;
}) {
const { closeModal, closeModals } = useModal();
const { showPopup } = usePopup();
const deleteLabelMutation = api.label.delete.useMutation({
onSuccess: () => {
refetch();
closeModals(2);
},
onError: () =>
showPopup({
header: t`Error deleting label`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
}),
});
const handleDeleteLabel = () => {
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">
{t`Are you sure you want to delete this label?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
{t`Cancel`}
</Button>
<Button onClick={handleDeleteLabel}>{t`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,620 @@
import type { Editor as TiptapEditor } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
SuggestionOptions,
} from "@tiptap/suggestion";
import type { Instance as TippyInstance } from "tippy.js";
import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import {
BubbleMenu,
EditorContent,
Extension,
ReactRenderer,
useEditor,
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import {
HiH1,
HiH2,
HiH3,
HiOutlineBold,
HiOutlineChatBubbleLeftEllipsis,
HiOutlineCodeBracket,
HiOutlineCodeBracketSquare,
HiOutlineItalic,
HiOutlineListBullet,
HiOutlineNumberedList,
HiOutlineStrikethrough,
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import tippy from "tippy.js";
import Mention from "@tiptap/extension-mention";
import Avatar from "./Avatar";
import { getAvatarUrl } from "~/utils/helpers";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
slashSuggestion: {
setSlashSuggestion: () => ReturnType;
};
}
}
export interface SlashCommandItem {
title: string;
icon?: React.ReactNode;
command?: (props: { editor: TiptapEditor; range: Range }) => void;
disabled?: boolean;
}
export interface SlashCommandsOptions {
suggestion?: Partial<SuggestionOptions>;
commandItems?: SlashCommandItem[];
options?: any;
}
function filterSlashCommandItems(items: SlashCommandItem[], query: string) {
return items.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()),
);
}
export interface RenderSuggestionsProps {
editor: TiptapEditor;
clientRect: () => DOMRect;
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
}
export type WorkspaceMember = {
publicId: string;
user: {
id: string;
name: string | null;
image: string | null;
} | null;
email: string;
};
const CommandsList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
{
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
}
>(({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
if (event.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (event.key === "Enter") {
const item = items[selectedIndex];
if (item) {
command(item);
}
return true;
}
return false;
},
}));
return (
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="max-h-[350px] overflow-y-auto p-1">
{items.map((item, index) => (
<button
key={item.title}
onClick={() => command(item)}
className={twMerge(
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
)}
>
<span className="text-dark-700 dark:text-dark-800">
{item.icon}
</span>
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
{item.title}
</span>
</button>
))}
</div>
</div>
);
});
CommandsList.displayName = "CommandsList";
const RenderSuggestions = () => {
let reactRenderer: ReactRenderer;
let popup: TippyInstance[];
return {
onStart: (props: RenderSuggestionsProps) => {
reactRenderer = new ReactRenderer(CommandsList, {
props,
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: RenderSuggestionsProps) {
reactRenderer.updateProps(props);
if (!props.clientRect) return;
popup[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: SuggestionKeyDownProps): boolean {
if (props.event.key === "Escape") {
popup[0]?.hide();
return true;
}
return (
(
reactRenderer.ref as {
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
}
).onKeyDown?.(props) ?? false
);
},
onExit() {
popup[0]?.destroy();
reactRenderer.destroy();
},
};
};
type MentionItem = { id: string; label: string; image: string | null };
const MentionList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
{
items: MentionItem[];
command: (item: MentionItem) => void;
}
>(({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
if (event.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (event.key === "Enter") {
const item = items[selectedIndex];
if (item) {
command(item);
}
return true;
}
return false;
},
}));
return (
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
{items.length > 0 ? items.map((item, index) => (
<button
key={item.id}
onClick={() => command(item)}
className={twMerge(
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
)}
>
<Avatar
size="xs"
name={item.label}
imageUrl={
item.image ? getAvatarUrl(item.image) : undefined
}
email={item.label}
/>
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
{item.label}
</span>
</button>
)) : (
<div className="flex items-center justify-start p-2">
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
</div>
)}
</div>
</div>
);
});
MentionList.displayName = "MentionList";
const renderMentionSuggestions = () => {
let reactRenderer: ReactRenderer;
let popup: TippyInstance[];
return {
onStart: (props: any) => {
reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: any) {
reactRenderer.updateProps(props);
if (!props.clientRect) return;
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
popup[0]?.hide();
return true;
}
return (
(
reactRenderer.ref as {
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
}
).onKeyDown?.(props) ?? false
);
},
onExit() {
popup[0]?.destroy();
reactRenderer.destroy();
},
};
};
const SlashCommands = Extension.create<SlashCommandsOptions>({
name: "slash-commands",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }) => {
editor.chain().focus().deleteRange(range).run();
props.command({ editor, range });
},
items: ({ query }: { query: string }) => {
return filterSlashCommandItems(
this.parent().commandItems ?? [],
query,
);
},
render: () => {
let component: ReturnType<typeof RenderSuggestions>;
return {
onStart: (props: any) => {
component = RenderSuggestions();
component.onStart(props);
},
onUpdate(props: any) {
component.onUpdate(props);
},
onKeyDown(props: any) {
if (props.event.key === "Escape") {
return true;
}
return component.onKeyDown(props) ?? false;
},
onExit: () => {
component.onExit();
},
};
},
},
commandItems: [] as SlashCommandItem[],
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
render: RenderSuggestions,
} as SuggestionOptions),
];
},
});
export interface SlashNodeAttrs {
id: string | null;
label?: string | null;
}
const CommandItems: SlashCommandItem[] = [
{
title: "Heading 1",
icon: <HiH1 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 1 }).run(),
},
{
title: "Heading 2",
icon: <HiH2 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 2 }).run(),
},
{
title: "Heading 3",
icon: <HiH3 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 3 }).run(),
},
{
title: "Bullet List",
icon: <HiOutlineListBullet />,
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
title: "Ordered List",
icon: <HiOutlineNumberedList />,
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
{
title: "Blockquote",
icon: <HiOutlineChatBubbleLeftEllipsis />,
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
export default function Editor({
content,
onChange,
onBlur,
readOnly = false,
workspaceMembers,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor(
{
extensions: [
StarterKit,
Placeholder.configure({
placeholder: readOnly
? ""
: t`Add description... (type '/' to open commands or '@' to mention)`,
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
class: "text-blue-600 hover:text-blue-800 underline cursor-pointer",
target: "_blank",
rel: "noopener noreferrer",
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
}),
SlashCommands.configure({
commandItems: CommandItems,
suggestion: {
items: ({ query }: { query: string }) =>
filterSlashCommandItems(CommandItems, query),
startOfLine: true,
char: "/",
},
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: {
char: "@",
items: ({ query }: { query: string }) => {
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
id: member.publicId,
label: member?.user?.name ?? member.email,
image: member?.user?.image ?? null,
}));
const q = query.toLowerCase();
return all.filter((u) => u.label.toLowerCase().includes(q));
},
command: ({ editor, range, props }: any) => {
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span>&nbsp;`;
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(mentionHTML)
.focus()
.run();
},
render: renderMentionSuggestions,
},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
}),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
onBlur: ({ event }) => {
if (
document
.querySelector(".tippy-box")
?.contains(event.relatedTarget as Node)
)
return;
// Only trigger onBlur if the click was outside both the editor and menu
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
onBlur?.();
}
},
editorProps: {
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
editable: !readOnly,
injectCSS: false,
},
[], // creating the editor only once
);
// this will sync external content changes without re-creating the editor instance
useEffect(() => {
if (!editor) return;
const currentHTML = editor.getHTML();
const safeContent = content ?? "";
if (safeContent !== currentHTML) {
editor.commands.setContent(safeContent, false);
}
}, [content, editor]);
return (
<div ref={containerRef}>
<style jsx global>{`
.tiptap p.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap p {
margin: 0 0 1rem 0 !important;
}
.tiptap .mention {
background-color: rgba(59, 130, 246, 0.1);
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
color: rgb(59, 130, 246);
text-decoration: none;
font-weight: 500;
}
`}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent
editor={editor}
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-light-950 [&_p]:dark:text-dark-950 [&_ul]:!text-xs"
/>
</div>
);
}
function EditorBubbleMenu({ editor }: { editor: TiptapEditor | null }) {
const isMac = navigator.platform.includes("Mac");
const bubbleMenuItems = [
{
title: "Bold",
icon: <HiOutlineBold />,
keys: ["meta", "b"],
onClick: () => editor?.chain().focus().toggleBold().run(),
active: editor?.isActive("bold"),
},
{
title: "Italic",
icon: <HiOutlineItalic />,
keys: ["meta", "i"],
onClick: () => editor?.chain().focus().toggleItalic().run(),
active: editor?.isActive("italic"),
},
{
title: "Strikethrough",
icon: <HiOutlineStrikethrough />,
keys: ["meta", "shift", "s"],
onClick: () => editor?.chain().focus().toggleStrike().run(),
active: editor?.isActive("strike"),
},
{
title: "Code",
icon: <HiOutlineCodeBracket />,
keys: ["meta", "e"],
onClick: () => editor?.chain().focus().toggleCode().run(),
active: editor?.isActive("code"),
},
];
return (
<BubbleMenu editor={editor}>
<div className="flex items-center gap-2 rounded-md border border-light-600 bg-light-50 p-1 dark:border-dark-600 dark:bg-dark-50">
{bubbleMenuItems.map((item) => (
<Button
key={item.title}
className={twMerge(
"rounded p-1 text-light-900 focus:ring-2 focus:ring-light-600 dark:text-dark-900 dark:focus:ring-dark-600",
item.active && "bg-light-100 dark:bg-dark-400",
)}
title={`${item.title} [${item.keys.join(" + ").replace("meta", isMac ? "⌘" : "ctrl")}]`}
onClick={item.onClick}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
item.onClick();
}
}}
>
{item.icon}
</Button>
))}
</div>
</BubbleMenu>
);
}

View File

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

View File

@@ -0,0 +1,106 @@
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-sm 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",
type === "password" && "pr-8",
className && className,
)}
onKeyDown={onKeyDown}
{...props}
/>
{type === "password" && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-light-900 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,244 @@
import { Listbox, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
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, setModalState } = 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: (newLabel) => {
const currentColourIndex = colours.findIndex(
(c) => c.code === watch("colour").code,
);
try {
refetch();
setModalState("NEW_LABEL_CREATED", newLabel.publicId);
if (!isCreateAnotherEnabled) {
closeModal();
} else {
const newFormState = {
name: "",
colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
};
reset(newFormState);
}
} catch (e) {
console.log(e);
}
},
});
const updateLabel = api.label.update.useMutation({
onSuccess: () => {
refetch();
closeModal();
},
});
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 ? t`Edit label` : t`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={t`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={t`Create another`}
isChecked={!!isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
}
/>
)}
<div className="space-x-2">
{isEdit && (
<Button
type="button"
variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)}
>
{t`Delete`}
</Button>
)}
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
disabled={
!watch("name")
}
>
{isEdit ? t`Update label` : t`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,26 @@
import { HiLanguage } from "react-icons/hi2";
import { useLocalisation } from "~/hooks/useLocalisation";
import { localeNames } from "~/locales";
export function LanguageSelector() {
const { locale, setLocale, availableLocales } = useLocalisation();
return (
<div className="relative">
<HiLanguage className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as any)}
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500 sm:text-sm"
>
{availableLocales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
</div>
);
}

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

View File

@@ -0,0 +1,99 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { 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 utils = api.useUtils();
const createWorkspace = api.workspace.create.useMutation({
onSuccess: (values) => {
if (values.publicId && values.name) {
utils.workspace.all.invalidate();
switchWorkspace({
publicId: values.publicId,
name: values.name,
description: values.description,
slug: values.slug,
plan: values.plan,
});
closeModal();
}
},
onError: () => {
showPopup({
header: t`Unable to create workspace`,
message: t`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">
{t`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={t`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}>
{t`Create workspace`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,13 @@
import Head from "next/head";
export const PageHead = ({ title }: { title: string }) => {
return (
<Head>
<title>{title}</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
</Head>
);
};

View File

@@ -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="10"
y="10"
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

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

View File

@@ -0,0 +1,156 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
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;
onCloseSideNav?: () => void;
}
interface UserType {
email?: string | null | undefined;
image?: string | null | undefined;
}
export default function SideNavigation({
user,
isLoading,
onCloseSideNav,
}: SideNavigationProps) {
const router = useRouter();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
useEffect(() => {
const savedState = localStorage.getItem("kan_sidebar-collapsed");
if (savedState !== null) {
setIsCollapsed(JSON.parse(savedState));
}
setIsInitialised(true);
}, []);
useEffect(() => {
if (isInitialised) {
localStorage.setItem(
"kan_sidebar-collapsed",
JSON.stringify(isCollapsed),
);
}
}, [isCollapsed, isInitialised]);
const { pathname } = router;
const { activeTheme } = useTheme();
const isDarkMode = activeTheme === "dark";
const navigation = [
{
name: t`Boards`,
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
},
{
name: t`Members`,
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
},
{
name: t`Settings`,
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
},
];
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
return (
<>
<nav
className={twMerge(
"flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:border-r-0 md:py-0 md:pl-0",
isCollapsed && "md:w-auto",
)}
>
<div>
<div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
{!isCollapsed && (
<Link href="/" className="block">
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
)}
<Button
onClick={toggleCollapse}
className={twMerge(
"flex h-8 items-center justify-center rounded-md hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed ? "w-full" : "w-8",
)}
>
{isCollapsed ? (
<TbLayoutSidebarLeftExpand
size={18}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarLeftCollapse
size={18}
className="text-light-900 dark:text-dark-900"
/>
)}
</Button>
</div>
<div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
<WorkspaceMenu isCollapsed={isCollapsed} />
<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}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
</li>
))}
</ul>
</div>
<div className="space-y-3">
<UserMenu
email={user.email ?? ""}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
</div>
</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,218 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useModal } from "~/providers/modal";
import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
email: string;
isLoading: boolean;
isCollapsed?: boolean;
onCloseSideNav?: () => void;
}
export default function UserMenu({
imageUrl,
email,
isLoading,
isCollapsed = false,
onCloseSideNav,
}: UserMenuProps) {
const router = useRouter();
const { themePreference, switchTheme } = useTheme();
const { openModal } = useModal();
const isMobile = useIsMobile();
const handleLogout = async () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
await authClient.signOut();
router.push("/login");
};
const handleLinkClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
const handleModalOpen = (modalType: string) => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
openModal(modalType);
};
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
return (
<Menu as="div" className="relative inline-block w-full text-left">
<div>
{isLoading ? (
<div className={twMerge(!isCollapsed && "flex")}>
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
<div
className={twMerge(
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
</div>
) : (
<Menu.Button
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
title={isCollapsed ? email : undefined}
>
{avatarUrl ? (
<Image
src={avatarUrl}
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={twMerge(
"mx-2 truncate text-sm",
isCollapsed && "md:hidden",
)}
>
{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={twMerge(
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "left-0 w-48" : "left-0 w-full",
)}
>
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
<div className="p-1">
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
<span>{t`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={twMerge(
themePreference === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
{t`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={twMerge(
themePreference === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
{t`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={twMerge(
themePreference === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
{t`Light`}
</button>
</Menu.Item>
</div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<Link
href="mailto:support@kan.bn"
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Support`}
</Link>
</Menu.Item>
<Menu.Item>
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Documentation`}
</Link>
</Menu.Item>
<Menu.Item>
<button
onClick={() => handleModalOpen("NEW_FEEDBACK")}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Feedback`}
</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"
>
{t`Logout`}
</button>
</Menu.Item>
</div>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

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

View File

@@ -1,19 +1,39 @@
"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";
isVisible?: boolean;
}
const Modal: React.FC<Props> = ({ children }) => {
const Modal: React.FC<Props> = ({
children,
modalSize = "sm",
positionFromTop = "md",
isVisible,
}) => {
const { isOpen, closeModal } = useModal();
const shouldShow = isVisible !== undefined ? isVisible : isOpen;
const modalSizeMap = {
sm: "max-w-[400px]",
md: "max-w-[550px]",
lg: "max-w-[800px]",
};
const positionFromTopMap = {
sm: "mt-[12vh]",
md: "mt-[25vh]",
lg: "mt-[50vh]",
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Transition.Root show={shouldShow} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
@@ -24,7 +44,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 +58,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>

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

@@ -0,0 +1,134 @@
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_TRUSTED_ORIGINS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) =>
!s ||
s.split(",").every((l) => z.string().url().safeParse(l).success),
)
.optional(),
POSTGRES_URL: z.string().url().optional().or(z.literal("")),
TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
DISCORD_CLIENT_ID: z.string().optional(),
DISCORD_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GITLAB_CLIENT_ID: z.string().optional(),
GITLAB_CLIENT_SECRET: z.string().optional(),
GITLAB_ISSUER: z.string().optional(),
MICROSOFT_CLIENT_ID: z.string().optional(),
MICROSOFT_CLIENT_SECRET: z.string().optional(),
TWITTER_CLIENT_ID: z.string().optional(),
TWITTER_CLIENT_SECRET: z.string().optional(),
KICK_CLIENT_ID: z.string().optional(),
KICK_CLIENT_SECRET: z.string().optional(),
ZOOM_CLIENT_ID: z.string().optional(),
ZOOM_CLIENT_SECRET: z.string().optional(),
DROPBOX_CLIENT_ID: z.string().optional(),
DROPBOX_CLIENT_SECRET: z.string().optional(),
VK_CLIENT_ID: z.string().optional(),
VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(),
REDDIT_CLIENT_ID: z.string().optional(),
REDDIT_CLIENT_SECRET: z.string().optional(),
ROBLOX_CLIENT_ID: z.string().optional(),
ROBLOX_CLIENT_SECRET: z.string().optional(),
SPOTIFY_CLIENT_ID: z.string().optional(),
SPOTIFY_CLIENT_SECRET: z.string().optional(),
TIKTOK_CLIENT_ID: z.string().optional(),
TIKTOK_CLIENT_SECRET: z.string().optional(),
TIKTOK_CLIENT_KEY: z.string().optional(),
TWITCH_CLIENT_ID: z.string().optional(),
TWITCH_CLIENT_SECRET: z.string().optional(),
APPLE_CLIENT_ID: z.string().optional(),
APPLE_CLIENT_SECRET: z.string().optional(),
APPLE_APP_BUNDLE_IDENTIFIER: 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(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
},
/**
* 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_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_DISABLE_SIGN_UP: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
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_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
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,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { useLingui } from "@lingui/react";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
import { activateLocale } from "~/utils/i18n";
export function useLocalisation() {
const { i18n } = useLingui();
const { locale, setLocale, availableLocales } = useLinguiContext();
const handleSetLocale = async (newLocale: Locale) => {
await activateLocale(newLocale);
setLocale(newLocale);
};
return {
locale,
setLocale: handleSetLocale,
availableLocales,
formatDate: i18n.date,
formatNumber: i18n.number,
};
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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