Compare commits

...

284 Commits

Author SHA1 Message Date
Henry
a2ac3acfb6 fix: prevent maximum update depth exceeded error 2026-08-12 20:06:11 +01:00
Henry
e9ebd06eaa feat(cloud): replace novu for all notification triggers 2026-08-08 13:37:51 +01:00
Henry
b4e9dd3811 feat(cloud): sync subscriber preferences on unsubscribe 2026-08-03 11:53:29 +01:00
Henry
d6aa82e070 feat(cloud): create subscribers 2026-07-31 16:25:25 +01:00
Henry
4291d7bc8c chore: add scripts to gitignore 2026-07-29 12:58:06 +01:00
hjball
5c72968d44 chore: update translations 2026-07-29 11:50:09 +00:00
Henry
5b586f4871 fix(cloud): default to active workspace on upgrade path 2026-07-29 12:49:00 +01:00
Andrey O
fee82e5e73 fix(mcp): map card.comment content field for Kan API (#540)
The MCP add_card_comment and update_card_comment tools send `{ content }`
to Kan API, but the API expects `{ comment }`. This causes a 400 Bad Request
("comment: Required") when trying to add or update card comments.

Fixed by mapping `{ comment: content }` in both tool handlers.

Co-authored-by: Craft Agent <agents-noreply@craft.do>
2026-07-27 21:18:55 +01:00
hjball
03a043784a chore: update translations 2026-07-27 20:16:05 +00:00
Anatoly Rubinshteyn
395dbfae04 fix(editor): guard tippy instance in suggestion handlers (#543)
The popup variable stays undefined when onStart bails out early because
props.clientRect is missing, so popup[0] throws instead of no-oping.
Use optional chaining on the array itself.
2026-07-27 21:14:51 +01:00
hjball
9ec4bedd9a chore: compile translations 2026-07-08 09:55:44 +00:00
hjball
ca694595c7 chore: update translations 2026-07-08 09:55:40 +00:00
Henry
0500d5171d feat: allow submitting comments via keyboard shortcut (#533) 2026-07-08 10:54:16 +01:00
hjball
a15e193f66 chore: update translations 2026-06-30 11:00:57 +00:00
Henry
019ecbc364 fix: correct template creation condition in BoardDropdown (#529) 2026-06-30 11:59:43 +01:00
hjball
82b2b4a8e1 chore: compile translations 2026-06-30 10:21:13 +00:00
hjball
c1adb2d4dd chore: update translations 2026-06-30 10:21:09 +00:00
Nick Meinhold
25ed4e39b3 feat: move boards between workspaces (#458)
* feat: add ability to move boards between workspaces

Implements the "Move to workspace" feature (#344) allowing users to
relocate a board and all its contents (lists, cards, labels, checklists,
comments, activity) to a different workspace.

Key design decisions:
- Card member assignments are cleared on move (they reference
  workspace-scoped members that may not exist in the target workspace)
- Comments and activity history are preserved (they reference global
  user IDs, not workspace members)
- Slug conflicts in the target workspace are auto-resolved by
  appending a UID suffix
- Permission model: requires board:edit in source workspace and
  board:create in target workspace
- Templates and archived boards cannot be moved

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: consolidate board queries in move mutation

Address review feedback:
- Consolidate 3 separate board queries into a single findFirst()
  that fetches all needed fields (id, name, slug, type, isArchived,
  workspaceId, createdBy)
- Fix slug fallback to use board.name instead of publicId for
  human-readable URLs

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: filter guest workspaces from move board destination list

Guests typically lack board:create permission in the target workspace,
so showing them as destinations leads to a confusing server rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract getBoardForMove repo function

Moves the inline board query from the move mutation into the repo
layer, consistent with how every other board mutation fetches data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add unit tests for board.move mutation

10 test cases covering auth, validation, permissions, slug conflict
resolution, and the happy path. Follows webhook.test.ts patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: mark locale files as linguist-generated

GitHub will now auto-collapse compiled translation files (messages.json,
messages.ts, messages.po) in PR diffs and exclude them from language
stats. This makes PRs that touch i18n strings much easier to review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove locale file changes from PR

Reverts locale file diffs and .gitattributes to match main, per review
feedback. The locale changes were unrelated translation updates that
inflated the PR diff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove locale file changes from PR

Per @hjball's review: locale compilation/translations are handled
automatically on merge to main, so this PR shouldn't carry them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: align locale files with upstream/main

Previous removal commit used local main, which had drifted from
upstream. Re-syncing to upstream/main so the PR carries no locale diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(board-move): tighten deletedAt handling per review

Three changes addressing @hjball's review comments, all about the
schema treating deletedAt as optional metadata while the move-board
flow needs it as a load-bearing invariant.

1. getBoardForMove now filters isNull(deletedAt). Moving a tombstoned
   board has no defensible semantics. Replaces the implicit
   "the board exists in the table" check with an explicit
   "the board is not soft-deleted" check.

2. Move-flow's clearing of cardToWorkspaceMembers now spans every
   card under every list ever associated with this board, including
   soft-deleted ones. If we leave member assignments on a deleted
   card and that card is later restored, the assignments would
   resurrect rogue references to workspace members from the OLD
   workspace. Removed the isNull filters on both lists and cards in
   that loop.

3. Move-flow now refuses to move into a soft-deleted target
   workspace. workspaceRepo.getByPublicId did not previously project
   deletedAt; extended its column selection so the call-site guard
   in board.move can check it. (A wider fix to make the repo treat
   deleted-as-not-found across all 14+ callers is left for a
   separate PR — narrow scope here.)

Plus one regression test: throws NOT_FOUND when target workspace is
soft-deleted. All 11 board-move tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 11:19:39 +01:00
Henry
a994c061ac fix: prevent cross-tenant comment deletion and edit 2026-06-25 17:11:08 +01:00
Henry
6e1d821b35 fix: list dnd when board exceeds viewport width (#526) 2026-06-25 17:00:07 +01:00
Henry
120504ab58 feat: tweak mobile list snapping 2026-06-25 16:38:13 +01:00
Max K.
ebb19584a2 feat: add mobile list snap scrolling (#510) 2026-06-25 16:05:27 +01:00
hjball
410f4c481e chore: update translations 2026-06-25 15:01:34 +00:00
JayDataEngineer
9d7a82d381 fix: sync Editor editable state with readOnly prop (#523)
The rich-text Editor (used for card descriptions) creates its Tiptap
instance once via useEditor with an empty dependency array. This means
the initial value of `editable: !readOnly` is captured at mount time
and never updated, and the onChange/onBlur callbacks are frozen as the
first-render closures.

In CardPage, the description Editor mounts as soon as the card query
resolves but before the permissions query resolves, so `readOnly` is
`true` and onChange/onBlur are `undefined` at that moment. When
permissions resolve a moment later and `canEdit` flips to `true`,
the Editor never becomes editable — leaving the description stuck
read-only even for workspace admins.

Fix mirrors the existing pattern in PlainTextEditor.tsx:
- Use refs for onChange/onBlur so the editor reads the latest values
- Add a useEffect that calls editor.setEditable(!readOnly) when the
  readOnly prop changes

Co-authored-by: Jay <CodeEngineering@pm.me>
2026-06-25 16:00:13 +01:00
hjball
f369ebeaa8 chore: compile translations 2026-06-11 15:45:43 +00:00
hajorappe
0fbe198fa2 fix: improve German translations in /de/messages.json (#514)
Improved German translations for better readability.
2026-06-11 16:44:09 +01:00
Morfixx
81b67df9e3 feat: kan mcp server (#485)
* feat: kan mcp server initial attempt

* fix: card was missing options and added default fallbacks

* fix: label creating with better information for colors and presets
2026-06-10 09:10:22 +01:00
Henry
4f3b49716d chore: improve contribution guidelines and add PR template 2026-06-09 15:30:59 +01:00
Henry
1347cc562c fix: encode attachment filename header to support non-ASCII characters (#519) 2026-06-09 12:16:51 +01:00
Henry
2893e95597 fix(cloud): free partner subscription slot when workspace is deleted 2026-05-31 21:21:36 +01:00
Henry
89654c20b0 fix(partner): scope auto-claim to owned workspaces and guard against active subs 2026-05-30 12:51:21 +01:00
hjball
4442e67832 chore: update translations 2026-05-29 22:53:24 +00:00
Henry
d7e8b5ae6b feat: reset checklist items to default when duplicating 2026-05-29 23:51:48 +01:00
Henry
03e1e19464 fix: ensure upgrade/downgrade paths for migrate requests 2026-05-29 23:48:01 +01:00
hjball
a3353acf44 chore: compile translations 2026-05-29 22:29:11 +00:00
hjball
a4a807ed1d chore: update translations 2026-05-29 22:29:06 +00:00
Henry
6b8ada8d90 feat: duplicate card via dropdown 2026-05-29 23:27:21 +01:00
hjball
22899cd14c chore: update translations 2026-05-29 21:59:33 +00:00
Henry
833c69a73e cloud: enable workspace slots for partners 2026-05-29 22:58:09 +01:00
hjball
d9cf363302 chore: update translations 2026-05-25 21:01:17 +00:00
Henry
9212b1ca2e fix: adjust layout of card prefix on public modal 2026-05-25 21:59:57 +01:00
hjball
ede2475796 chore: update translations 2026-05-25 20:49:46 +00:00
Henry
e32ad1cd32 fix(cloud): hide empty notice box for pro subscriptions 2026-05-25 21:48:27 +01:00
hjball
bac22021ad chore: update translations 2026-05-21 21:53:34 +00:00
Henry
8d8cfa3bae feat(cloud): pause members when subscription is cancelled 2026-05-21 22:52:17 +01:00
hjball
971348a397 chore: compile translations 2026-05-21 20:39:43 +00:00
hjball
d3920cd246 chore: update translations 2026-05-21 20:39:39 +00:00
Henry
5af375825b fix: reliably select workspace on login (#507) 2026-05-21 21:38:09 +01:00
Henry
861416a18e feat(cloud): set seat limit checks for partner member invitations (#506) 2026-05-21 21:36:57 +01:00
Henry
a7e78ad580 fix: respect redirect param on login and signup pages (#504) 2026-05-21 21:36:02 +01:00
Henry
f288047a6a fix: run web container as non-root user (UID 1000) (#501) 2026-05-21 21:35:47 +01:00
hjball
edb88525c0 chore: compile translations 2026-05-18 22:21:55 +00:00
hjball
d69051611c chore: update translations 2026-05-18 22:21:52 +00:00
Henry
cf566e04c6 feat(cloud): improved upgrade journey 2026-05-18 23:20:11 +01:00
Henry
6ea622e7f7 fix(cloud): use prev_licence_key for upgrade/downgrade handler 2026-05-16 23:38:04 +01:00
Danny Halarewich
0e7b7a072c fix(editor): convert pasted markdown to formatted content (#498)
The tiptap-markdown extension was registered with default config, where
`transformPastedText` defaults to false. As a result, pasting markdown
text into card descriptions resulted in literal characters (e.g. '# heading'
shown as a paragraph of text) rather than rendered formatting.

Typing markdown shortcuts in the editor already worked because that goes
through a different path. Programmatic writes via the API also work
because they bypass the editor entirely. Only paste was affected.

Enabling `transformPastedText: true` makes pasted markdown behave as
users intuitively expect, matching the typed-markdown UX.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:03:06 +01:00
Nick Meinhold
f801d2a7eb fix(s3): default S3_REGION to us-east-1 instead of empty string (#495)
The AWS SDK throws "Region is missing" when S3Client is constructed
with an empty-string region, so any S3 feature (avatars, attachments,
presigned URL generation) is silently broken when S3_REGION is unset
— even though .env.example ships it unset and env.ts declares it
optional. The schema and the runtime disagreed about whether the var
was required.

Default to "us-east-1" in createS3Client. S3-compatible providers
(MinIO, Backblaze B2, R2, DigitalOcean Spaces, Wasabi) ignore the
region entirely; real AWS S3 users should set S3_REGION explicitly
to their bucket's actual region (clarified in .env.example).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:56:48 +01:00
Nick Meinhold
d9157b6218 fix(workspace): point package "types" exports at src/, not stale dist/ (#496)
Each workspace package had its exports map pointing `types` at
`./dist/...d.ts` while `default` pointed at `./src/...ts`. The dist
directory is a build artifact only refreshed when someone runs
`pnpm build` in that package — meaning every other package in the
monorepo typechecks against last week's type signatures.

Concrete failure mode: contributor edits a function in
packages/shared/src/utils/foo.ts (changes a parameter type, adds a
new export, etc.), runs `pnpm typecheck` from root, sees green. The
check actually validated against the stale dist/.d.ts. Real type
errors stay invisible until CI builds shared first, by which point
the diff is already pushed.

Fix: point `types` at the same `src/` paths the runtime resolves to.
TypeScript reads .ts source as types fine when consumers share the
same TS version, which a monorepo guarantees. Verified end-to-end:
adding a new export to shared and immediately typechecking @kan/api
now picks it up without rebuilding shared, and breaking a return
type immediately fails the consumer's typecheck.

Applied to @kan/api, @kan/db, @kan/logger, @kan/shared.
@kan/email is intentionally left as-is because its source is .tsx
(JSX) — pointing types at .tsx would force every consumer to enable
--jsx in their tsconfig, which is a worse cascade than the stale
dist problem we're solving.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:55:07 +01:00
hjball
711ff7f610 chore: update translations 2026-05-16 21:36:10 +00:00
Henry
742e8a1fab fix(cloud): prevent reprompting users to onboard after first signup 2026-05-16 22:34:59 +01:00
hjball
69ed5fd8f2 chore: compile translations 2026-05-16 21:25:37 +00:00
hjball
e21830138d chore: update translations 2026-05-16 21:25:33 +00:00
Henry
f38533590d feat(cloud): fix upgrade/downgrade and migrate webhook handler 2026-05-16 22:23:59 +01:00
hjball
7d52e4eb88 chore: compile translations 2026-05-15 21:34:55 +00:00
hjball
692123433b chore: update translations 2026-05-15 21:34:51 +00:00
Henry
8436644966 feat(cloud): improve partner onboarding 2026-05-15 22:33:18 +01:00
Henry
0c2b670baf Update README.md 2026-05-15 21:01:42 +01:00
Henry
584fe13244 fix(cloud): allow callback without code 2026-05-14 14:38:34 +01:00
Henry
cc6804c21d fix(cloud): update license key on conflict 2026-05-14 14:02:17 +01:00
Henry
8512946102 fix(cloud): partner webhook integration 2026-05-14 13:42:36 +01:00
Henry
6bebb14855 feat(cloud): add partner config to docker compose 2026-05-14 13:22:06 +01:00
hjball
0adb68d547 chore: update translations 2026-05-13 21:12:51 +00:00
Henry
e26c122d76 fix: add card number fields to zod schemas 2026-05-13 22:11:29 +01:00
hjball
95321c1c0d chore: update translations 2026-05-13 20:41:18 +00:00
Henry
f459c489f9 fix: broken migrations 2026-05-13 21:39:47 +01:00
hjball
460008d340 chore: compile translations 2026-05-12 21:40:38 +00:00
hjball
4371fe2d4d chore: update translations 2026-05-12 21:40:34 +00:00
Henry
60d8e1bd83 feat(cloud): partner integration (#493)
* feat: add partnerLicenseKey and partnerTier to subscription schema

* feat: add repo funcs

* feat: set up partner webhook handler
2026-05-12 22:39:07 +01:00
Henry
4459fbb4b2 fix: pin pnpm version in dockerfile to prevent corepack fetching latest 2026-05-11 15:49:34 +01:00
hjball
507c553778 chore: update translations 2026-05-11 12:45:01 +00:00
Henry
3d9e859b31 refactor: small tweaks to password reset flow 2026-05-11 13:43:38 +01:00
hjball
54dcd08d9a chore: compile translations 2026-05-11 12:42:35 +00:00
hjball
468aeef8b6 chore: update translations 2026-05-11 12:42:31 +00:00
Morfixx
71205b67dc fix: unable to change password when using magic link (#484)
* fix: fixed password not resetting & added password prompt with save guards

* fix: migrated to using setPassword
2026-05-11 13:40:59 +01:00
Midas
607672718c Add cardPublicId to card webhook payloads (#490)
* Add cardPublicId to card webhook payloads

* fix: include card publicId in webhook payloads

Add data.card.publicId to webhook payloads while keeping data.card.id for compatibility, and update card/webhook call sites and tests so webhook consumers can reliably use public IDs.
2026-05-11 13:22:21 +01:00
Henry
dd262ee50f fix: allow reordering without supplying an index when updating cards (#492) 2026-05-11 13:18:07 +01:00
Henry
82c8eef0c2 Update README.md 2026-05-05 21:50:50 +01:00
hjball
ea116268ff chore: update translations 2026-05-05 20:49:45 +00:00
Henry
74384d841e Update README.md 2026-05-05 21:48:29 +01:00
hjball
c0962e9f70 chore: compile translations 2026-04-28 21:31:18 +00:00
hjball
3e6aec2d6b chore: update translations 2026-04-28 21:31:14 +00:00
Florent Azonnoudo
4658b2961c feat: add ticket numbers to cards with workspace prefix (#443)
* feat: add ticket numbers to cards with workspace prefix

- Add cardNumber to cards
- Add cardPrefix/cardCounter to workspace
- Populate initial prefixes and numbers via new migration
- Introduce generateWorkspacePrefix util and export it
- Include cardNumber in card-related API responses and search results
- Render and display tickets as PREFIX-NUMBER in UI
- Update Card component to accept ticketNumber
- Update CardModal and board cards to show number when available
- Add cardNumber to CommandPalette search results type

* feat: regen migration

* feat: enhance card and workspace schemas with cardNumber and indexing

- Updated the card repository to allocate card numbers atomically per workspace.
- Modified the card schema to include a cardNumber field and added an index on listId and cardNumber for improved query performance.
- Enhanced the workspace schema to include an index on cardPrefix for optimized lookups.
- Adjusted the migration journal to reflect the new schema changes and their timestamps.
- Updated the regex in workspace repository to allow alphanumeric prefixes in ticket IDs.

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-04-28 22:29:41 +01:00
hjball
bc8b5463b3 chore: compile translations 2026-04-20 21:37:14 +00:00
hjball
9eee59a877 chore: update translations 2026-04-20 21:37:10 +00:00
Henry
e32e400976 chore: clean up types (#476) 2026-04-20 22:35:39 +01:00
Louie Chillingworth
b4b07f3120 fix: remove duplicate OIDC provider registration in getSocialProviders (#475)
The getSocialProviders endpoint was manually pushing "oidc" to the 
providers array when OIDC env vars are present, but Better Auth already 
includes the OIDC provider in ctx.context.socialProviders when it is 
configured. This resulted in ["oidc","oidc"] being returned from 
/api/auth/social-providers, causing a duplicate provider registration 
that led to a TypeError: Cannot read properties of null (reading 'id') 
during the OIDC login callback, preventing all OIDC logins.

Fix: remove the manual providers.push("oidc") block as it is redundant.
2026-04-20 21:20:23 +01:00
vvaswani
e39faa1172 fix: use stable public ids for card webhook (#463)
Signed-off-by: Vikram Vaswani <2571660+vvaswani@users.noreply.github.com>
2026-04-20 21:19:31 +01:00
hjball
b46979f539 chore: update translations 2026-04-18 20:42:20 +00:00
Nick Meinhold
f4237fffe6 fix: redirect to boards list after archiving/unarchiving a board (#459)
Previously, archiving or unarchiving a board left the user on the
(now archived/unarchived) board view with no clear navigation path.
This redirects to /boards after either action, matching the existing
behaviour of delete and move operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 21:41:04 +01:00
Redox
1d9bb96367 Don't default NEXT_PUBLIC_STORAGE_URL to empty string (#455) 2026-04-18 21:33:29 +01:00
hjball
bcb449e394 chore: update translations 2026-04-18 20:30:54 +00:00
Morfixx
ae4bc77407 feat: added schemas for openapi (#452) 2026-04-18 21:29:35 +01:00
hjball
6056167063 chore: update translations 2026-04-18 08:33:16 +00:00
Henry
adf91c7dfd refactor: remove legacy code from NewWorkspaceForm (#471) 2026-04-18 09:31:51 +01:00
hjball
bba9a94e45 chore: update translations 2026-04-17 21:23:27 +00:00
Henry
e36eb64c59 fix(cloud): enable upgrading new and existing workspaces onto team/pro (#470)
* feat(cloud): enable upgrading new and existing workspaces onto team/pro

* refactor: clean up

* fix: toggle spacing
2026-04-17 22:22:14 +01:00
hjball
201fe05493 chore: update translations 2026-04-11 07:19:15 +00:00
Henry
25490b2223 feat(cloud): temporarily disable context menu 2026-04-11 08:17:59 +01:00
hjball
b3108942db chore: compile translations 2026-04-11 07:01:09 +00:00
hjball
2ba759966e chore: update translations 2026-04-11 07:01:05 +00:00
Henry
7045cd8e25 feat(cloud): improved workspace onboarding (#462)
* feat: setup onboarding and select plan page

* feat: add workspace details step

* refactor: optimize board rendering in workspace details

* feat: tweak padding in boards mockup

* fix: validate/convert slug input

* refactor: tweak onboarding steps UX

* feat: add team to workspace plans

* feat: allow setting description when creating a workspace

* feat: add logging to next api routes

* feat: create worspace on checkout.session.completed

* chore: update types

* refactor: improve webhook logs

* feat: create workspace on successful activation

* feat: set slug on workspace name change

* feat: add returnUrl params
2026-04-11 07:59:26 +01:00
hjball
603cc46b7d chore: compile translations 2026-04-01 13:35:04 +00:00
hjball
e9d338384a chore: update translations 2026-04-01 13:34:59 +00:00
Nick Meinhold
8a2a9ecef5 fix: avatar upload display and oversized crop on HiDPI screens (#442)
Two related avatar bugs:

1. getAvatarUrl() returned empty string for S3 keys, so uploaded
   avatars never displayed. Now constructs the full URL using
   NEXT_PUBLIC_STORAGE_URL and NEXT_PUBLIC_AVATAR_BUCKET_NAME,
   with support for both path-style (MinIO) and virtual-hosted
   (Tigris/AWS S3) URLs.

2. Avatar crop scaled canvas by devicePixelRatio (4x pixels on
   Retina), producing blobs that exceeded the 2MB upload limit
   even for small source images. Now caps output at 512x512 and
   uses quality=0.85 for toBlob().

Closes #440, closes #441

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 14:33:11 +01:00
Charity
3a44957662 feat: add card context menu and duplication functionality (#381)
* feat: add card context menu and duplication functionality

* Implemented a context menu for cards allowing actions such as copying links, duplicating cards, and managing members, labels, and due dates
* Added modals for card duplication and context actions.
* Updated API with a new endpoint for duplicating cards, including options for copying labels, members, and checklists

* refactor: remove cardPublicId from context menu and related components

* Removed cardPublicId prop from CardContextMenu and CardContextMembersModal for cleaner context handling
* Updated scrollbar styling

* feat: add delete card functionality to context menu
2026-04-01 14:32:56 +01:00
Henry
20e6c99e32 feat: enhance logging with request input (#450) 2026-03-23 22:31:02 +00:00
Henry
c0aae5449f fix: prevent invalid calls to fetch label by publicId (#449)
* fix: prevent invalid calls to fetch label by publicId

* fix: ensure query only runs when isEdit is true
2026-03-23 21:40:35 +00:00
Henry
596f346181 fix: increase maximum length for boardSlug from 24 to 60 characters (#448) 2026-03-23 16:30:29 +00:00
Henry
7b59e2ac07 feat(cloud): log to docker container 2026-03-22 07:21:30 +00:00
Henry
2a9b1fd2b5 refactor: replace @axiomhq/pino with @axiomhq/js 2026-03-20 13:56:52 +00:00
Henry
a2d4314e14 fix: update Dockerfile to include pino transport packages 2026-03-20 13:26:27 +00:00
Henry
f94e4d6da2 fix: include pino transports in outputFileTracingIncludes 2026-03-20 12:04:46 +00:00
Henry
97f4defc0e fix: add pino to serverExternalPackages 2026-03-20 11:46:08 +00:00
Henry
dd29dc89fd fix: remove broken dep 2026-03-19 21:33:44 +00:00
Henry
486cdf8313 feat(cloud): setup log drain (#447) 2026-03-19 21:16:47 +00:00
Henry
67cf523ee8 fix: API key authentication and REST handler errors (#446) 2026-03-19 12:20:19 +00:00
hjball
0b45ceff7f chore: update translations 2026-03-16 14:32:37 +00:00
Henry
76b2c58461 fix: security improvements to checklist input (#444)
* feat: sanitize input for checklists

* feat: add basic editor

* feat: use plain text editor for checklist items

* feat: remove key from user response
2026-03-16 14:31:13 +00:00
Henry
b8d1fe230d fix: prevent empty string from throwing error 2026-03-13 23:08:46 +00:00
hjball
bd652ba6c9 chore: update translations 2026-03-13 22:59:04 +00:00
Henry
1fb56aafd0 feat: add logger package to improve observability (#437)
* feat: add logger package to improve observability

* chore: add LOG_LEVEL to docker compose and readme
2026-03-13 22:57:47 +00:00
hjball
91deaca6ec chore: compile translations 2026-03-13 22:45:15 +00:00
hjball
ab81db0f56 chore: update translations 2026-03-13 22:45:11 +00:00
Andy Clemenko
29080fa851 Fontsize PR for Issue #429 (#430)
* adding font size

* removing trash

* moving font menu

* chore: revert port change in compose

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-13 22:43:50 +00:00
hjball
2360c5075d chore: compile translations 2026-03-11 20:06:56 +00:00
hjball
79c2b5f3d5 chore: update translations 2026-03-11 20:06:52 +00:00
Matt
8e7a95ff2b feat: workspace start of week column (closes #361) (#399)
* feat: workspace start of week column

* feat(l10n): add workspace setting for the first day of the week

Fixes #361

* feat: add Saturday as option

* chore: fix migration order

* chore: fix merge

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-11 20:05:03 +00:00
hjball
94fd2cb11f chore: update translations 2026-03-10 22:45:30 +00:00
Henry
2a8af514d1 chore: add missing env vars to docker compose 2026-03-10 22:44:09 +00:00
hjball
0b405e0456 chore: compile translations 2026-03-10 22:35:57 +00:00
hjball
f1f3a00c17 chore: update translations 2026-03-10 22:35:53 +00:00
wotan-allfather
0fb2bac102 fix: add 404 not found page (#349)
* fix: add 404 not found page

Closes #320

- Add custom 404 page matching the app's design system
- Include PatternedBackground and proper dark mode support
- Provide navigation links to homepage and boards
- Add i18n support with @lingui/react

* fix: add 404 redirects for non-existent boards and cards

Add useEffect hooks to redirect to /404 page when:
- Board does not exist in board view
- Card does not exist in card view

This addresses the cases mentioned in maintainer feedback where
the static 404 page wasn't being used for dynamic routes.

Addresses review feedback on PR #349

* feat: redirect to 404 for non-existent public workspaces and cards

Per maintainer feedback:
- Add redirect logic to public boards view when workspace doesn't exist
- Add redirect logic to public card modal when card doesn't exist
- Uses error?.data?.code === 'NOT_FOUND' check as suggested
- Closes modal before redirecting for card modal
2026-03-10 22:34:28 +00:00
hjball
a7b71ae764 chore: compile translations 2026-03-10 22:33:49 +00:00
hjball
a246514b0b chore: update translations 2026-03-10 22:33:45 +00:00
Henry
53397d8e81 feat: add url validation to download attatchment endpoint (#432) 2026-03-10 22:31:59 +00:00
Nick Meinhold
1f9f07df20 feat(web): add webhook management UI (#394)
* feat(api): add webhook CRUD API router and tests

Add tRPC router for managing workspace webhooks:

- list, create, update, delete endpoints (admin role required)
- test endpoint to send a synthetic payload to a webhook URL
- URL validation, event subscription filtering
- Unit tests for all router procedures
- Integration tests with PGlite test database
- Add vitest config and test infrastructure for API package

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use assertPermission instead of assertUserInWorkspace

Replace assertUserInWorkspace with assertPermission("workspace:manage")
per project conventions. The permissions system is the preferred
authorization approach for new code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use @kan/db alias instead of relative imports in tests

Replace relative path imports (../../db/src/...) with the @kan/db
alias configured in vitest.config.ts for consistency and robustness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use webhookUrlSchema in router input validation

Cherry-pick router-related changes from b2cc9ac:
- Use extracted webhookUrlSchema zod validator in create/update
  input schemas for consistent SSRF checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): replace dynamic import with static import for webhook utility

Add packages/api/src/utils/webhook.ts with sendWebhookToUrl,
createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic
import() in the test endpoint with a static import at the top of the
file for better tree-shaking, type-checking, and readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): align sendWebhooksForWorkspace tests with merged PR #392

The merged delivery utility uses client-side event filtering
(getActiveByWorkspaceId takes 2 args, not 3). Update test assertions
to match the actual implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(web): add webhook management UI

Add settings page for managing workspace webhooks:

- Add webhooks page route and settings navigation link
- Add webhook list view with status toggles and action menus
- Add create/edit modal with URL validation and event selection
- Add delete confirmation dialog
- Add WEBHOOKS_ENABLED env flag for feature gating

Depends on #393 (CRUD API router).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): remove dead env vars, extract TableRow, import webhookEvents

- Remove unused WEBHOOK_URL and WEBHOOK_SECRET env vars (leftovers
  from earlier env-var-based design)
- Move TableRow component outside WebhookList to avoid re-creation
  on every render
- Import webhookEvents from @kan/db/schema instead of hardcoding
- Simplify formatDate to only handle Date objects (strings are not
  returned by tRPC/Superjson)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): gate webhooks settings tab to admin role

The webhook API requires admin role, but the settings tab was visible
to all users (condition: true). Now matches the API's authorization
requirement, addressing reviewer feedback on PR #394.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(web): use webhookEvents constant for form defaults

Replace hardcoded event arrays with [...webhookEvents] in
NewWebhookModal so default values stay in sync if new events
are added to the schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): use date-fns with locale for webhook date formatting

Replace hardcoded toLocaleDateString('en-US') with date-fns format()
using the useLocalisation() hook's dateLocale, matching the pattern
used throughout the codebase (ActivityList, DateSelector, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:31:45 +00:00
Nick Meinhold
1d5e3a936c feat(api): add webhook CRUD API router and tests (#393)
* feat(api): add webhook CRUD API router and tests

Add tRPC router for managing workspace webhooks:

- list, create, update, delete endpoints (admin role required)
- test endpoint to send a synthetic payload to a webhook URL
- URL validation, event subscription filtering
- Unit tests for all router procedures
- Integration tests with PGlite test database
- Add vitest config and test infrastructure for API package

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use assertPermission instead of assertUserInWorkspace

Replace assertUserInWorkspace with assertPermission("workspace:manage")
per project conventions. The permissions system is the preferred
authorization approach for new code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use @kan/db alias instead of relative imports in tests

Replace relative path imports (../../db/src/...) with the @kan/db
alias configured in vitest.config.ts for consistency and robustness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use webhookUrlSchema in router input validation

Cherry-pick router-related changes from b2cc9ac:
- Use extracted webhookUrlSchema zod validator in create/update
  input schemas for consistent SSRF checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): replace dynamic import with static import for webhook utility

Add packages/api/src/utils/webhook.ts with sendWebhookToUrl,
createCardWebhookPayload, and webhookUrlSchema. Replace the dynamic
import() in the test endpoint with a static import at the top of the
file for better tree-shaking, type-checking, and readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): align sendWebhooksForWorkspace tests with merged PR #392

The merged delivery utility uses client-side event filtering
(getActiveByWorkspaceId takes 2 args, not 3). Update test assertions
to match the actual implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Henry <30578846+hjball@users.noreply.github.com>
2026-03-10 22:27:52 +00:00
Thomas Goetten
0b49f502a9 feat: make avatar upload size limit configurable via environment variable (#375) 2026-03-10 22:16:20 +00:00
Amin NAIRI
400dcec56d Allow installing this app as a Web App (#419)
* added an icon of 512x512 for the manifest

* added a web app manifest

* added a screenshot for richer install on mobile

* added a screenshot for richer install ui on desktop

* added a document to include the manifest

* fixed the command to generate a better auth secret

* moved the link tag directly into the PageHead component
2026-03-10 22:13:58 +00:00
hjball
dfcdc5e47e chore: update translations 2026-03-10 22:00:41 +00:00
kOaDT
32e77e0291 fix: preserve board scroll position when navigating back from a card (#431) 2026-03-10 21:59:22 +00:00
Henry
a6780e3e28 fix: show correct highlighting in pricing comparison table 2026-03-03 22:31:08 +00:00
hjball
2c5195a2b8 chore: update translations 2026-03-03 22:17:28 +00:00
Henry
793fa30162 fix: adjust icon size in CardPage component 2026-03-03 22:16:09 +00:00
Henry
af63719de5 fix: swap latest to only publish on version tags and add edge for builds (#428) 2026-03-03 22:14:35 +00:00
hjball
52fd624b81 chore: update translations 2026-03-03 21:37:46 +00:00
Henry
b4d4810f45 fix: migration order (#427) 2026-03-03 21:36:31 +00:00
hjball
239d152340 chore: compile translations 2026-03-01 22:41:43 +00:00
hjball
f294760747 chore: update translations 2026-03-01 22:41:39 +00:00
Morfixx
280d8f66dd feat: initial github integration with importing projects (#421)
* feat: initial github integration with importing projects

* fix: remove unused args

* chore: remove duplicate col

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-03-01 22:39:25 +00:00
Henry
eeae23a24c docs: add migrate service to self hosting guide (#425)
* docs: add migrate service to self hosting guide

* chore: remove build from readme compose
2026-02-28 23:08:59 +00:00
Henry
d81950b8bd fix: publish migrate image (#423) 2026-02-27 22:38:01 +00:00
hjball
e530f39360 chore: update translations 2026-02-27 13:16:47 +00:00
Nick Meinhold
bd25fb33f7 feat(api): add webhook delivery utility and card event integration (#392)
* feat(api): add webhook delivery utility and card event integration

Add the core webhook delivery logic and wire it into card mutations:

- Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout
- Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget)
- Add createCardWebhookPayload() for building webhook payloads
- Fire webhooks on card create, update, move, and delete events
- Add unit tests for webhook utility functions

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use correct boardId in webhook payloads and add rejection safety

- Fix bug where workspaceId was incorrectly passed as boardId in all
  webhook payloads — now uses board's publicId via boardPublicId
- Replace void sendWebhooksForWorkspace() with .catch() to prevent
  unhandled promise rejections if the DB query inside fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): add SSRF protection to webhook delivery

Block webhook URLs targeting internal networks:
- Require HTTPS (reject HTTP)
- Block localhost, 127.0.0.1, ::1, 0.0.0.0
- Block cloud metadata endpoints (169.254.169.254, metadata.google.internal)
- Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
- Add tests for all blocked URL patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use WebhookEvent type from schema instead of duplicating

Replace the hardcoded WebhookEventType union with the canonical
WebhookEvent type from @kan/db/schema, addressing reviewer feedback
on PR #392.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): improve webhook delivery safety and validation

Cherry-pick delivery-related changes from b2cc9ac:
- Extract URL validation into reusable webhookUrlSchema zod validator
  for SSRF checks
- Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled
  promise rejections
- Document SSRF risk mitigation on sendWebhookToUrl
- Add corresponding tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:15:35 +00:00
hjball
93f2816b37 chore: compile translations 2026-02-26 21:41:09 +00:00
hjball
2292b972c4 chore: update translations 2026-02-26 21:41:05 +00:00
Eliott Herbert-Byrnes
af36fb133a feat: archived boards (#386)
* init: schema migration, isArchived added to
board table. refactor: board repo for isArchived filtering

* init: archived, unarchived API procedures. refactor: all query

* fix: migration error

* feat: add tabbed navigation for boards view

* Implemented a Listbox for mobile and a tabbed navigation for desktop to switch between "Boards" and "Archived" views.
* Introduced state management for active tab selection.
* Updated UI components to reflect the new navigation structure.

* init: frontend/boards lists & tabs

* chore: fixed font styling and spacing

* init:boardDropdown / boardView.

* chore:added translations

* Remove .cursor plan file from repo

* fix:build erros

* revert: remove locales changes

* fix:reverted changes under locales, replaced the archive and unarchive endpoints. Reorder migrations

* fix:migration issue

* fix: update journal.json

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-26 21:39:15 +00:00
hjball
9cb1fb5218 chore: update translations 2026-02-26 21:26:17 +00:00
Nick Meinhold
5f190b92de fix: allow invited users to sign up when registration is disabled (#418)
* fix: allow invited users to sign up when registration is disabled

Move sign-up restriction logic from better-auth's disableSignUp config
to the existing user.create.before database hook, which already checks
for pending invitations. The frontend signup and login pages now detect
invite flows (?next=/invite/...) and bypass the disabled UI accordingly.

Closes #411

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add regression tests for sign-up hook invite bypass

Verify that the user.create.before database hook correctly:
- allows sign-up when registration is not disabled
- blocks sign-up when disabled and no invitation exists
- allows sign-up when disabled but a pending invitation exists
- respects BETTER_AUTH_ALLOWED_DOMAINS in combination with invites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add OIDC/social sign-up path coverage for invite bypass

Address review suggestion: add explicit tests verifying the
user.create.before hook handles OIDC/social sign-ups the same way as
email/password — invited users are allowed, uninvited users are blocked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:24:58 +00:00
Nick Meinhold
48a1f39588 fix(api): add missing openapi meta to checklist.update (#417)
The checklist.update procedure was inheriting the default
protectedProcedure meta (GET /protected) instead of declaring
its own OpenAPI route, making it unreachable via the REST API.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:12:02 +00:00
hjball
c7fce10fa7 chore: update translations 2026-02-26 21:05:45 +00:00
Matej
094e1d139d fix: make links clickable in comments (#376) (#415)
Move Link extension before Markdown in the TipTap extensions array
to ensure URL detection happens before markdown processing. Also
explicitly enable linkOnPaste for paste detection.

Fixes #376

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 21:04:31 +00:00
Henry
fae169c691 fix: ensure deps are installed during translate workflow 2026-02-23 23:11:52 +00:00
Henry
91bc319cf1 fix: prevent cross-workspace access on member and permission endpoints (#413) 2026-02-23 22:56:36 +00:00
Henry
d42540b9ef chore: update translations to json (#408)
* chore: update translations format

* chore: compile to ts
2026-02-20 21:38:01 +00:00
Henry
71ecf2e6d3 fix: catch and log failed email attempts 2026-02-20 15:42:07 +00:00
Henry
8a9cb8fabf chore: trigger docker publish after translate workflow 2026-02-19 22:30:19 +00:00
Henry
37f6be52ee chore: improve translation workflow 2026-02-19 22:26:20 +00:00
hjball
b72449bef1 chore: compile translations 2026-02-19 21:39:32 +00:00
Henry
5cb47a588f fix: update file pattern for translation workflow 2026-02-19 21:38:25 +00:00
Lingo.dev
d5c2404715 chore: update translations 2026-02-19 21:23:58 +00:00
Henry
0276c51ac7 fix: correct broken workflow 2026-02-19 21:22:58 +00:00
Henry
1eee819c9d fix: typo 2026-02-19 21:20:40 +00:00
Henry
2113cc7873 fix: update translate workflow 2026-02-19 21:19:46 +00:00
Henry
32714ccedf fix: update commit message format in translation workflow 2026-02-19 21:12:41 +00:00
Henry
e72b34b541 feat: setup translation workflow 2026-02-19 21:11:02 +00:00
Henry
b44a1296a0 feat: update remote patterns to be more permissive 2026-02-19 14:38:09 +00:00
Henry
3bf132286b fix: use correct context 2026-02-19 13:17:49 +00:00
Henry
c9c3365c0a fix: use correct path to dockerfile 2026-02-19 13:15:06 +00:00
Henry
7c2937a7b7 fix: add migrator container name to docker compose 2026-02-19 13:12:38 +00:00
Henry
f27a031ea8 feat(cloud): add migrator to docker-compose 2026-02-19 12:52:58 +00:00
Henry
05a0245988 feat: improve pricing tiers and add comparison table (#405) 2026-02-18 22:46:32 +00:00
Matt
b6b9423823 fix: increase max workspace url length from 24 to 64 characters (closes #379( (#398) 2026-02-17 13:40:58 +00:00
Owais Rizvi
1696aab43b fix: handle empty commenter name in mention notification emails (#400)
Use `||` instead of `??` so empty strings also fall back to email.
The nullish coalescing operator (`??`) only catches null/undefined,
so users with an empty name string would appear nameless in emails.
2026-02-17 13:39:24 +00:00
Nick Meinhold
33d2f23955 feat(db): add webhook schema, migration, and repository (#391)
* feat(db): add webhook schema, migration, and repository

Add the database foundation for workspace webhooks:

- Add workspace_webhooks table with migration (webhook_event enum,
  URL, secret, event subscriptions, active flag)
- Add webhook repository with CRUD operations
- Add webhooks schema definition with relations
- Extend card and list repos to return board/list names for
  webhook payload context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): remove unused webhook_event enum and fix migration timestamp

- Remove dead webhook_event pgEnum from schema (events column uses text)
- Remove CREATE TYPE statement from migration SQL
- Fix migration journal timestamp to be chronologically after the
  notifications migration (idx 25)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(db): return boardPublicId from card and list repo queries

Add board publicId to getWorkspaceAndCardIdByCardPublicId and
getWorkspaceAndListIdByListPublicId return values, needed for
correct boardId in webhook payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): add workspaceId index and document secret exposure

- Add index on workspaceId for efficient webhook lookups per workspace
- Add JSDoc comment on getActiveByWorkspaceId explaining that it
  returns secrets for server-side HMAC signing only and must never
  be exposed via client-facing endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(db): extract parseEvents helper in webhook repo

DRY up 6 repeated JSON.parse-and-cast calls into a single helper
function, addressing reviewer feedback on PR #391.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:23:00 +00:00
Morfixx
961219835e refactor: docker image for less final size (#385) 2026-02-17 13:12:53 +00:00
Nick Meinhold
031a42b9eb fix: generate presigned avatar URLs for card members on board view (#388)
The board.byId query was generating presigned S3 URLs for workspace
member avatars but not for card member avatars. This caused card avatars
to silently fall back to initials since the frontend helper returns ""
for raw S3 keys.

Closes #387

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:10:23 +00:00
Matt
8a1189d011 feat: Activity log for uploading attachments (closes #354) (#366)
* feat: Activity log for uploading attachments (closes #354)

* chore: fix coding style

* revert: upload spinning icon patch

* refactor: add fallbacks, extended card_activity
2026-02-15 20:53:01 +00:00
Charity
53f6ada4d3 feat: add close button to card view header (#383) 2026-02-11 13:30:05 +00:00
Henry
6ea02b1db1 feat: send email notifications on user mentions (#372)
* feat: add notifications schema

* chore: gen migration

* feat: add mention email template

* feat: add sendMentionEmail func

* feat: add repo funcs

* feat: update card router to send emails on mention

* fix: update the editor suggestion to show all members

* feat: skip pending members in sendMentionEmails

* feat: update comments to use tiptap editor
2026-02-11 13:10:46 +00:00
Matt
737b1383d0 feat: add two types of roadmap templates (#369) 2026-02-11 13:03:18 +00:00
Matt
38d0e9495c Join Workspace Email improvements (#371)
* fix failing on percent-encoding invitation urls

* feat: add the workspace and inviter names

* fix: respect the NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY environment variable
2026-02-08 21:34:17 +00:00
Henry
19798ddbc9 fix: update pnpm-lock 2026-02-08 21:11:26 +00:00
Matt
08ba965921 fix: handle x-forwarded-host and proxy issues behind Cloudflare (#370) 2026-02-08 20:59:01 +00:00
kOaDT
008b5fd1a0 fix: prevent long card titles from overflowing column width (#367) 2026-02-08 20:54:44 +00:00
Henry
97cfd60b59 fix: use multipart upload for attachments to resolve chunk size error (#368) 2026-02-07 22:42:38 +00:00
Ivan Shelepugin
24e283e22c feat: smart typography (#360)
* build(web, deps): add `@tiptap/extension-typography`

* feat(editor): add typography extension

* chore(editor): disable some typographic characters

* chore(editor): disable superscript
2026-02-07 20:04:37 +00:00
Matt
07db7409cb fix: remove phantom spacing below social providers in auth form (closes #334) (#364) 2026-02-07 19:34:06 +00:00
Charity
346ddc16be feat: add functionality to copy card link (#358)
* Implemented link copying feature in CardDropdown and CardModal components.
* Updated Dropdown component to accept new props for card identification.
2026-02-06 23:34:23 +00:00
Charity
f9eee53ed0 fix: board and workspace URL links and copy behavior (#357)
- Use /boards/{id} for private board links so copied link works; keep pretty URL for public boards
- Show matching path in board header (pretty path for public, boards/id for private)
- Copy link button copies URL to clipboard instead of opening in new tab (opening a new tab seems redundant since we're already on it)
- Edit workspace URL button links to /settings/workspace instead of account settings
2026-02-06 23:31:08 +00:00
Henry
1ab4e24c96 docs: add railway deployment template to self hosting docs (#363) 2026-02-05 21:52:40 +00:00
Henry
34165b390f fix: handle missing board in board router 2026-02-04 22:56:17 +00:00
Henry
ef0d53db8f refactor: standardise S3 URL generation (#362)
* refactor: replace presigned URL uploads with backend upload endpoints

* feat: update avatar upload to use new endpoint

* refactor: use createS3Client in auth hooks

* feat: generate presigned URLs for avatars

* fix: show avatar image in user menu

* fix: hide tooltip if content is empty

* fix: support external avatar URLs in generateAvatarUrl

* fix: remove content type restriction on attachments
2026-02-04 22:40:14 +00:00
Henry
78b9de869f fix: prevent validation errors in workspace/card queries (#356)
* fix: prevent workspace.byId validation errors when publicId is empty

* fix: add keys to shortcut elements

* fix: prevent card.byId validation errors when cardPublicId is empty

* chore: remove invalid config option from next.config.js
2026-02-02 22:25:54 +00:00
Henry
81c03b51e2 fix: fallback to email when user name is missing in activity list (#355)
* fix: fallback to email when user name is missing in activity list

* chore: gen translations
2026-02-02 22:00:03 +00:00
Henry
0d3d9a358f fix: prevent ellipses from showing through member dropdown menu (#352) 2026-02-02 20:05:53 +00:00
Henry
2269d23c94 fix: return empty permissions if WorkspaceProvider is not available 2026-02-01 23:23:06 +00:00
Henry
e437d075e3 fix: remove duplicate statement 2026-02-01 22:58:57 +00:00
Henry
b422907b53 fix: skip new col if already exists 2026-02-01 22:52:28 +00:00
Henry
672dfe6540 fix: ensure publicId is returned from create card (#350) 2026-02-01 22:42:13 +00:00
Eliott Herbert-Byrnes
ef5bf87fdf feat: add board favourites with animations (#318)
* feat: add board favorites with animations

* feat: add board favorites functionality with admin-only access

* refactor: make board favorites user-specific via junction table

* fix: assertUserInWorkspace

* Update pnpm lockfile to match package.json

* chore: clean up migrations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-01 22:29:51 +00:00
Nick Meinhold
ec37f5480a fix(api): include name and slug in workspace byId response (#347)
The GET /workspaces/{id} endpoint was missing the workspace's name and
slug fields because they weren't included in the column selection.

Fixes #346

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:29:37 +00:00
Henry
7f5a1ab513 feat: customisable workspace role permissions (#345)
* feat: setup schema for workspace roles

* chore: regen migration

* feat: add publicId to workspace roles

* feat: setup default permissions

* feat: add repo funcs

* feat: setup basic router interactions

* feat: add card permissions

* feat: assert permissions for lists

* feat: assert board permissions

* feat: assert permission for remaining routes

* feat: add permissions page to settings

* feat: enable updating member roles

* feat: order members by role and createdAt

* feat: allow editing individual permissions

* feat: reset role defaults

* feat: clear all permission overrides

* feat: allow users to delete entities they have created

* feat: set roleId when inviting new members

* feat: disable UI elements if user does not have permissions

* feat: allow admins to assign the admin role to other users

* feat: allow delete:list as default

* refactor: centre permissions modal

* chore: translations
2026-02-01 21:17:18 +00:00
Henry
5f2d409773 fix: allow empty REDIS_URL in environment configuration 2026-01-26 11:44:17 +00:00
Henry
210e44db5c feat: add env var to use virtual hosted urls (#338) 2026-01-26 10:16:42 +00:00
Henry
befe7ab7f4 feat: setup AGENTS.md file (#337) 2026-01-25 22:40:33 +00:00
Henry
53a33c68fc feat: rate limit api routes (#336)
* feat: install rate-limiter-flexible and ioredis

* feat: setup redis client

* feat: add withRateLimit wrapper

* feat: wrap trpc endpoint in withRateLimit

* feat: wrap withRateLimit on remaining routes

* feat: exclude webhook route from rate limiting

* chore: update compose files and readme

* refactor: move into api/db packages
2026-01-25 22:28:41 +00:00
Henry
3bae03613d feat(cloud): reset workspace slug on subscription cancelled (#335) 2026-01-25 21:20:04 +00:00
Sachin Divekar
c42ee7cf2c fix: disable tooltips on touch devices (#333) 2026-01-25 13:41:59 +00:00
Henry
07f997c3ad fix: update localization for brazilian portuguese (#332)
* fix: update localization for brazilian portuguese

* compile
2026-01-24 22:51:32 +00:00
Sachin Divekar
d084d323bc feat: show user avatar on comments and activity (#331)
Display user profile pictures instead of initials on card comments
and activity items. Fetches the user image field from the database
and passes imageUrl to the Avatar component.

Co-authored-by: Sachin Divekar <sachin.divekar@remiges.tech>
2026-01-24 22:04:46 +00:00
Sachin Divekar
6729c2e228 fix: support virtual-hosted S3 URLs for avatar storage (#330)
Avatar images failed to load with 403 on Tigris and newer AWS S3 buckets
because getAvatarUrl only constructed path-style URLs. Modern S3-compatible
providers require virtual-hosted URLs (bucket.domain/key).

Add NEXT_PUBLIC_STORAGE_DOMAIN env var to signal URL style:
- If set: virtual-hosted URL (Tigris, AWS S3)
- If not set: path-style URL (MinIO, LocalStack) - backward compatible

Add vitest and tests for getAvatarUrl behavior.

Co-authored-by: Sachin Divekar <sachin.divekar@remiges.tech>
2026-01-24 22:02:41 +00:00
Charity
b18ef10313 refactor: simplify drag-to-scroll logic to enable dragging on any non-interactive element within the container (fixes my oopsie) (#328) 2026-01-24 21:43:12 +00:00
Charity
03bd2d771b Hide member emails from non-admins (#327)
* feat(workspace): sanitize member emails for non-admin users in workspace router

* feat(workspace): add email visibility setting and update member display logic

* fix(workspace): update default value for showEmailsToMembers column to true

* - Removed unnecessary type assertions
- Added anonymous name handling (anonymous_[publicId]) in the API
- Removed placeholder logic
- Reverted null check
- Avoided as unknown cast
2026-01-24 21:41:59 +00:00
Redox
ff7944a4a2 Show display name instead of email (#323)
* Show display name instead of email

* Properly pass displayName

* Show email on settings page too

* Show email on missing username
2026-01-24 21:34:38 +00:00
Redox
0ca9c1d0e6 Add NEXT_API_BODY_SIZE_LIMIT config (#325)
* Add `NEXT_API_BODY_SIZE_LIMIT` config

* Add NEXT_API_BODY_SIZE_LIMIT env var to more files
2026-01-24 21:22:32 +00:00
Charity
927bf2fe69 feat: implement horizontal drag-to-scroll functionality in board views (#326) 2026-01-22 19:49:25 +00:00
Redox
ccd84385a2 Add Discord CDN avatars to allowed image sources (#322) 2026-01-22 14:23:25 +00:00
Henry
58c5e92155 feat(cloud): handle cancelled subscriptions (#313)
* feat(cloud): handle cancelled subscriptions

* chore: refactor auth
2026-01-16 20:20:22 +00:00
Henry
885f119404 chore: unshallow git clone 2026-01-15 20:15:19 +00:00
Henry
b17a24455a chore: generate app version during build 2026-01-14 21:21:04 +00:00
Henry
0c9561467d feat: display app version in user menu (#310)
* feat: display app version

* feat: add fetch depth
2026-01-13 12:48:07 +00:00
Henry
47b6f06be4 fix: set min height for new card editor (#305) 2026-01-06 09:13:19 +00:00
Eliott Herbert-Byrnes
89c8961176 feat: add YouTube video embed support for TipTap editor (#294)
* feat: add YouTube video embed support for TipTap editor

- Paste YouTube URLs to auto-embed videos with metadata
- Edit video title and URL through dropdown menu
- Convert embed back to plain URL link
- Fetch video titles automatically via YouTube oEmbed API

* fix:
- reverted NewCardForm CSS properties back to max-h-48.
- Added boolean prop to TipTap Edtior for YouTube embed.
- Disabled YouTube embed feature for NewCardForm Editor

* chore: lint

* chore: translations

* chore: update translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-01-05 20:15:07 +00:00
Henry
a5432c60b7 fix: recenter collapsed menu buttons (#303) 2026-01-03 20:55:37 +00:00
Henry
2b8fe3d3a2 fix: extend allowed card title length (#301)
* feat: update card title from varchar to text

* feat: add input length validation

* fix: slice title when longer than max chars during import
2025-12-29 22:50:16 +00:00
Henry
73c9b326a1 fix: map profile to user for OIDC (#297) 2025-12-29 21:45:35 +00:00
Henry
a55c8cd52b fix: update checklist when pressing enter on edit (#292)
* fix: update checklist when pressing enter on edit

* feat: open new checklist item form on enter
2025-12-19 21:40:24 +00:00
Henry
cca8a9d424 feat: health check and stats endpoints (#290) 2025-12-18 08:08:30 +00:00
Ridham Khandar
57186b6b4d feat: paginate and merge frequent activities (#274)
* feat: paginate and merge frequent activities

* feat: due date activities

* fix: requested changes fixes

* refactor: slight reshuffle and improve types

* chore: translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-12-16 09:07:08 +00:00
Eliott Herbert-Byrnes
3cb40d0f9a feat: implement drag-and-drop reordering for checklist items (#275)
* feat: implement drag-and-drop reordering for checklist items

* WIP: Changes before syncing with main

* feat: update drag icon and positioning

* refactor: consolidate checklist item updates into single endpoint

- Remove standalone reorderItem route (now part of updateItem)
- Add optional index parameter to updateItem for reordering
- Change updateItem from PUT to PATCH method
- Add deletedAt IS NULL filter to reorderItem SQL queries
- Follows existing pattern from card.update route

* feat: add optimistic updates

* chore: translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-12-15 10:01:04 +00:00
Henry
6ebab28606 feat(cloud): force payment method collection for subscription checkout (#285) 2025-12-14 21:55:03 +00:00
Henry
5765c71ebf feat: update logos (#284) 2025-12-13 23:14:25 +00:00
Henry
86ecdca7f1 fix: enable sessions for API keys (#283)
* fix: enable sessions for API keys

* chore: remove old due date filters

* chore: update lockfile

* fix: temp ignore eslint errors on build
2025-12-13 22:19:16 +00:00
Henry
bcd20b58c6 chore: remove instrumentation (#279)
* chore: remove instrumentation

* chore: clean apk cache after installation

* chore: fix docker builds
2025-12-10 22:16:38 +00:00
Henry
36675604f9 chore: upgrade next, react-email and better-auth (#278)
* feat: upgrade next and react-email

* chore: upgrade better-auth
2025-12-10 20:34:10 +00:00
Jovânio Júnior
94760f1e4c Add Brazilian Portuguese (pt-BR) translation support to Kan (#276)
* adding Brazilian Portuguese translation; added a repository on Docker Hub to use in Umbrel — version ptbr jovaniojunior/kan-ptbr:latest

* add notes to changelog.md

* adjust pt-BR date format in date-fns

* improve Portuguese integration across code functions and other small pt-BR tweaks

* chore: gen missing translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-12-09 20:24:30 +00:00
Henry
38f477bb5a fix: delete attachment from s3 (#272) 2025-12-05 22:04:53 +00:00
Henry
d208952d15 feat: card due dates (#271)
* feat: add card due dates to schema

* feat: update repo funcs

* feat: update card router to support due dates

* chore: update migration journal

* feat: add date selector

* feat: display date icon and label on cards

* feat: add due date filters

* feat: add due date to new card form

* feat: light mode tweaks

* feat: improve text eligibility on light mode

* feat: reduce selector font size

* feat: display date updates in card activity

* chore: gen translations
2025-12-05 21:49:45 +00:00
Eliott Herbert-Byrnes
88f1aa0ec4 feat: implemented addOrRemoveLabel mutation on CardPage #266 (#270)
* feat: implemented addOrRemoveLabel mutation on CardPage to automatically add new labels #266

* Remove .env.backup
2025-12-05 19:39:18 +00:00
Owais Rizvi
06ca6e38b3 feat: add IRSA support for S3 authentication (#265) 2025-12-01 19:54:58 +00:00
Patrick McEvoy
915877b8e6 feat: limit oidc domains (#263) 2025-12-01 19:51:08 +00:00
Henry
bb8f3de0ad feat: add env var to allow invalid SSL certs (#262)
* feat: add env var to allow invalid SSL certs

* Update readme
2025-11-30 23:00:55 +00:00
Henry
d3c8cbd6c0 fix: persist filters when opening card modal (#261) 2025-11-30 22:32:59 +00:00
Henry
6c3dbaefd3 Update README.md 2025-11-30 22:13:44 +00:00
Ivan Shelepugin
f938723d9a fix(api): correct values of Content-Disposition attributes (#260) 2025-11-30 21:58:40 +00:00
Henry
0928709173 chore: revert hardcoded baseUrl 2025-11-27 23:34:01 +00:00
Henry
ade9aad4bf feat: set next public base url as optional 2025-11-27 23:25:50 +00:00
Henry
5fbaf26707 fix: add missing dependency 2025-11-27 23:15:54 +00:00
Henry
b69835f7de fix: hardcode baseUrl 2025-11-27 23:11:48 +00:00
Henry
58ff6ad041 fix: use next-runtime-env for baseUrl 2025-11-27 22:37:26 +00:00
Spencer Simpson
adbc945ed6 feat: add support for keyboard shortcuts (#255)
* feat: add support for keyboard shortcuts

* fix: recenter icons on button

* feat: extend tooltip delay

* chore: add translations

* refactor: tweak kbd styling

* feat: move shortcuts to user menu

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-11-27 22:09:37 +00:00
Henry
c5d85cdb2d feat(cloud): email unsubscribe (#258)
* feat(cloud): add email unsubscribe

* chore: add translations
2025-11-27 21:44:51 +00:00
Henry
56e4c43cfa chore: update translations 2025-11-27 11:15:12 +00:00
Henry
07fc41761b fix: remove unused params from invite paths (#256) 2025-11-26 20:09:58 +00:00
Henry
50382e9186 feat(cloud): free trial notifications (#254) 2025-11-25 23:29:04 +00:00
Henry
00be5fb058 feat(cloud): add discord creds to subscribers 2025-11-25 13:22:43 +00:00
Henry
fc65f53e0c feat(cloud): add notification client (#253) 2025-11-24 22:20:34 +00:00
Henry
64aace6e3b feat: add breadcrumb navigation header to card view (#250)
* feat: add card breadcrumb navigation

* feat: wrap title onto new line
2025-11-21 21:34:12 +00:00
Henry
1651faae14 fix(cloud): reenable magic login on cloud and extend session duration (#248)
* fix(cloud): reenable magic login

* feat: extend session duration
2025-11-20 00:01:23 +00:00
Henry
b472c5ce93 feat: card attachments (#247)
* feat: add card attachment schema

* feat: setup s3 util funcs

* feat: add attachments router and repo funcs

* feat: add upload button

* feat: add thumbnails and attachment viewer

* feat: add download and delete button

* feat: add file viewer and downloads

* feat: update compose and readme

* chore: build lang

* feat: display attachments on public boards

* chore: update compiled translations
2025-11-19 21:34:43 +00:00
Arpit Soni
af6490896a feat: add list filter to board view (#241)
* feat: add list filter to board view

* feat: implement API-driven list filtering

* refactor: remove unnecessary list join from card filtering queries

* feat: enable list filter options for public boards

* refactor(perf): move to main query

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-11-17 21:25:50 +00:00
exception-raised
c0a3c37d95 feat: add tiptap markdown support, add checklist support from trello (#245)
* feat: add tiptap markdown support, add checklist support from trello

* feat(api): implement bulk creation of checklists and items
2025-11-17 20:31:57 +00:00
Morfixx
c30da618ec fix(web): logic of disabling credential login (#244)
* fix(web): logic of disabling credential login

If credentials are disabled, then don't show the email login form at all. If credentials are disabled and no social providers are present, show message saying none are available.

* chore: update lang

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-11-14 22:24:43 +00:00
359 changed files with 158043 additions and 23328 deletions

View File

@@ -1,18 +1,62 @@
# Environment
.env
.env.*
!.env.example
docker-compose.override.yml
Dockerfile
./**/*/Dockerfile
# Docker
docker-compose*.yml
.dockerignore
# Dependencies (rebuilt in Docker)
node_modules
./**/*/node_modules
**/node_modules
# Build outputs (rebuilt in Docker)
**/.next
**/dist
**/out
**/.turbo
**/.cache
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# CI/CD
.github
.husky
.changeset
# Documentation (not needed in build)
*.md
!packages/db/migrations/**
LICENSE
CONTRIBUTING.md
AGENTS.md
CHANGELOG.md
# Test files
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
**/__tests__
**/coverage
# Logs
pnpm-debug.log
./**/*/pnpm-debug.log
**/pnpm-debug.log
README.md
.next
.git
# Cloud compose (separate deployment)
cloud/

View File

@@ -2,7 +2,7 @@
# 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)
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
# Fill if you want to use an external database
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
@@ -17,30 +17,47 @@ SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
# Switch email features off entirely (optional)
NEXT_PUBLIC_DISABLE_EMAIL=
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# API configuration (optional)
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# Redis (optional - for rate limiting)
# If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
LOG_LEVEL=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
BETTER_AUTH_ALLOWED_DOMAINS=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
@@ -78,4 +95,3 @@ TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=

View File

@@ -7,6 +7,9 @@ assignees: ''
---
> [!IMPORTANT]
> Wait for a maintainer to approve this issue before opening a PR. We'll signal approval with a comment or label. Feature PRs without an approved issue will be closed.
## ✨ Feature Request
**Is your feature request related to a problem? Please describe.**

24
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,24 @@
## Description
<!-- What does this PR do? Why? -->
## Type of change
- [ ] Bug fix
- [ ] Feature (requires an approved issue — see below)
- [ ] Refactor / chore
- [ ] Documentation
## Checklist
- [ ] I have linked the related issue below
- [ ] My code follows the existing style and conventions
- [ ] I have tested my changes locally
- [ ] I have included screenshots for any UI changes
## Linked issue
Closes #<!-- issue number -->
> **Feature PRs without a linked, approved issue will be closed without review.**
> Open or comment on a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) first and wait for maintainer approval before building.

View File

@@ -6,23 +6,31 @@ name: Docker
# documentation.
on:
schedule:
- cron: "21 21 * * *"
push:
branches: ["main"]
# Publish semver tags as releases.
# Only trigger on tags (releases), not main branch pushes
# Main branch builds are handled by workflow_run after Translate completes
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
workflow_run:
workflows: ["Translate"]
types: [completed]
branches: [main]
# Docker builds after Translate completes (success or failure)
# This ensures Docker always has the latest translations
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs:
build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
permissions:
contents: read
@@ -34,6 +42,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
@@ -72,7 +82,58 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
- name: Extract Docker metadata (migrate)
id: meta-migrate
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_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=${{ github.ref_type == 'tag' }}
type=raw,value=edge,enable={{is_default_branch}}
# Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
- name: Extract version
id: version
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
VERSION="${{ github.ref_name }}"
# Remove 'v' prefix if present
VERSION="${VERSION#v}"
else
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
if [[ -n "$GIT_DESCRIBE" ]]; then
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
# Format as tag+commit (SemVer build metadata)
TAG_VERSION="${BASH_REMATCH[1]}"
COMMIT_HASH="${BASH_REMATCH[2]}"
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
# Exactly on a tag
VERSION="${BASH_REMATCH[1]}"
else
# Fallback: just commit hash
COMMIT_SHA="${{ github.sha }}"
VERSION="${COMMIT_SHA:0:7}"
fi
else
# No tags exist, use commit hash
COMMIT_SHA="${{ github.sha }}"
VERSION="${COMMIT_SHA:0:7}"
fi
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
@@ -86,6 +147,24 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push migrate Docker image
id: build-and-push-migrate
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: apps/web/Dockerfile
target: migrate
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta-migrate.outputs.tags }}
labels: ${{ steps.meta-migrate.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -103,3 +182,10 @@ jobs:
# 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}
- name: Sign the published migrate Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
TAGS: ${{ steps.meta-migrate.outputs.tags }}
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

56
.github/workflows/translate.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install dependencies
run: pnpm install --filter @kan/web...
- name: Extract translations
working-directory: apps/web
run: pnpm lingui:extract
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
commit-message: "chore: update translations"
working-directory: apps/web
commit-author-name: "${{ github.actor }}"
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
parallel: true
- name: Compile translations
working-directory: apps/web
run: pnpm lingui:compile
- name: Commit compiled translations
run: |
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
git config --local user.name "${{ github.actor }}"
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
git add apps/web/src/locales/*/messages.ts
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
fi

6
.gitignore vendored
View File

@@ -34,6 +34,7 @@ yarn-error.log*
.env
.env*.local
# vercel
.vercel
@@ -53,3 +54,8 @@ i18n.cache
# pgdata
/apps/web/pgdata
# local scripts
scripts/
.claude

333
AGENTS.md Normal file
View File

@@ -0,0 +1,333 @@
# AGENTS.md
## Project Overview
Kan is an open-source project management tool (Trello alternative) built with:
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
- **Backend**: tRPC, Node.js
- **Database**: PostgreSQL with Drizzle ORM
- **Monorepo**: pnpm workspaces with Turbo
- **Auth**: Better Auth
- **Internationalization**: Lingui
## Setup Commands
- Install deps: `pnpm install`
- Start dev server: `pnpm dev`
- Create migrations: `cd packages/db && pnpm drizzle-kit generate --name "AddFieldToTable"`
- Run database migrations: `pnpm db:migrate`
- Run linter: `pnpm lint`
- Run type check: `pnpm typecheck`
- Format code: `pnpm format:fix`
- Extract i18n strings: `pnpm lingui:extract`
## Project Structure
- `apps/web/` - Next.js web application
- `packages/api/` - tRPC API routers
- `packages/db/` - Database schema, migrations, and repositories
- `packages/auth/` - Authentication package
- `packages/shared/` - Shared utilities
- `packages/email/` - Email templates and sending
- `packages/stripe/` - Stripe integration
- `tooling/` - Shared tooling configs (ESLint, Prettier, TypeScript)
## Code Style
### TypeScript
- Use TypeScript strictly - avoid `any` types
- Prefer explicit types over inference when it improves clarity
- Use `as const` for literal types when appropriate
- Follow existing patterns for type definitions
### Naming Conventions
- **Files**: kebab-case for files (e.g., `card-repo.ts`)
- **Components**: PascalCase for React components
- **Functions**: camelCase for functions
- **Constants**: UPPER_SNAKE_CASE for constants
- **Types/Interfaces**: PascalCase
### Database Layer (`packages/db/`)
- **Schema**: Define schemas in `src/schema/` using Drizzle ORM
- **Migrations**: Create migrations with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
- **Repositories**: Put database queries in `src/repository/` files
- **Soft Deletes**: Use `deletedAt` timestamp for soft deletion (not hard deletes)
- **Index Management**: Cards have `index` fields that must be maintained sequentially per list
- **Activity Logging**: Use `card_activity` table to track all card changes
### API Layer (`packages/api/`)
- **Routers**: Create tRPC routers in `src/routers/`
- **Procedures**: Use `protectedProcedure` for authenticated endpoints, `publicProcedure` for public
- **Validation**: Use Zod schemas for input validation
- **Error Handling**: Use `TRPCError` with appropriate error codes
- **OpenAPI**: Add OpenAPI metadata for all endpoints
- **Authorization**: Always check workspace membership with `assertUserInWorkspace`
### Frontend (`apps/web/`)
- **Components**: React components in `src/components/`
- **Views**: Page-level components in `src/views/`
- **Hooks**: Custom hooks in `src/hooks/`
- **i18n**: Use `t` template literal for translations (Lingui)
- **Styling**: Use Tailwind CSS classes
- **State Management**: Use tRPC React Query hooks for server state
- **Modals**: Use `useModal` hook for modal management
- **Popups**: Use `usePopup` hook for toast notifications
## Key Concepts
### Cards
- Cards are the main entity in Kan
- Cards belong to Lists, which belong to Boards
- Cards have: title, description, labels, members, checklists, comments, attachments, due dates
- Cards use soft deletion (`deletedAt` field)
- Cards have an `index` field that must be maintained sequentially per list
- All card changes are tracked in `card_activity` table
### Activity Tracking
- Every significant card change creates an activity record
- Activity types include: created, updated (various fields), etc.
- Activities are displayed in card activity feeds
### Workspaces & Boards
- Users belong to Workspaces
- Boards belong to Workspaces
- Workspace members have different permission levels
- Boards can be public or private
### Soft Deletion Pattern
- Entities use `deletedAt` timestamp for soft deletion
- Queries filter with `isNull(table.deletedAt)` to exclude deleted items
## File Locations Reference
### Database
- Schema: `packages/db/src/schema/*.ts`
- Repositories: `packages/db/src/repository/*.repo.ts`
- Migrations: `packages/db/migrations/`
### API
- Routers: `packages/api/src/routers/*.ts`
- Utils: `packages/api/src/utils/`
- Types: `packages/api/src/types/`
### Frontend
- Components: `apps/web/src/components/`
- Views: `apps/web/src/views/`
- Pages: `apps/web/src/pages/`
- Hooks: `apps/web/src/hooks/`
- Utils: `apps/web/src/utils/`
- Locales: `apps/web/src/locales/`
## Database Patterns
- **Soft Deletes**: Always filter with `isNull(table.deletedAt)` in queries
- **Public IDs**: Use 12-character public IDs (`publicId`) for all user-facing entities
- **Internal IDs**: Never expose internal database IDs (e.g., `id`, `cardId`, `listId`) in API responses or URLs - always use `publicId` externally
- **Transactions**: Use database transactions for multi-step operations
- **Index Management**: When deleting/moving cards, maintain sequential indices
- **Activity Tracking**: Create activity records for all significant changes
## API Patterns
- **Input Validation**: Always validate inputs with Zod
- **Error Messages**: Provide clear, user-friendly error messages
- **Optimistic Updates**: Use tRPC's `onMutate` for optimistic UI updates
- **Cache Invalidation**: Properly invalidate queries after mutations
- **ID Exposure**: Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` for external communication
## Important Patterns
### Card Index Management
When cards are created, moved, or deleted, their indices must be maintained:
- New cards: Append to end (max index + 1) or insert at position
- Moving cards: Adjust indices of affected cards
- Deleting cards: Decrement indices of cards after deleted one
- Always use transactions for index updates
### Activity Logging
Create activity records for:
- Card creation
- Card updates (title, description, list, etc.)
- Label/member additions/removals
- Comments
- Checklists and items
- Attachments
- Due dates
### Authorization
Always check:
1. User is authenticated
2. User has access to workspace
3. User has permission for the operation
Use `assertUserInWorkspace` helper for workspace checks.
### Error Handling
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
- Provide user-friendly error messages
- Log errors appropriately using the `@kan/logger` package
- Show popup notifications for user-facing errors
### Logging
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
- Create a module-scoped logger: `const logger = createLogger("module-name")`
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
- Defaults to `debug` in development, `info` in production
- Never use `console.log` — always use the logger
## Common Patterns
### Creating a Card
1. Create card in repository with proper index management
2. Create `card.created` activity
3. Handle label/member relationships if provided
4. Return the created card
### Updating a Card
1. Validate user has workspace access
2. Update card fields
3. Create appropriate activity records
4. Invalidate relevant queries
### Querying Cards
- Always filter by `isNull(cards.deletedAt)` in queries
- Include related data (labels, members, checklists) via Drizzle relations
- Order by `index` for proper card ordering
## Adding a New Feature
1. **Database**: Update schema in `packages/db/src/schema/`
2. **Migration**: Create migration with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
3. **Repository**: Add repository functions in `packages/db/src/repository/`
4. **API**: Add tRPC router procedures in `packages/api/src/routers/`
5. **Frontend**: Add UI components in `apps/web/src/`
6. **i18n**: Add translations for new strings
## Adding a New Environment Variable
Update all of the following:
1. `.env.example` — add the variable with an empty value and a comment explaining it
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
3. `docker-compose.yml` — add to the `web` service `environment` section
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
5. `README.md` — add a row to the Environment Variables table
## Database Changes
- Always create migrations (never modify existing migrations)
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
- Run migrations with: `pnpm db:migrate`
- Update schema files in `packages/db/src/schema/`
- Test migrations on development database first
- Update TypeScript types after schema changes
- Consider index management for card operations
## API Endpoints
- Use tRPC procedures (not REST)
- Add OpenAPI metadata for documentation
- Validate inputs with Zod
- Check workspace permissions
- Create activity records for significant changes
## Frontend Components
- Use Tailwind for styling
- Follow existing component patterns
- Use tRPC hooks for data fetching
- Implement optimistic updates where appropriate
- Add proper loading and error states
## Testing Instructions
- Test database operations in transactions that rollback
- Test authorization checks
- Test index management when moving/deleting cards
- Test activity logging
- Test UI interactions
- Run `pnpm lint` and `pnpm typecheck` before committing
## Performance Considerations
- Use database indexes appropriately
- Batch operations when possible
- Avoid N+1 queries
- Use transactions for related operations
- Implement optimistic updates in UI
## Security
- Always check workspace membership before operations
- Validate all inputs
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
- Sanitize user input
## Internationalization
- All user-facing strings must use `t` template literal
- Add translations to locale files in `apps/web/src/locales/`
- Run `pnpm lingui:extract` to update translation files
- Update locale files for all languages
## Dependencies
- Use workspace dependencies (`workspace:*`) for internal packages
- Keep dependencies up to date
- Use catalog for shared dependency versions
## When Implementing Features
### Adding New Card Fields
1. Update schema in `packages/db/src/schema/cards.ts`
2. Create migration
3. Update repository functions
4. Add API endpoints
5. Update frontend components
6. Add activity tracking if needed
### Adding New Activity Types
1. Add to `activityTypes` array in schema
2. Create migration to update enum
3. Use in activity creation code
4. Update activity display components if needed
## Git & Commits
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
- Keep commits focused on single changes
- Reference issue numbers when applicable
## PR Instructions
- Title format: `feat: description` or `fix: description`
- Always run `pnpm lint` and `pnpm typecheck` before committing
- Provide clear description of changes
- Include screenshots for UI changes
- Keep PRs focused on a single feature/fix

View File

@@ -1,52 +1,54 @@
# Contributing to Kan
Thank you for your interest in contributing to Kan! This document provides guidelines and instructions for contributing to the project.
Thank you for your interest in contributing to Kan!
## Getting Started
## Before you start
**Open an issue before writing code for a new feature.**
Feature PRs opened without a prior approved issue will be closed without review - not because the idea is bad, but because we can't review code for features we haven't aligned on yet. This protects your time as much as ours.
The process is:
1. Open a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) and describe what you want to build
2. Wait for a maintainer to respond and signal approval (we aim to respond within a few days)
3. Once approved, open a PR that links the issue
Bug fixes and documentation improvements don't need prior approval - just open a PR.
---
## 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`
3. Create a branch: `git checkout -b feat/your-feature-name` or `fix/your-fix-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
7. Commit using [conventional commits](https://www.conventionalcommits.org/): `git commit -m "feat: description"`
8. Push and open a Pull Request
## Development Guidelines
## Pull request expectations
### Code Style
- Link the related issue (required for features)
- Write a clear description of what changed and why
- Include screenshots for any UI changes
- Keep PRs focused - one feature or fix per PR
- All CI checks must pass before review
- Follow the existing code style
- Use meaningful variable and function names
- Add comments for complex logic
## Code style
- Follow the existing patterns in the codebase
- Use meaningful names - avoid comments that just restate what the code does
- Keep functions focused and concise
### Commits
## Need help?
- 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
- Join our [Discord server](https://discord.gg/e6ejRb6CmT)
- Check existing issues and PRs before opening new ones
- 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.

280
README.md
View File

@@ -34,7 +34,7 @@ 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" />
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/8490104a-cd5d-49de-afc2-152fd8a93119" />
## Made With 🛠️
@@ -47,39 +47,71 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
### One-click Deployments
1. Create a new file called `docker-compose.yml` and paste the following configuration:
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
</a>
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
```yaml
services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: kan-migrate
networks:
- kan-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
env_file:
- .env
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
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
depends_on:
- postgres
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- kan-network
@@ -91,23 +123,23 @@ volumes:
kan_postgres_data:
```
2. Start the containers in detached mode:
3. Start the containers in detached mode:
```bash
docker compose up -d
```
3. Access Kan at http://localhost:3000
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
The application will be running in the background. You can manage the containers using these commands:
**Managing containers:**
- To stop the containers: `docker compose down`
- To view logs: `docker compose logs -f`
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
- To restart the containers: `docker compose restart`
- To rebuild after code changes: `docker compose up -d --build`
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.
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
## Local Development 🧑‍💻
@@ -138,44 +170,180 @@ 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_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
| Variable | Description | Required | Example |
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
| `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` |
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
| `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_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
| `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` |
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
See `.env.example` for a complete list of supported environment variables.
## MCP Server (AI Control) 🤖
Kan ships with a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that lets any MCP-compatible AI client — GitHub Copilot, Claude Desktop, Cursor, Codex, and others — read and control your Kan instance using natural language.
### Prerequisites
- Node.js 18+
- A running Kan instance (self-hosted or cloud)
- A Kan API key (Settings → API Keys → Create key)
### Installation
You do **not** need to clone this repository. The recommended way is to use `npx`, which runs the server on-demand and always uses the latest version — no global install required:
```bash
npx -y @kan/mcp
```
Alternatively, install it globally:
```bash
npm install -g @kan/mcp
kan-mcp
```
### Configuration
The server is configured via two environment variables:
| Variable | Description | Example |
| --------------- | ----------------------------------- | ------------------------------ |
| `KAN_BASE_URL` | Base URL of your Kan instance | `https://your-kan.example.com` |
| `KAN_API_TOKEN` | API key from your Kan user settings | `kan_xxxxxxxxxxxx` |
#### GitHub Copilot (VS Code)
Add the following to your VS Code `mcp.json` (open it via **MCP: Open User MCP Configuration** from the Command Palette):
```json
{
"servers": {
"kan": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@kan/mcp"],
"env": {
"KAN_BASE_URL": "https://your-kan-instance.com",
"KAN_API_TOKEN": "kan_your_api_key_here"
}
}
}
}
```
Then use Copilot in **Agent mode** to interact with Kan.
#### Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"kan": {
"command": "npx",
"args": ["-y", "@kan/mcp"],
"env": {
"KAN_BASE_URL": "https://your-kan-instance.com",
"KAN_API_TOKEN": "kan_your_api_key_here"
}
}
}
}
```
#### Cursor / Codex / other clients
Use the same `command` + `args` + `env` pattern above — all MCP stdio clients follow the same format.
### Example prompts
Once connected, you can ask your AI assistant things like:
**Browsing**
- _"List all my workspaces"_
- _"Show me all boards in the Marketing workspace"_
- _"What cards are in the Backlog list of the Q3 Planning board?"_
- _"Get the full details of card X including comments and checklists"_
**Managing cards**
- _"Create a card called 'Fix login bug' in the To Do list of the Dev board"_
- _"Move the 'API redesign' card to the In Progress list"_
- _"Set a due date of next Friday on the 'Write docs' card"_
- _"Add a comment to the 'Deploy to prod' card saying the deployment is blocked"_
- _"Duplicate the 'Sprint template' card into the new Sprint 4 list"_
- _"Mark the 'Setup CI' checklist item as complete"_
**Organisation**
- _"Add the 'urgent' label to all cards assigned to me in the Backend board"_
- _"Create a 'Release checklist' checklist on the v2.0 card with items: smoke test, update changelog, tag release"_
- _"What tasks are assigned to @alice in the Mechanics Rework board?"_
**Workspace management**
- _"Create a new workspace called 'Client Projects'"_
- _"Invite bob@example.com to the Marketing workspace as a member"_
- _"Create a new board called 'Sprint 5' in the Dev workspace with lists: Backlog, In Progress, Done"_
- _"Search for anything related to 'authentication' across the Dev workspace"_
### Available tools
The MCP server exposes 46 tools across 7 resource types:
| Resource | Tools |
| ----------------- | ------------------------------------------------------------------- |
| Workspaces | list, find by name, get, create, update, delete, search, check slug |
| Boards | list, find by name, get, get by slug, create, update, delete |
| Lists | create, update, delete |
| Cards | create, get, update, delete, duplicate, get activities |
| Card interactions | add/update/delete comment, toggle label, toggle member |
| Checklists | create, update, delete, create item, update item, delete item |
| Labels | get, create, update, delete |
| Members | invite, remove, update role, manage invite links |
## Contributing 🤝
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
@@ -186,6 +354,12 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## Sponsors ❤️
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -11,10 +11,10 @@ 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.
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 as a Bearer token in the `Authorization` header of each request.
```
'x-api-key': kan_123456789
'Authorization': 'Bearer kan_123456789'
```
## Response codes

View File

@@ -1,84 +1,124 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=20
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
# 1. Alpine image
# ============================================
# Stage 1: Alpine base with pnpm and turbo
# ============================================
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update
RUN apk add --no-cache libc6-compat python3 make g++
RUN apk update && \
apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
corepack prepare pnpm@9.14.2 --activate && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
# 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
# ============================================
# Stage 2: Prune the monorepo
# ============================================
FROM alpine AS pruner
RUN pnpm config set store-dir ~/.pnpm-store
RUN apk add --no-cache git
# 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
# Generate version from git (fallback if APP_VERSION not provided)
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
sed 's/^v//' || \
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
echo "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
# ============================================
# Stage 3: Install dependencies
# ============================================
FROM alpine AS deps
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)
ENV CI=true
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
# ============================================
# Stage 4: Build the web application
# ============================================
FROM alpine AS builder
ARG APP_VERSION
WORKDIR /app
COPY --from=deps /app/ ./
COPY --from=pruner /app/out/full/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
# Force standalone output for production Docker image
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
ENV CI=true
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
# ============================================
# Stage 5: Migration image (run-once container)
# ============================================
FROM node:${NODE_VERSION}-alpine AS migrate
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
COPY packages/db/migrations/ ./migrations/
RUN npm init -y && \
npm install drizzle-kit drizzle-orm pg --save-exact && \
# Strip unnecessary files from node_modules
find node_modules -type f \( \
-name '*.d.ts' -o \
-name '*.d.mts' -o \
-name '*.d.cts' -o \
-name '*.map' -o \
-name '*.md' -o \
-name '*.txt' -o \
-name 'LICENSE*' -o \
-name 'CHANGELOG*' -o \
-name 'README*' -o \
-name '.eslint*' -o \
-name '.prettier*' -o \
-name 'tsconfig*.json' \
\) -delete && \
find node_modules -type d -empty -delete && \
rm -rf /root/.npm /tmp/*
CMD ["npx", "drizzle-kit", "migrate"]
# ============================================
# Stage 6: Production web image (distroless)
# ============================================
FROM ${DISTROLESS_NODE_IMAGE} AS web
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --chown=nextjs:nodejs --from=builder /app/ ./
WORKDIR /app/apps/${APP_DIRNAME}
# Copy the standalone Next.js server
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
# Copy static assets and public files
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
# Copy bootstrap script for runtime env var injection
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
USER 1000:1000
EXPOSE 3000
CMD ["bootstrap.cjs"]

44
apps/web/bootstrap.cjs Normal file
View File

@@ -0,0 +1,44 @@
/**
* Bootstrap script for the distroless production image.
*
* Distroless images have no shell, so this Node.js script handles two tasks
* that would normally be done in an entrypoint.sh:
*
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
* environment variables. The file was originally created at build time by
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
* env vars are provided at *run* time via docker-compose / docker run.
*
* 2. Start the Next.js standalone server.
*/
const { writeFileSync, existsSync, mkdirSync } = require("fs");
const path = require("path");
// ---------------------------------------------------------------------------
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
// ---------------------------------------------------------------------------
const publicDir = path.join(__dirname, "apps", "web", "public");
if (!existsSync(publicDir)) {
mkdirSync(publicDir, { recursive: true });
}
const envVars = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("NEXT_PUBLIC_")) {
envVars[key] = value;
}
}
writeFileSync(
path.join(publicDir, "__ENV.js"),
`self.__ENV = ${JSON.stringify(envVars)};`,
);
// ---------------------------------------------------------------------------
// 2. Start the Next.js standalone server
// ---------------------------------------------------------------------------
require("./apps/web/server.js");

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
import type { LinguiConfig } from "@lingui/conf";
import { defineConfig } from "@lingui/cli";
import { formatter } from "@lingui/format-json";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
export default defineConfig({
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
sourceLocale: "en",
catalogs: [
{
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
exclude: ["**/node_modules/**"],
},
],
};
export default config;
format: formatter({ style: "lingui" }),
});

View File

@@ -16,6 +16,19 @@ const config = {
: undefined,
reactStrictMode: true,
/** Exclude build tools and dev-only packages from the standalone output */
outputFileTracingExcludes: {
"**/*": [
"@esbuild/**",
"esbuild/**",
"typescript/**",
"webpack/**",
"uglify-js/**",
"terser/**",
],
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@kan/api",
@@ -26,35 +39,35 @@ const config = {
],
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
// temporarily ignore eslint errors during build until we fix all the errors sigh
eslint: { ignoreDuringBuilds: true },
images: {
remotePatterns: [
{
protocol: "https",
hostname:
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",
},
],
remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [
{ protocol: "https", hostname: "**" },
{
protocol: "http",
hostname: "localhost",
},
];
return patterns;
})(),
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});
return config;
turbopack: {
rules: {
"*.svg": {
loaders: ["@svgr/webpack"],
as: "*.js",
},
},
},
serverExternalPackages: ["pino"],
experimental: {
// instrumentationHook: true,
swcPlugins: [["@lingui/swc-plugin", {}]],

View File

@@ -4,12 +4,14 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"build": "pnpm with-env next build --turbo",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"dev": "pnpm with-env next dev --turbo",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --",
"lingui:extract": "lingui extract",
@@ -18,12 +20,15 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^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/email": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",
@@ -35,6 +40,7 @@
"@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-typography": "^3.18.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
@@ -43,15 +49,14 @@
"@trpc/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.26.2",
"geist": "^1.3.1",
"next": "^15.3.4",
"next-logger": "^5.0.1",
"jose": "^6.1.2",
"next": "15.5.9",
"next-runtime-env": "^1.7.2",
"next-themes": "^0.4.6",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"posthog-js": "^1.254.0",
"react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1",
@@ -65,6 +70,7 @@
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8",
"zod": "catalog:"
},
"devDependencies": {
@@ -74,6 +80,7 @@
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0",
@@ -84,10 +91,10 @@
"dotenv-cli": "^7.4.4",
"eslint": "catalog:",
"jiti": "^1.21.6",
"pino-pretty": "^13.0.0",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^3.0.0"
},
"prettier": "@kan/prettier-config"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,39 @@
{
"background_color": "#000000",
"categories": [
"business",
"productivity",
"utilities"
],
"description": "The open source Trello alternative.",
"display": "standalone",
"icons": [
{
"sizes": "512x512",
"src": "/icon-512.png",
"type": "image/png",
"purpose": "any"
}
],
"id": "/",
"screenshots": [
{
"sizes": "1624x1561",
"src": "/screenshot-wide.png",
"type": "image/png",
"form_factor": "wide"
},
{
"sizes": "1290x2796",
"src": "/screenshot-narrow.png",
"type": "image/png",
"form_factor": "narrow"
}
],
"orientation": "portrait-primary",
"name": "Kan.bn | The open source alternative to Trello",
"scope": "/",
"short_name": "Kan.bn",
"start_url": "/",
"theme_color": "#000000"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -46,6 +46,7 @@ interface FormValues {
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
callbackURL?: string;
}
const EmailSchema = z.object({
@@ -152,7 +153,11 @@ const availableSocialProviders = {
},
};
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
export function Auth({
setIsMagicLinkSent,
isSignUp,
callbackURL: callbackURLProp,
}: AuthProps) {
const [isCloudEnv, setIsCloudEnv] = useState(false);
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | AuthProvider>(null);
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const passwordRef = useRef<HTMLInputElement | null>(null);
const redirect = useSearchParams().get("next");
const callbackURL = redirect ?? "/boards";
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
// Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => {
@@ -303,6 +308,10 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const password = watch("password");
const isMagicLinkAvailable = useMemo(() => {
return isCloudEnv || (isEmailSendingEnabled && !isSignUp);
}, [isCloudEnv, isEmailSendingEnabled, isSignUp]);
// Determine if we should operate in magic link mode for current form state (login only)
const isMagicLinkMode = useMemo(() => {
// Magic link only viable when email sending enabled AND not sign up.
@@ -357,71 +366,88 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
})}
</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`}
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
socialProviders?.length === 0 && (
<div className="flex w-full items-center gap-4">
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
<span className="text-center text-sm text-light-900 dark:text-dark-900">
{t`No authentication methods are currently available`}
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
</div>
)}
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
{(isCredentialsEnabled || isMagicLinkAvailable) && (
<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("name", { required: true })}
placeholder={t`Enter your name`}
{...register("email", { required: true })}
placeholder={t`Enter your email address`}
/>
{errors.name && (
{errors.email && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid name`}
{t`Please enter a valid email address`}
</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>
{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">
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</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">
{errors.password.message ?? 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 `}
{isMagicLinkMode ? t`magic link` : t`email`}
</Button>
</div>
</form>
<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 `}
{isMagicLinkMode ? t`magic link` : t`email`}
</Button>
</div>
</form>
)}
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
);
}

View File

@@ -25,7 +25,7 @@ const Avatar = ({
icon?: React.ReactNode;
isLoading?: boolean;
}) => {
const initials = name
const initials = name?.trim()
? getInitialsFromName(name)
: inferInitialsFromEmail(email);

View File

@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
handleEdit?: (key: string) => void;
handleCreate?: () => void;
asChild?: boolean;
disabled?: boolean;
}
export default function CheckboxDropdown({
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
handleEdit,
handleCreate,
asChild = true,
disabled = false,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
{items.length > 0 ? (
items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<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}
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
<>
<Menu.Button
as={asChild ? "div" : undefined}
className="h-full w-full cursor-pointer focus-visible:outline-none"
disabled={disabled}
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
>
{children}
</Menu.Button>

View File

@@ -33,6 +33,7 @@ type SearchResult =
boardPublicId: string;
boardName: string;
listName: string;
cardNumber: number | null;
updatedAt: Date | null;
createdAt: Date;
type: "card";
@@ -145,7 +146,6 @@ export default function CommandPallette({
value={result}
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
onClick={() => {
console.log("clicked", url);
void router.push(url);
onClose();
setQuery("");
@@ -160,8 +160,17 @@ export default function CommandPallette({
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
<div className="flex items-center gap-2">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" &&
result.cardNumber != null &&
workspace.cardPrefix && (
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
{workspace.cardPrefix}-{result.cardNumber}
</span>
)}
</div>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">

View File

@@ -1,3 +1,6 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import {
@@ -11,7 +14,12 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
import Button from "./Button";
import Modal from "./modal";
import SideNavigation from "./SideNavigation";
interface DashboardProps {
@@ -40,10 +48,19 @@ export default function Dashboard({
hasRightPanel = false,
}: DashboardProps) {
const { resolvedTheme } = useTheme();
const { openModal } = useModal();
const { openModal, closeModal, modalContentType } = useModal();
const { availableWorkspaces, hasLoaded } = useWorkspace();
const { showPopup } = usePopup();
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -90,10 +107,65 @@ export default function Dashboard({
});
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
openModal("NEW_WORKSPACE", undefined, undefined, false);
const partnerActivated = searchParams.get("partner_activated");
const partnerError = searchParams.get("partner_error");
if (partnerActivated) {
showPopup({
header: t`License activated`,
message: t`Your license has been activated successfully.`,
icon: "success",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_activated");
router.replace(`?${params.toString()}`);
} else if (partnerError) {
const messages: Record<string, string> = {
invalid_license: t`That license key could not be found. Please contact support.`,
license_inactive: t`Your license is not active. Please check your account.`,
missing_license: t`No license key was provided. Please try activating again.`,
};
showPopup({
header: t`License activation failed`,
message:
messages[partnerError] ??
t`Something went wrong during license activation.`,
icon: "error",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_error");
router.replace(`?${params.toString()}`);
}
}, [hasLoaded, availableWorkspaces.length, openModal]);
}, [searchParams, showPopup, router]);
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
openModal("NEW_WORKSPACE", undefined, undefined, false);
}
}
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
useEffect(() => {
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
if (
!userLoading &&
user &&
isCredentialsEnabled &&
user.hasMagicLinkAccount &&
!user.hasPassword &&
!sessionStorage.getItem("set_password_prompted")
) {
sessionStorage.setItem("set_password_prompted", "1");
openModal("SET_PASSWORD_PROMPT");
}
}, [user, userLoading, openModal]);
const isDarkMode = resolvedTheme === "dark";
@@ -155,8 +227,12 @@ export default function Dashboard({
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}
user={{
displayName: user?.name ?? session?.user.name,
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
onCloseSideNav={closeSideNav}
/>
</div>
@@ -185,6 +261,30 @@ export default function Dashboard({
</div>
</div>
</div>
<Modal
modalSize="sm"
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
>
{user?.hasPassword ? (
<div className="p-5">
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
<p className="mb-6 text-sm text-light-900">
{t`Your account already has a password. You can change it from your account settings.`}
</p>
<Button
variant="secondary"
onClick={closeModal}
fullWidth
size="lg"
>
{t`Close`}
</Button>
</div>
) : (
<ChangePasswordFormConfirmation hasPassword={false} />
)}
</Modal>
</>
);
}

View File

@@ -0,0 +1,141 @@
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameDay,
isToday,
startOfMonth,
startOfWeek,
subMonths,
} from "date-fns";
import { useMemo, useState } from "react";
import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
interface DateSelectorProps {
selectedDate?: Date | null;
onDateSelect?: (date: Date | undefined) => void;
weekStartsOn?: 0 | 1 | 6;
}
const DateSelector = ({
selectedDate,
onDateSelect,
weekStartsOn = 1,
}: DateSelectorProps) => {
const [currentMonth, setCurrentMonth] = useState(() => {
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
});
const monthName = format(currentMonth, "MMMM");
const year = format(currentMonth, "yyyy");
const dayHeaders = useMemo(() => {
const weekStart = startOfWeek(new Date(), { weekStartsOn });
return eachDayOfInterval({
start: weekStart,
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
}, [weekStartsOn]);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
(date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date: dateString,
isToday: isToday(date),
isSelected: selectedDate ? isSameDay(date, selectedDate) : false,
isCurrentMonth: date >= monthStart && date <= monthEnd,
dateObj: date,
};
},
);
}, [currentMonth, selectedDate, weekStartsOn]);
const handlePreviousMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));
};
const handleNextMonth = () => {
setCurrentMonth(addMonths(currentMonth, 1));
};
const handleDateClick = (date: Date, e: React.MouseEvent) => {
e.stopPropagation();
// If clicking the same date that's already selected, unselect it
if (selectedDate && isSameDay(date, selectedDate)) {
onDateSelect?.(undefined);
} else {
onDateSelect?.(date);
}
};
return (
<div className="w-[250px] p-4">
<div className="flex items-center text-light-1000 dark:text-dark-1000">
<button
type="button"
onClick={handlePreviousMonth}
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
>
<span className="sr-only">Previous month</span>
<HiChevronLeft aria-hidden="true" className="h-4 w-4" />
</button>
<div className="flex-1 text-center text-sm font-semibold">
{monthName} {year}
</div>
<button
type="button"
onClick={handleNextMonth}
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
>
<span className="sr-only">Next month</span>
<HiChevronRight aria-hidden="true" className="h-4 w-4" />
</button>
</div>
<div className="mt-6 grid grid-cols-7 text-center text-xs/6 text-light-950 dark:text-dark-950">
{dayHeaders.map((day, index) => (
<div key={index}>{day}</div>
))}
</div>
<div className="isolate mt-2 grid grid-cols-7 text-sm">
{days.map((day) => (
<button
key={day.date}
type="button"
onClick={(e) => handleDateClick(day.dateObj, e)}
className={twMerge(
"flex aspect-square items-center justify-center rounded-lg focus:z-10",
day.isSelected
? "bg-light-1000 hover:bg-light-1000 dark:bg-dark-1000 dark:hover:bg-dark-1000"
: "bg-transparent hover:bg-light-200 dark:bg-transparent dark:hover:bg-dark-200",
)}
>
<time
dateTime={day.date}
className={twMerge(
"mx-auto flex size-7 items-center justify-center rounded-full text-light-900 dark:text-dark-900",
day.isCurrentMonth
? "text-light-900 dark:text-dark-900"
: "text-light-700 dark:text-dark-600",
day.isSelected && "text-light-50 dark:text-dark-50",
)}
>
{day.date.split("-").pop()?.replace(/^0/, "")}
</time>
</button>
))}
</div>
</div>
);
};
export default DateSelector;

View File

@@ -6,7 +6,7 @@ export default function Dropdown({
children,
disabled,
}: {
items: { label: string; action: () => void; icon?: React.ReactNode }[];
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
children: React.ReactNode;
disabled?: boolean;
}) {
@@ -30,13 +30,14 @@ export default function Dropdown({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-50 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">
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white 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}>
<Menu.Item key={item.label} disabled={item.disabled}>
<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"
disabled={item.disabled ?? !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 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
>
{item.icon}
{item.label}

View File

@@ -1,3 +1,4 @@
import type { Range as TiptapRange } from "@tiptap/core";
import type { Editor as TiptapEditor } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
@@ -7,7 +8,9 @@ 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 Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import Typography from "@tiptap/extension-typography";
import {
BubbleMenu,
EditorContent,
@@ -39,9 +42,11 @@ import {
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import tippy from "tippy.js";
import Mention from "@tiptap/extension-mention";
import Avatar from "./Avatar";
import { Markdown } from "tiptap-markdown";
import { getAvatarUrl } from "~/utils/helpers";
import Avatar from "./Avatar";
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -54,7 +59,7 @@ declare module "@tiptap/core" {
export interface SlashCommandItem {
title: string;
icon?: React.ReactNode;
command?: (props: { editor: TiptapEditor; range: Range }) => void;
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
disabled?: boolean;
}
@@ -77,15 +82,15 @@ export interface RenderSuggestionsProps {
command: (item: SlashCommandItem) => void;
}
export type WorkspaceMember = {
export interface WorkspaceMember {
publicId: string;
user: {
id: string;
id: string | null;
name: string | null;
image: string | null;
} | null;
email: string;
};
}
const CommandsList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
@@ -175,13 +180,13 @@ const RenderSuggestions = () => {
if (!props.clientRect) return;
popup[0]?.setProps({
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: SuggestionKeyDownProps): boolean {
if (props.event.key === "Escape") {
popup[0]?.hide();
popup?.[0]?.hide();
return true;
}
@@ -194,13 +199,17 @@ const RenderSuggestions = () => {
);
},
onExit() {
popup[0]?.destroy();
popup?.[0]?.destroy();
reactRenderer.destroy();
},
};
};
type MentionItem = { id: string; label: string; image: string | null };
interface MentionItem {
id: string;
label: string;
image: string | null;
}
const MentionList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
@@ -238,30 +247,32 @@ const MentionList = forwardRef<
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>
)) : (
{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>
<span className="text-[12px] text-dark-900 dark:text-dark-1000">
No results
</span>
</div>
)}
</div>
@@ -297,11 +308,11 @@ const renderMentionSuggestions = () => {
onUpdate(props: any) {
reactRenderer.updateProps(props);
if (!props.clientRect) return;
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect });
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
popup[0]?.hide();
popup?.[0]?.hide();
return true;
}
return (
@@ -313,7 +324,7 @@ const renderMentionSuggestions = () => {
);
},
onExit() {
popup[0]?.destroy();
popup?.[0]?.destroy();
reactRenderer.destroy();
},
};
@@ -376,70 +387,98 @@ export interface SlashNodeAttrs {
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(),
},
];
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
const headingCommands: SlashCommandItem[] = disableHeadings
? []
: [
{
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(),
},
];
return [
...headingCommands,
{
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,
onSubmit,
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
onSubmit?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
// useEditor is created once (empty deps below), so keep the latest callbacks
// in refs to avoid the editor capturing stale closures on re-render.
const onChangeRef = useRef(onChange);
const onBlurRef = useRef(onBlur);
const onSubmitRef = useRef(onSubmit);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onSubmitRef.current = onSubmit;
}, [onSubmit]);
const editor = useEditor(
{
extensions: [
StarterKit,
Placeholder.configure({
placeholder: readOnly
? ""
: t`Add description... (type '/' to open commands or '@' to mention)`,
StarterKit.configure({
heading: disableHeadings ? false : undefined,
}),
Link.configure({
openOnClick: true,
@@ -450,51 +489,90 @@ export default function Editor({
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
linkOnPaste: true,
}),
Markdown.configure({ transformPastedText: true }),
Placeholder.configure({
placeholder: readOnly
? ""
: (placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`),
}),
SlashCommands.configure({
commandItems: CommandItems,
commandItems: getCommandItems(disableHeadings),
suggestion: {
items: ({ query }: { query: string }) =>
filterSlashCommandItems(CommandItems, query),
filterSlashCommandItems(getCommandItems(disableHeadings), query),
startOfLine: true,
char: "/",
},
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
class: "mention",
},
suggestion: {
char: "@",
items: ({ query }: { query: string }) => {
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
const withEmail = workspaceMembers.filter(
(member) => member.email,
);
const mapped = withEmail.map((member: WorkspaceMember) => ({
id: member.publicId,
label: member?.user?.name ?? member.email,
label: member?.user?.name?.trim() || member.email || "",
image: member?.user?.image ?? null,
}));
const q = query.toLowerCase();
return all.filter((u) => u.label.toLowerCase().includes(q));
const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0,
);
const q = query.toLowerCase().trim();
if (q === "") {
return all;
}
const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q),
);
return filtered;
},
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}`;
command: ({ editor, range, props }) => {
const id = props.id ?? "";
const label = props.label ?? "";
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${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}`;
},
}),
Typography.configure({
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getHTML()),
onBlur: ({ event }) => {
if (
document
@@ -504,13 +582,20 @@ export default function Editor({
return;
// Only trigger onBlur if the click was outside both the editor and menu
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
onBlur?.();
onBlurRef.current?.();
}
},
editorProps: {
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
handleKeyDown: (_view, event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
onSubmitRef.current?.();
return true;
}
return false;
},
},
editable: !readOnly,
injectCSS: false,
@@ -528,6 +613,16 @@ export default function Editor({
}
}, [content, editor]);
// useEditor captures `readOnly` once at creation time (empty deps above), so
// explicitly sync `editable` when the prop changes. Without this the editor
// gets stuck read-only when `readOnly` flips from true to false after mount
// (e.g. card permissions resolving slower than the card data on first load).
useEffect(() => {
if (!editor) return;
if (editor.isEditable === !readOnly) return;
editor.setEditable(!readOnly);
}, [editor, readOnly]);
return (
<div ref={containerRef}>
<style jsx global>{`
@@ -548,6 +643,9 @@ export default function Editor({
text-decoration: none;
font-weight: 500;
}
.tiptap [data-youtube] {
margin: 1rem 0;
}
`}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent

View File

@@ -0,0 +1,32 @@
import { t } from "@lingui/core/macro";
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
import { useFontSize, type FontSize } from "~/providers/font-size";
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
{ value: "small", label: () => t`Small` },
{ value: "medium", label: () => t`Medium` },
{ value: "large", label: () => t`Large` },
];
export function FontSizeSelector() {
const { fontSize, setFontSize } = useFontSize();
return (
<div className="relative">
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="font-size-select"
value={fontSize}
onChange={(e) => setFontSize(e.target.value as FontSize)}
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm 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"
>
{fontSizeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label()}
</option>
))}
</select>
</div>
);
}

View File

@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
{iconRight}
</div>
)}

View File

@@ -39,7 +39,7 @@ export function LabelForm({
labelPublicId: entityId,
},
{
enabled: isEdit && !!entityId,
enabled: !!isEdit && entityId.length >= 12,
},
);
@@ -75,7 +75,7 @@ export function LabelForm({
reset(newFormState);
}
} catch (e) {
console.log(e);
console.error(e);
}
},
});
@@ -207,7 +207,7 @@ export function LabelForm({
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && (
<Toggle
label={t`Create another`}
@@ -231,9 +231,7 @@ export function LabelForm({
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
disabled={
!watch("name")
}
disabled={!watch("name")}
>
{isEdit ? t`Update label` : t`Create label`}
</Button>

View File

@@ -1,21 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
HiBolt,
HiCheck,
HiCheckBadge,
HiInformationCircle,
HiXMark,
} from "react-icons/hi2";
import { HiCheck, HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -33,7 +26,7 @@ const schema = z.object({
.min(3, {
message: t`URL must be at least 3 characters long`,
})
.max(24, { message: t`URL cannot exceed 24 characters` })
.max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
})
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
watch,
trigger,
clearErrors,
setValue,
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
// Pro toggle state management
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
// Validate slug only after debounce
useEffect(() => {
if (isTyping) {
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (values, variables) => {
onSuccess: (values) => {
if (values.publicId && values.name) {
void utils.workspace.all.invalidate();
switchWorkspace({
@@ -110,45 +96,11 @@ export function NewWorkspaceForm() {
description: values.description,
slug: values.slug,
plan: values.plan,
cardPrefix: values.cardPrefix,
role: "admin",
weekStartDay: 1,
});
// If in cloud and Pro toggle is enabled, create checkout session for pro
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
try {
const response = await fetch(
"/api/stripe/create_checkout_session",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: slug || undefined,
workspacePublicId: values.publicId,
cancelUrl: "/settings/workspace?upgrade=pro",
successUrl: "/boards",
}),
},
);
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return; // Don't close modal if redirecting to checkout
}
} catch (error) {
console.error("Error creating checkout session:", error);
showPopup({
header: t`Error upgrading to Pro`,
message: t`Workspace created successfully. You can upgrade later in settings.`,
icon: "warning",
});
}
}
closeModal();
}
},
@@ -167,57 +119,8 @@ export function NewWorkspaceForm() {
if (nameElement) nameElement.focus();
}, []);
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
useEffect(() => {
if (
isCloudEnv &&
!checkWorkspaceSlugAvailability.isPending &&
isValidSlug
) {
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
setShouldShowBenefits(isAvailable);
// Automatically enable Pro toggle when slug is available
if (isAvailable) {
setIsProToggleEnabled(true);
setLastAvailableSlug(slug);
}
}
}, [
isValidSlug,
isWorkspaceSlugAvailable?.isAvailable,
checkWorkspaceSlugAvailability.isPending,
slug,
isCloudEnv,
]);
// Reset benefits when slug becomes invalid
useEffect(() => {
if (!isValidSlug) {
setShouldShowBenefits(false);
}
}, [isValidSlug]);
// Handle Pro toggle changes
const handleProToggleChange = () => {
if (isProToggleEnabled) {
// Turning off: clear the slug and disable Pro
setValue("slug", "");
setIsProToggleEnabled(false);
} else {
// Turning on: restore the last available slug if we have one
if (lastAvailableSlug) {
setValue("slug", lastAvailableSlug);
}
setIsProToggleEnabled(true);
}
};
const showProBenefits = isProToggleEnabled;
const onSubmit = (values: FormValues) => {
// Don't submit if slug is provided but not available
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
@@ -226,7 +129,7 @@ export function NewWorkspaceForm() {
createWorkspace.mutate({
name: values.name,
slug: !isCloudEnv && values.slug ? values.slug : undefined,
slug: values.slug,
});
};
@@ -289,11 +192,7 @@ export function NewWorkspaceForm() {
? t`This workspace URL is reserved`
: undefined)
}
prefix={
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn/"
: `${env("NEXT_PUBLIC_BASE_URL")}/`
}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
iconRight={
slug && slug.length >= 3 && !errors.slug ? (
isWorkspaceSlugAvailable?.isAvailable ? (
@@ -310,68 +209,9 @@ export function NewWorkspaceForm() {
}
}}
/>
{slug && slug.length >= 3 && shouldShowBenefits && (
<div className="mt-2 flex items-center gap-1">
<HiInformationCircle className="h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Custom URLs require upgrading to a Pro plan`}
</p>
</div>
)}
</div>
{showProBenefits && (
<div className="mt-6">
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Unlimited members`}
</span>
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
{t`Launch offer`}
</span>
</div>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Custom workspace URL`}
</span>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Board analytics`}
</span>
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
{t`Coming soon`}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<div
className={twMerge(
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
!showProBenefits && "mt-12",
)}
>
{/* Pro Toggle - only show in cloud environment */}
{isCloudEnv && (
<Toggle
isChecked={isProToggleEnabled}
onChange={handleProToggleChange}
label={t`Upgrade to Pro ($29/month)`}
/>
)}
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"

View File

@@ -8,6 +8,7 @@ export const PageHead = ({ title }: { title: string }) => {
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<link rel="manifest" href="/manifest.json" />
</Head>
);
};

View File

@@ -0,0 +1,130 @@
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
interface PlainTextEditorProps {
content: string;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
onEnter?: (value: string) => void;
onEscape?: () => void;
readOnly?: boolean;
placeholder?: string;
className?: string;
}
export default function PlainTextEditor({
content,
onChange,
onBlur,
onEnter,
onEscape,
readOnly = false,
placeholder,
className,
}: PlainTextEditorProps) {
const onEnterRef = useRef(onEnter);
const onEscapeRef = useRef(onEscape);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
const contentRef = useRef(content);
useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);
useEffect(() => {
onEscapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
contentRef.current = content;
}, [content]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
hardBreak: false,
}),
Placeholder.configure({ placeholder }),
],
content,
editable: !readOnly,
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === "Enter") {
event.preventDefault();
onEnterRef.current?.(view.state.doc.textContent.trim());
return true;
}
if (event.key === "Escape") {
event.preventDefault();
// Reset to original content before calling the callback
editor?.commands.setContent(contentRef.current, false);
editor?.commands.blur();
onEscapeRef.current?.();
return true;
}
return false;
},
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
},
[],
);
useEffect(() => {
if (!editor) return;
if (content !== editor.getText()) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
return (
<>
<style jsx global>{`
.plain-text-editor p.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--placeholder-color, #9ca3af);
}
.plain-text-editor .tiptap p {
margin: 0 !important;
}
`}</style>
<EditorContent
editor={editor}
className={twMerge("plain-text-editor", className)}
/>
</>
);
}

View File

@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
return (
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
>
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
<Transition
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
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="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg 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-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
<div className="p-4 relative">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="flex-shrink-0 mt-1">
{popupIcon === "success" && (
<HiOutlineCheckCircle
aria-hidden="true"
className="h-6 w-6 text-green-400"
className="h-5 w-5 text-green-400"
/>
)}
{popupIcon === "error" && (
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-6 w-6 text-red-400"
className="h-5 w-5 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">
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
{popupHeader}
</p>
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
{popupMessage}
</p>
</div>
<div className="ml-4 flex flex-shrink-0">
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
<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"
className="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-200"
>
<span className="sr-only">Close</span>
<HiXMark
aria-hidden="true"
className="h-5 w-5 text-dark-900"
className="h-4 w-4 text-dark-900"
/>
</button>
</div>

View File

@@ -2,8 +2,10 @@ import Link from "next/link";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
import LottieIcon from "~/components/LottieIcon";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
const Button: React.FC<{
href: string;
@@ -12,10 +14,20 @@ const Button: React.FC<{
json: object;
isCollapsed?: boolean;
onCloseSideNav?: () => void;
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
keyboardShortcut: KeyboardShortcut;
}> = ({
href,
current,
name,
json,
isCollapsed = false,
keyboardShortcut,
onCloseSideNav,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const isMobile = useIsMobile();
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
const handleMouseEnter = () => {
setIsHovered(true);
@@ -35,17 +47,27 @@ const Button: React.FC<{
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",
isCollapsed ? "md:justify-center" : "justify-between",
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>
<div
className={twMerge(
"flex items-center",
isCollapsed
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
: "gap-x-3",
)}
>
<LottieIcon index={index} json={json} isPlaying={isHovered} />
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
</div>
{!isCollapsed && (
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
)}
</Link>
);
};

View File

@@ -12,10 +12,14 @@ import { useEffect, useState } from "react";
import {
HiChevronDown,
HiOutlineBanknotes,
HiOutlineBolt,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
HiOutlineUser,
} from "react-icons/hi2";
import { usePermissions } from "~/hooks/usePermissions";
import { useWorkspace } from "~/providers/workspace";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -24,8 +28,12 @@ interface SettingsLayoutProps {
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
const router = useRouter();
const { workspace } = useWorkspace();
const { canViewWorkspace, canEditWorkspace } = usePermissions();
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const isAdmin = workspace.role === "admin";
const settingsTabs = [
{
key: "account",
@@ -37,13 +45,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "workspace",
icon: <HiOutlineRectangleGroup />,
label: t`Workspace`,
condition: true,
condition: canViewWorkspace,
},
{
key: "permissions",
icon: <HiOutlineShieldCheck />,
label: t`Permissions`,
condition: isAdmin,
},
{
key: "billing",
label: t`Billing`,
icon: <HiOutlineBanknotes />,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
},
{
key: "api",
@@ -51,11 +65,17 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
label: t`API`,
condition: true,
},
{
key: "webhooks",
icon: <HiOutlineBolt />,
label: t`Webhooks`,
condition: isAdmin,
},
{
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
label: t`Integrations`,
condition: true,
condition: canEditWorkspace,
},
];
@@ -97,7 +117,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 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">
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"

View File

@@ -4,7 +4,7 @@ import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { HiBolt } from "react-icons/hi2";
import {
TbLayoutSidebarLeftCollapse,
@@ -15,6 +15,7 @@ import { twMerge } from "tailwind-merge";
import type { Subscription } from "@kan/shared/utils";
import { hasActiveSubscription } from "@kan/shared/utils";
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
import membersIconDark from "~/assets/members-dark.json";
@@ -27,7 +28,6 @@ import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
@@ -38,6 +38,7 @@ interface SideNavigationProps {
}
interface UserType {
displayName?: string | null | undefined;
email?: string | null | undefined;
image?: string | null | undefined;
}
@@ -51,11 +52,11 @@ export default function SideNavigation({
const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery({
workspacePublicId: workspace.publicId,
});
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
@@ -86,28 +87,64 @@ export default function SideNavigation({
const isDarkMode = resolvedTheme === "dark";
const navigation = [
{
name: t`Boards`,
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
},
{
name: t`Templates`,
href: "/templates",
icon: isDarkMode ? templatesIconDark : templatesIconLight,
},
{
name: t`Members`,
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
},
{
name: t`Settings`,
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
},
];
const navigation: {
name: string;
href: string;
icon: object;
keyboardShortcut: KeyboardShortcut;
}[] = useMemo(
() => [
{
name: t`Boards`,
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "B" }],
action: () => router.push("/boards"),
group: "NAVIGATION",
description: t`Go to boards`,
},
},
{
name: t`Templates`,
href: "/templates",
icon: isDarkMode ? templatesIconDark : templatesIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "T" }],
action: () => router.push("/templates"),
group: "NAVIGATION",
description: t`Go to templates`,
},
},
{
name: t`Members`,
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "M" }],
action: () => router.push("/members"),
group: "NAVIGATION",
description: t`Go to members`,
},
},
{
name: t`Settings`,
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "S" }],
action: () => router.push("/settings"),
group: "NAVIGATION",
description: t`Go to settings`,
},
},
],
[isDarkMode],
);
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
@@ -122,10 +159,10 @@ export default function SideNavigation({
)}
>
<div>
<div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
<div className="hidden h-[45px] items-center justify-between pb-3 md:flex">
{!isCollapsed && (
<Link href="/" className="block">
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
<h1 className="pl-2 text-[16px] font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
@@ -163,6 +200,7 @@ export default function SideNavigation({
json={item.icon}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
keyboardShortcut={item.keyboardShortcut}
/>
</li>
))}
@@ -171,7 +209,8 @@ export default function SideNavigation({
<div className="space-y-2">
<UserMenu
email={user.email ?? ""}
displayName={user.displayName ?? undefined}
email={user.email ?? "Email not provided?"}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}
@@ -185,21 +224,19 @@ export default function SideNavigation({
<ButtonComponent
iconLeft={<HiBolt />}
variant="secondary"
href="/settings/workspace?upgrade=pro"
aria-label="Upgrade to Pro"
title="Upgrade to Pro"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
aria-label={t`Start free trial`}
title={t`Start free trial`}
iconOnly
onClick={() => openModal("UPGRADE_TO_PRO")}
/>
) : (
<ButtonComponent
iconLeft={<HiBolt />}
fullWidth
variant="secondary"
href="/settings/workspace?upgrade=pro"
onClick={() => openModal("UPGRADE_TO_PRO")}
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
>
{t`Upgrade to Pro`}
{t`Start free trial`}
</ButtonComponent>
)}
</div>

View File

@@ -6,16 +6,22 @@ const Toggle = ({
onChange,
label,
disabled,
showLabel = true,
labelPosition = "before",
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
labelPosition?: "before" | "after";
}) => (
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
<div className="flex items-center">
{showLabel && labelPosition === "before" && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
<Switch
checked={isChecked}
onChange={onChange}
@@ -34,6 +40,11 @@ const Toggle = ({
)}
/>
</Switch>
{showLabel && labelPosition === "after" && (
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
</div>
);

View File

@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import type { Placement, Instance as TippyInstance } from "tippy.js";
import { useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import tippy from "tippy.js";
interface TooltipProps {
children: ReactNode;
content?: ReactNode;
placement?: Placement;
delay?: number | [number, number];
}
export function Tooltip({
children,
content,
placement = "bottom",
delay = [500, 0],
}: TooltipProps) {
const triggerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(null);
const tippyRef = useRef<TippyInstance | null>(null);
const contentRef = useRef(content);
contentRef.current = content;
useEffect(() => {
if (!triggerRef.current) return;
const container = document.createElement("div");
const root = createRoot(container);
rootRef.current = root;
const instance = tippy(triggerRef.current, {
content: container,
placement,
delay,
interactive: false,
theme: "tooltip",
touch: false,
});
tippyRef.current = instance;
if (contentRef.current) {
root.render(contentRef.current);
} else {
instance.disable();
}
return () => {
instance.destroy();
tippyRef.current = null;
root.unmount();
rootRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [placement, delay]);
useEffect(() => {
if (!content) {
tippyRef.current?.disable();
return;
}
if (tippyRef.current) {
tippyRef.current.enable();
rootRef.current?.render(content);
}
}, [content]);
return (
<div ref={triggerRef} className="inline-flex">
{children}
</div>
);
}

View File

@@ -9,12 +9,15 @@ import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { env } from "~/env";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
displayName: string | undefined;
email: string;
isLoading: boolean;
isCollapsed?: boolean;
@@ -24,6 +27,7 @@ interface UserMenuProps {
export default function UserMenu({
imageUrl,
email,
displayName,
isLoading,
isCollapsed = false,
onCloseSideNav,
@@ -31,6 +35,7 @@ export default function UserMenu({
const router = useRouter();
const { theme, setTheme } = useTheme();
const { openModal } = useModal();
const { openLegend } = useKeyboardShortcuts();
const isMobile = useIsMobile();
const handleLogout = async () => {
@@ -72,7 +77,7 @@ export default function UserMenu({
) : (
<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}
title={isCollapsed ? (displayName || email) : undefined}
>
{avatarUrl ? (
<Image
@@ -99,7 +104,7 @@ export default function UserMenu({
isCollapsed && "md:hidden",
)}
>
{email}
{displayName || email}
</span>
</Menu.Button>
)}
@@ -169,6 +174,19 @@ export default function UserMenu({
</Menu.Item>
</div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<button
onClick={() => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
openLegend();
}}
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`Shortcuts`}
</button>
</Menu.Item>
<Menu.Item>
<Link
href="mailto:support@kan.bn"
@@ -210,6 +228,25 @@ export default function UserMenu({
</button>
</Menu.Item>
</div>
{env.NEXT_PUBLIC_APP_VERSION && (
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<Link
href={
env.NEXT_PUBLIC_APP_VERSION.includes("+")
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
}
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
>
Version: {env.NEXT_PUBLIC_APP_VERSION}
</Link>
</Menu.Item>
</div>
)}
</div>
</Menu.Items>
</Transition>

View File

@@ -1,12 +1,17 @@
import { useRouter } from "next/navigation";
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useEffect, useState } from "react";
import { env } from "next-runtime-env";
import { Fragment, useMemo, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
export default function WorkspaceMenu({
isCollapsed = false,
@@ -16,19 +21,27 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const { data: hasPartnerSlot } =
api.workspace.hasAvailablePartnerSlot.useQuery();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
setIsOpen(true);
}
};
const commandPaletteShortcut = useMemo(
() => ({
type: "PRESS" as const,
stroke: {
key: "k",
modifiers: ["META"] as ("META" | "CONTROL" | "ALT" | "SHIFT")[],
},
action: () => setIsOpen(true),
description: t`Open command menu`,
group: "GENERAL" as const,
}),
[],
);
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
useKeyboardShortcut(commandPaletteShortcut);
return (
<>
@@ -84,15 +97,17 @@ export default function WorkspaceMenu({
</span>
)}
</Menu.Button>
<Button
className={twMerge(
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
isCollapsed && "md:mb-2 md:h-9 md:w-9",
)}
onClick={() => setIsOpen(true)}
>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
<Tooltip content={commandPaletteShortcutTooltipContent}>
<Button
className={twMerge(
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
isCollapsed && "md:mb-2 md:h-9 md:w-9",
)}
onClick={() => setIsOpen(true)}
>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
</Tooltip>
</div>
)}
</div>
@@ -143,7 +158,19 @@ export default function WorkspaceMenu({
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => openModal("NEW_WORKSPACE")}
onClick={() => {
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
openModal("NEW_WORKSPACE");
} else if (hasPartnerSlot) {
router.push(
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
}
}}
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`}

View File

@@ -0,0 +1,189 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } 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 { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
interface EditYouTubeFormInput {
url: string;
title: string;
}
interface EditYouTubeModalState {
url: string;
title: string;
onSave: (url: string, title: string) => void;
}
export function EditYouTubeModal() {
const { closeModal, getModalState } = useModal();
const [isValidating, setIsValidating] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
// Get initial values and callback from modal state
const modalState = getModalState("EDIT_YOUTUBE") as
| EditYouTubeModalState
| undefined;
const initialUrl = modalState?.url ?? "";
const initialTitle = modalState?.title ?? "";
const onSave = modalState?.onSave;
const { register, handleSubmit, watch, reset } =
useForm<EditYouTubeFormInput>({
defaultValues: {
url: initialUrl,
title: initialTitle,
},
});
// Reset form when modal state changes (when modal opens with new values)
useEffect(() => {
if (modalState) {
reset({
url: modalState.url,
title: modalState.title,
});
}
}, [modalState, reset]);
const currentUrl = watch("url");
const onSubmit = async (values: EditYouTubeFormInput) => {
// Validate URL
if (!isYouTubeUrl(values.url)) {
setUrlError(t`Please enter a valid YouTube URL`);
return;
}
setUrlError(null);
setIsValidating(true);
try {
// If title is empty and URL changed, fetch new title
let finalTitle = values.title;
if (!finalTitle.trim() && values.url !== initialUrl) {
const metadata = await fetchYouTubeMetadata(values.url);
finalTitle = metadata?.title ?? "YouTube Video";
} else if (!finalTitle.trim()) {
// Keep existing title if no new title provided and URL unchanged
finalTitle = initialTitle || "YouTube Video";
}
if (onSave) {
onSave(values.url, finalTitle);
}
closeModal();
} catch (error) {
console.error(error);
setUrlError(t`Failed to fetch video information`);
} finally {
setIsValidating(false);
}
};
// Auto-focus on title input (more useful for editing)
useEffect(() => {
const titleElement: HTMLElement | null =
document.querySelector<HTMLElement>("#youtube-title");
if (titleElement) titleElement.focus();
}, []);
// Validate URL on change
useEffect(() => {
if (currentUrl && !isYouTubeUrl(currentUrl)) {
setUrlError(t`Please enter a valid YouTube URL`);
} else {
setUrlError(null);
}
}, [currentUrl]);
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">{t`Edit YouTube Video`}</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>
<div className="space-y-4">
<div>
<label
htmlFor="youtube-title"
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
>
{t`Title`}
</label>
<Input
id="youtube-title"
placeholder={t`Enter a custom title`}
{...register("title")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div>
<label
htmlFor="youtube-url"
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
>
{t`YouTube URL`}
</label>
<Input
id="youtube-url"
placeholder={t`https://www.youtube.com/watch?v=...`}
{...register("url", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
{urlError && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{urlError}
</p>
)}
</div>
</div>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="space-x-2">
<Button
type="button"
variant="secondary"
onClick={() => closeModal()}
>
{t`Cancel`}
</Button>
<Button
type="submit"
isLoading={isValidating}
disabled={!watch("url") || !!urlError}
>
{t`Save`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,53 @@
import YouTubeDropdown from "./YouTubeDropdown";
interface YouTubeCardProps {
videoId: string;
url: string;
title: string;
showEmbed?: boolean;
onConvertToLink: () => void;
onDelete: () => void;
onUpdate: (url: string, title: string) => void;
}
const YouTubeCard = ({
videoId,
url,
title,
showEmbed = true,
onConvertToLink,
onDelete,
onUpdate,
}: YouTubeCardProps) => {
return (
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
<div className="flex items-center justify-between gap-6 p-0 px-6">
<h3 className="truncate text-sm font-medium">{title}</h3>
<div className="mt-3">
<YouTubeDropdown
url={url}
title={title}
onConvertToLink={onConvertToLink}
onDelete={onDelete}
onUpdate={onUpdate}
/>
</div>
</div>
{showEmbed && videoId && (
<div className="p-6 pt-1">
<iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={title}
className="aspect-video w-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
)}
</div>
);
};
export default YouTubeCard;

View File

@@ -0,0 +1,63 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiLink,
HiPencil,
HiTrash,
} from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import Dropdown from "../Dropdown";
interface YouTubeDropdownProps {
url: string;
title: string;
onConvertToLink: () => void;
onDelete: () => void;
onUpdate: (url: string, title: string) => void;
}
const YouTubeDropdown = ({
url,
title,
onConvertToLink,
onDelete,
onUpdate,
}: YouTubeDropdownProps) => {
const { openModal, setModalState } = useModal();
const handleEdit = () => {
setModalState("EDIT_YOUTUBE", {
url,
title,
onSave: onUpdate,
});
openModal("EDIT_YOUTUBE");
};
return (
<Dropdown
items={[
{
label: t`Edit`,
action: handleEdit,
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
},
{
label: t`Convert to link`,
action: onConvertToLink,
icon: <HiLink className="h-4 w-4 text-dark-900" />,
},
{
label: t`Delete`,
action: onDelete,
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);
};
export default YouTubeDropdown;

View File

@@ -0,0 +1,196 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
import YouTubeNodeView from "./YouTubeNodeView";
export interface YouTubeOptions {
inline: boolean;
HTMLAttributes: Record<string, undefined>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
youTube: {
setYouTubeEmbed: (options: {
videoId: string;
url: string;
title: string;
thumbnailUrl?: string;
showEmbed?: boolean;
}) => ReturnType;
};
}
}
export const YouTubeNode = Node.create<YouTubeOptions>({
name: "youtube",
group: "block",
atom: true,
addOptions() {
return {
inline: false,
HTMLAttributes: {},
};
},
addAttributes() {
return {
videoId: {
default: null,
parseHTML: (element) => element.getAttribute("data-video-id"),
renderHTML: (attributes) => {
if (!attributes.videoId) return {};
return { "data-video-id": attributes.videoId as string };
},
},
url: {
default: null,
parseHTML: (element) => element.getAttribute("data-url"),
renderHTML: (attributes) => {
if (!attributes.url) return {};
return { "data-url": attributes.url as string };
},
},
title: {
default: "YouTube Video",
parseHTML: (element) => element.getAttribute("data-title"),
renderHTML: (attributes) => {
return { "data-title": attributes.title as string };
},
},
thumbnailUrl: {
default: null,
parseHTML: (element) => element.getAttribute("data-thumbnail"),
renderHTML: (attributes) => {
if (!attributes.thumbnailUrl) return {};
return { "data-thumbnail": attributes.thumbnailUrl as string };
},
},
showEmbed: {
default: true,
parseHTML: (element) => element.getAttribute("data-show-embed"),
renderHTML: (attributes) => {
return { "data-show-embed": attributes.showEmbed as string };
},
},
};
},
parseHTML() {
return [
{
tag: "div[data-youtube]",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-youtube": "" },
this.options.HTMLAttributes,
HTMLAttributes,
),
];
},
addNodeView() {
return ReactNodeViewRenderer(YouTubeNodeView);
},
addProseMirrorPlugins() {
const nodeType = this.type;
return [
new Plugin({
key: new PluginKey("youtubePaste"),
props: {
handlePaste: (view, event) => {
const text = event.clipboardData?.getData("text/plain");
if (!text) return false;
// Check if the pasted text is a YouTube URL
const youtubeRegex =
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
const match = youtubeRegex.exec(text);
if (!match || !isYouTubeUrl(text)) return false;
const videoId = extractVideoId(text);
if (!videoId) return false;
const { state, dispatch } = view;
const { tr } = state;
const node = nodeType.create({
videoId,
url: text,
title: "Loading...",
showEmbed: true,
});
tr.replaceSelectionWith(node);
dispatch(tr);
// Fetch metadata asynchronously and update the node
void fetchYouTubeMetadata(text).then((metadata) => {
const { state: newState, dispatch: newDispatch } = view;
const { tr: newTr } = newState;
newState.doc.descendants((n, pos) => {
if (
n.type.name === "youtube" &&
n.attrs.videoId === videoId &&
n.attrs.title === "Loading..."
) {
newTr.setNodeMarkup(pos, undefined, {
...n.attrs,
title: metadata?.title ?? "YouTube Video",
thumbnailUrl: metadata?.thumbnail_url ?? null,
});
return false;
}
return true;
});
if (newTr.docChanged) {
newDispatch(newTr);
}
});
return true;
},
},
}),
];
},
// commands for the YouTube embed node
addCommands() {
return {
setYouTubeEmbed:
(options: {
videoId: string;
url: string;
title?: string;
thumbnailUrl?: string;
showEmbed?: boolean;
}) =>
({ editor }) => {
return editor.commands.insertContent({
type: this.name,
attrs: {
videoId: options.videoId,
url: options.url,
title: options.title ?? "YouTube Video",
thumbnailUrl: options.thumbnailUrl,
showEmbed: options.showEmbed ?? true,
},
});
},
};
},
});

View File

@@ -0,0 +1,82 @@
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper } from "@tiptap/react";
import { extractVideoId } from "./utils";
import YouTubeCard from "./YouTubeCard";
export default function YouTubeNodeView({
node,
editor,
getPos,
deleteNode,
updateAttributes,
}: NodeViewProps) {
const { videoId, title, url, showEmbed } = node.attrs as {
videoId: string;
title: string;
url: string;
showEmbed: boolean;
};
const handleConvertToLink = () => {
const pos = getPos();
if (typeof pos !== "number") return;
const linkContent = {
type: "paragraph",
content: [
{
type: "text",
text: url,
marks: [
{
type: "link",
attrs: {
href: url,
target: "_blank",
},
},
],
},
],
};
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContentAt(pos, linkContent)
.run();
};
const handleDelete = () => {
deleteNode();
};
const handleUpdate = (newUrl: string, newTitle: string) => {
if (newUrl !== url) {
const newVideoId = extractVideoId(newUrl);
updateAttributes({
url: newUrl,
title: newTitle,
videoId: newVideoId,
});
} else {
updateAttributes({ title: newTitle });
}
};
return (
<NodeViewWrapper>
<YouTubeCard
videoId={videoId}
url={url}
title={title}
showEmbed={showEmbed}
onConvertToLink={handleConvertToLink}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,61 @@
export function extractVideoId(url: string): string | null {
if (!url) return null;
url = url.trim();
// youtube.com/watch?v=VIDEO_ID
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
if (watchMatch) return watchMatch[1] ?? null;
// youtu.be/VIDEO_ID
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
if (shortMatch) return shortMatch[1] ?? null;
// youtube.com/embed/VIDEO_ID
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
if (embedMatch) return embedMatch[1] ?? null;
return null;
}
/**
* Check if a URL is a valid YouTube link
*/
export function isYouTubeUrl(url: string): boolean {
if (!url) return false;
const videoId = extractVideoId(url);
return videoId !== null;
}
/**
* Fetch YouTube video metadata using oEmbed API
* Returns title, author, thumbnail URL, etc.
* No API key required
*/
export async function fetchYouTubeMetadata(url: string): Promise<{
title: string;
author_name: string;
thumbnail_url: string;
} | null> {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const response = await fetch(oembedUrl);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
title: string;
author_name: string;
thumbnail_url: string;
};
return {
title: data.title || "YouTube Video",
author_name: data.author_name || "",
thumbnail_url: data.thumbnail_url || "",
};
} catch (error) {
console.error("Failed to fetch YouTube metadata:", error);
return null;
}
}

View File

@@ -9,6 +9,7 @@ interface Props {
positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
closeOnClickOutside?: boolean;
centered?: boolean;
}
const Modal: React.FC<Props> = ({
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
positionFromTop = "md",
isVisible,
closeOnClickOutside,
centered = false,
}) => {
const {
isOpen,
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
</Transition.Child>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -71,7 +73,7 @@ const Modal: React.FC<Props> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<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]}`}
className={`relative ${centered ? "" : 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>

View File

@@ -14,6 +14,7 @@ export const env = createEnv({
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
KAN_ADMIN_API_KEY: z.string().optional(),
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z
.string()
@@ -51,6 +52,9 @@ export const env = createEnv({
VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(),
SUBSCRIBER_API_URL: z.string().url().optional(),
SUBSCRIBER_API_KEY: z.string().optional(),
SUBSCRIBER_ENVIRONMENT_ID: z.string().optional(),
// Generic OIDC Provider
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
@@ -75,6 +79,7 @@ export const env = createEnv({
S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
REDIS_URL: z.string().url().optional().or(z.literal("")),
},
/**
@@ -87,10 +92,19 @@ export const env = createEnv({
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_BASE_URL: z.string().url().optional(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.transform((s) => (s === "" ? undefined : s))
@@ -125,7 +139,12 @@ export const env = createEnv({
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_ATTACHMENTS_BUCKET_NAME:
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
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:

View File

@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from "react";
interface UseDragToScrollOptions {
/**
* Whether drag-to-scroll is enabled
*/
enabled?: boolean;
/**
* The direction to scroll
*/
direction?: "horizontal" | "vertical" | "both";
}
/**
* Hook to enable drag-to-scroll functionality on a scrollable element
* @param options Configuration options
* @returns Ref to attach to the scrollable element and mouse event handlers
*/
export function useDragToScroll({
enabled = true,
direction = "horizontal",
}: UseDragToScrollOptions = {}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const startPosRef = useRef({ x: 0, y: 0 });
const scrollStartRef = useRef({ x: 0, y: 0 });
useEffect(() => {
if (!enabled || !isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!scrollRef.current) return;
const deltaX = e.clientX - startPosRef.current.x;
const deltaY = e.clientY - startPosRef.current.y;
if (direction === "horizontal" || direction === "both") {
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
}
if (direction === "vertical" || direction === "both") {
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [enabled, isDragging, direction]);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
const target = e.target as HTMLElement;
const container = scrollRef.current;
// Check if the click is on an interactive or draggable element
// We need to be careful not to interfere with react-beautiful-dnd dragging
const isInteractiveElement =
target.closest("a") ||
target.closest("button") ||
target.closest("input") ||
target.closest("textarea") ||
target.closest("[role='button']") ||
target.closest("[draggable='true']") ||
target.closest(".react-beautiful-dnd-drag-handle") ||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
target.closest("[data-rbd-draggable-id]");
// Don't start dragging if clicking on interactive elements
if (isInteractiveElement) return;
// Enable drag-to-scroll for any non-interactive element within the container
if (container.contains(target)) {
e.preventDefault();
setIsDragging(true);
startPosRef.current = { x: e.clientX, y: e.clientY };
scrollStartRef.current = {
x: container.scrollLeft,
y: container.scrollTop,
};
}
};
return {
ref: scrollRef,
onMouseDown: handleMouseDown,
isDragging,
};
}

View File

@@ -1,4 +1,6 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { useLingui } from "@lingui/react";
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
@@ -8,6 +10,20 @@ export function useLocalisation() {
const { i18n } = useLingui();
const { locale, setLocale, availableLocales } = useLinguiContext();
const dateLocaleMap: Partial<Record<Locale, DateFnsLocale>> = {
en: enGB,
fr,
de,
es,
it,
nl,
ru,
pl,
ptbr: ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
const handleSetLocale = async (newLocale: Locale) => {
await activateLocale(newLocale);
setLocale(newLocale);
@@ -15,9 +31,9 @@ export function useLocalisation() {
return {
locale,
dateLocale: currentDateLocale,
setLocale: handleSetLocale,
availableLocales,
formatDate: i18n.date,
formatNumber: i18n.number,
};
}

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useModal } from "~/providers/modal";
interface UseModalFormStateOptions<T> {
@@ -12,25 +13,43 @@ export function useModalFormState<T extends Record<string, any>>({
initialValues,
resetOnClose = false,
}: UseModalFormStateOptions<T>) {
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
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);
};
// Keep refs so the callbacks below stay stable across re-renders.
const modalTypeRef = useRef(modalType);
const initialValuesRef = useRef(initialValues);
const getModalStateRef = useRef(getModalState);
modalTypeRef.current = modalType;
initialValuesRef.current = initialValues;
getModalStateRef.current = getModalState;
const clearFormState = () => {
clearModalState(modalType);
};
const saveFormState = useCallback(
(state: Partial<T>) => {
const type = modalTypeRef.current;
const currentState =
getModalStateRef.current(type) ?? initialValuesRef.current;
const newState = { ...currentState, ...state };
setModalState(type, newState);
},
// setModalState is stable (useCallback with [] deps in ModalProvider)
[setModalState],
);
const clearFormState = useCallback(() => {
clearModalState(modalTypeRef.current);
}, [clearModalState]);
useEffect(() => {
if (resetOnClose && !isOpen && savedState) {
@@ -45,4 +64,4 @@ export function useModalFormState<T extends Record<string, any>>({
isCurrentModal,
hasSavedState: !!savedState,
};
}
}

View File

@@ -0,0 +1,112 @@
import type { Permission } from "@kan/shared";
import { useContext } from "react";
import { WorkspaceContext } from "~/providers/workspace";
import { api } from "~/utils/api";
interface UsePermissionsResult {
permissions: Permission[];
role: string | null;
isLoading: boolean;
hasPermission: (permission: Permission) => boolean;
canViewCard: boolean;
canCreateCard: boolean;
canEditCard: boolean;
canDeleteCard: boolean;
canCreateList: boolean;
canEditList: boolean;
canDeleteList: boolean;
canCreateBoard: boolean;
canEditBoard: boolean;
canDeleteBoard: boolean;
canArchiveBoard: boolean;
canViewComment: boolean;
canCreateComment: boolean;
canEditComment: boolean;
canDeleteComment: boolean;
canInviteMember: boolean;
canEditMember: boolean;
canRemoveMember: boolean;
canViewWorkspace: boolean;
canEditWorkspace: boolean;
}
export function usePermissions(): UsePermissionsResult {
// Check if WorkspaceProvider is available (for public board views, it may not be)
const workspaceContext = useContext(WorkspaceContext);
// If WorkspaceProvider is not available, return safe defaults
if (!workspaceContext) {
const emptyPermissions: UsePermissionsResult = {
permissions: [],
role: null,
isLoading: false,
hasPermission: () => false,
canViewCard: false,
canCreateCard: false,
canEditCard: false,
canDeleteCard: false,
canCreateList: false,
canEditList: false,
canDeleteList: false,
canCreateBoard: false,
canEditBoard: false,
canDeleteBoard: false,
canArchiveBoard: false,
canViewComment: false,
canCreateComment: false,
canEditComment: false,
canDeleteComment: false,
canInviteMember: false,
canEditMember: false,
canRemoveMember: false,
canViewWorkspace: false,
canEditWorkspace: false,
};
return emptyPermissions;
}
const { workspace } = workspaceContext;
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
{ workspacePublicId: workspace.publicId },
{
enabled: !!workspace.publicId,
},
);
const permissions = (data?.permissions ?? []) as Permission[];
const role = data?.role ?? null;
const hasPermission = (permission: Permission): boolean => {
return permissions.includes(permission);
};
return {
permissions,
role,
isLoading,
hasPermission,
canViewCard: hasPermission("card:view"),
canCreateCard: hasPermission("card:create"),
canEditCard: hasPermission("card:edit"),
canDeleteCard: hasPermission("card:delete"),
canCreateList: hasPermission("list:create"),
canEditList: hasPermission("list:edit"),
canDeleteList: hasPermission("list:delete"),
canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"),
canArchiveBoard: hasPermission("board:edit"),
canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"),
canDeleteComment: hasPermission("comment:delete"),
canInviteMember: hasPermission("member:invite"),
canEditMember: hasPermission("member:edit"),
canRemoveMember: hasPermission("member:remove"),
canViewWorkspace: hasPermission("workspace:view"),
canEditWorkspace: hasPermission("workspace:edit"),
};
}

View File

@@ -0,0 +1,44 @@
import type { RefObject } from "react";
import type { NextRouter } from "next/router";
import { useEffect, useRef } from "react";
const scrollPositions = new Map<string, number>();
export function useScrollRestore(
boardId: string | null | undefined,
scrollRef: RefObject<HTMLElement | null>,
router: NextRouter,
isReady: boolean,
) {
const restored = useRef(false);
useEffect(() => {
if (!boardId) return;
restored.current = false;
const saveScrollPosition = () => {
if (scrollRef.current) {
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
}
};
router.events.on("routeChangeStart", saveScrollPosition);
return () => router.events.off("routeChangeStart", saveScrollPosition);
}, [boardId, router.events, scrollRef]);
useEffect(() => {
if (restored.current || !isReady || !boardId) return;
restored.current = true;
const saved = scrollPositions.get(boardId);
if (saved === undefined) return;
// StrictModeDroppable delays rendering by one requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
});
});
}, [isReady, scrollRef, boardId]);
}

View File

@@ -1,6 +0,0 @@
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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ export const locales = [
"nl",
"ru",
"pl",
"ptbr"
] as const;
export type Locale = (typeof locales)[number];
@@ -22,4 +23,5 @@ export const localeNames: Record<Locale, string> = {
nl: "Nederlands",
ru: "Русский",
pl: "Polski",
ptbr: "Português",
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
export default function NotFoundPage() {
return (
<>
<PageHead title={t`404 - Page Not Found | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
<Link href="/">
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
404
</p>
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
<Trans>Page not found</Trans>
</p>
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
<Trans>
The page you're looking for doesn't exist or has been moved.
</Trans>
</p>
<div className="flex flex-col gap-3">
<Link
href="/"
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
>
<Trans>Go to homepage</Trans>
</Link>
<Link
href="/boards"
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
>
<Trans>Go to boards</Trans>
</Link>
</div>
</div>
</div>
</div>
<PatternedBackground />
</div>
</main>
</>
);
}

View File

@@ -12,6 +12,8 @@ import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
import { FontSizeProvider } from "~/providers/font-size";
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
@@ -80,21 +82,25 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
)}
<script src="/__ENV.js" />
<main className="font-sans">
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
<KeyboardShortcutProvider>
<LinguiProviderWrapper>
<FontSizeProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</FontSizeProvider>
</LinguiProviderWrapper>
</KeyboardShortcutProvider>
</main>
</>
);

View File

@@ -2,9 +2,34 @@ import { toNodeHandler } from "better-auth/node";
import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export const config = { api: { bodyParser: false } };
export const auth = initAuth(createDrizzleClient());
export default toNodeHandler(auth.handler);
const authHandler = toNodeHandler(auth.handler);
export default withRateLimit(
{ points: 100, duration: 60 },
async (req, res) => {
/**
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
*/
const forwardedProto = req.headers["x-forwarded-proto"];
if (forwardedProto) {
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
}
const forwardedHost = req.headers["x-forwarded-host"];
if (forwardedHost) {
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
req.headers["host"] = h?.split(",")[0]?.trim();
}
return await authHandler(req, res);
},
);

View File

@@ -0,0 +1,75 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit(
{ points: 100, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { url, filename } = req.query;
if (!url || typeof url !== "string") {
return res.status(400).json({ message: "url parameter is required" });
}
const s3Endpoint = env.S3_ENDPOINT;
if (s3Endpoint) {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return res.status(400).json({ message: "Invalid URL" });
}
const hostname = parsed.hostname.toLowerCase();
let allowedHost: string;
try {
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
} catch {
return res
.status(500)
.json({ message: "Storage endpoint misconfigured" });
}
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
return res.status(403).json({ message: "URL not allowed" });
}
}
try {
const downloadFilename =
typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res
.status(upstream.status)
.json({ message: "Failed to fetch attachment" });
}
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
return res.status(500).json({ message: "Failed to download attachment" });
}
}),
);

View File

@@ -1,23 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
export default withRateLimit(
{ points: 100, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
console.error("Error fetching OSS friends:", error);
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}),
);

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