Compare commits

...

20 Commits

Author SHA1 Message Date
Henry
d04fd62b8d feat: add fetch depth 2026-01-13 11:02:38 +00:00
Henry
cd3f6fc918 feat: display app version 2026-01-12 21:34:50 +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
118 changed files with 15958 additions and 2753 deletions

View File

@@ -34,6 +34,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
@@ -74,6 +76,42 @@ jobs:
type=semver,pattern={{major}}
type=raw,value=latest,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
- name: Build and push Docker image
@@ -86,6 +124,8 @@ 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

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ yarn-error.log*
.env
.env*.local
# vercel
.vercel

View File

@@ -138,44 +138,45 @@ 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` |
| `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` |
| `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` |
| 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` |
| `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` |
| `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` |
| `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_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` |
| `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_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` |
See `.env.example` for a complete list of supported environment variables.

View File

@@ -4,8 +4,9 @@ ARG PROJECT=@kan/web
# 1. Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update
RUN apk add --no-cache libc6-compat python3 make g++
RUN apk update && \
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true
# Setup pnpm and turbo on the alpine base
FROM alpine AS base
@@ -36,9 +37,11 @@ RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
ARG APP_VERSION
# Environment to skip .env validation on build
ENV CI=true
ENV NEXT_PUBLIC_APP_VERSION=${APP_VERSION}
WORKDIR /app

View File

@@ -2,7 +2,7 @@
"version": 0,
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
},
"buckets": {
"po": {

View File

@@ -15,6 +15,7 @@ checksums:
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
Actions/singular: c46571856723b03262fd33f511116298
Activity/singular: 1948763de8e531483a798b68195e297e
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
@@ -26,6 +27,7 @@ checksums:
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
Add%20label/singular: 0be732d46df263265935fda342097a08
Add%20member/singular: 11979625770516ca287e929381778e02
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
@@ -91,6 +93,7 @@ checksums:
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
changed%20the%20due%20date%20to%20%3C0%3E%7BformattedDate%7D%3C%2F0%3E/singular: 32189aa87452ab18f90f02742bfa9910
Check%20out%20some%20of%20our%20favorite%20open%20source%20projects./singular: b0d0be4f63c16552b3aa795af29240d1
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
@@ -120,6 +123,7 @@ checksums:
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
@@ -175,14 +179,22 @@ checksums:
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
Download%20failed/singular: 142b391cca3e05f2af9617092f7358c5
Due%20date/singular: b098b52aff38516c5838c2ac4419f958
Due%20next%20month/singular: 223ab6f1211e1dfdf39ea68d25fe380b
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
email/singular: f31eb214738e037d58e26149797739df
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
@@ -212,6 +224,7 @@ checksums:
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
@@ -231,6 +244,7 @@ checksums:
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
Full-time/singular: fe6e201f6676ae354111435cf6c76910
Fun/singular: 395bdc48e943762b6cde649f4a96771d
General/singular: b891e8f15579fc5d97bcaf3637f5ae59
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
@@ -242,6 +256,10 @@ checksums:
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
Go%20to%20boards/singular: dc1362de207d67fe84fb0caf60262e73
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
@@ -285,6 +303,7 @@ checksums:
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
Keyboard%20Shortcuts/singular: ef00d7494b69def6841620bd6554d040
Labels/singular: 6f15627a90002323eac018274b6922d6
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
@@ -301,6 +320,8 @@ checksums:
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
Lists/singular: 9f4a73afc8de321175d71935134ef066
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
@@ -318,6 +339,7 @@ checksums:
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
Name/singular: 9368b5a047572b6051f334af5aa76819
Navigation/singular: 0373afd8238db1c49f4be4fa6cdf5cd3
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
New/singular: 126d036fae5fb6b629728ecb97e6195b
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
@@ -338,18 +360,23 @@ checksums:
No%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
No%20dates/singular: bd0ec82b3b1f4a5fa89d362b71f8141d
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
Open%20command%20menu/singular: cf00563ea1a994e7c36a761d56664acc
Open%20keyboard%20shortcuts/singular: 638ae0999fd03d2c611064274859422e
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
Open%20Source%20Friends/singular: ffe3d75e5d03b8469c9dcb019dceb164
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
@@ -369,6 +396,7 @@ checksums:
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
@@ -390,9 +418,11 @@ checksums:
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
@@ -417,12 +447,15 @@ checksums:
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
Senior/singular: 3fff865dc00435f82896fc302ea45630
Set%20due%20date/singular: 3cb81c4829857556f5d117c9a23f7bcc
set%20the%20due%20date/singular: b5d9a1edd988f455041aee9fa7fb3d63
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
@@ -469,6 +502,7 @@ checksums:
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
Title/singular: 344e64395eaff6822a57d18623853e1a
To%20Do/singular: d60813ea824f373462471e092d136eed
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
@@ -479,6 +513,7 @@ checksums:
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
@@ -490,6 +525,7 @@ checksums:
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
@@ -497,6 +533,7 @@ checksums:
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
Unable%20to%20update%20due%20date/singular: af20d7482cb639a8c8264c681a18abed
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
@@ -515,6 +552,7 @@ checksums:
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
updated%20the%20due%20date/singular: 7636c200d50fed58a4295ed1e9b5c1e0
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
@@ -586,3 +624,4 @@ checksums:
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148

View File

@@ -1,7 +1,7 @@
import type { LinguiConfig } from "@lingui/conf";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
sourceLocale: "en",
catalogs: [
{

View File

@@ -26,9 +26,11 @@ 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: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
@@ -75,12 +77,13 @@ const config = {
return patterns;
})(),
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});
return config;
turbopack: {
rules: {
"*.svg": {
loaders: ["@svgr/webpack"],
as: "*.js",
},
},
},
experimental: {
// instrumentationHook: true,

View File

@@ -4,9 +4,9 @@
"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",
@@ -47,12 +47,10 @@
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"jose": "^6.1.2",
"next": "^15.3.4",
"next-logger": "^5.0.1",
"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",
@@ -86,7 +84,6 @@
"dotenv-cli": "^7.4.4",
"eslint": "catalog:",
"jiti": "^1.21.6",
"pino-pretty": "^13.0.0",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.0 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

@@ -0,0 +1,136 @@
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;
}
const DateSelector = ({ selectedDate, onDateSelect }: 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: 1 }); // Monday
return eachDayOfInterval({
start: weekStart,
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
}, []);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
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]);
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

@@ -1,3 +1,4 @@
import type { Range as TiptapRange } from "@tiptap/core";
import type { Editor as TiptapEditor } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
@@ -44,6 +45,7 @@ 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> {
@@ -56,7 +58,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;
}
@@ -431,12 +433,14 @@ export default function Editor({
onBlur,
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -484,10 +488,17 @@ export default function Editor({
}),
);
const q = query.toLowerCase();
return all.filter((u) => u.label.toLowerCase().includes(q));
return all.filter(
(u) =>
u.label &&
typeof u.label === "string" &&
u.label.toLowerCase().includes(q),
);
},
command: ({ editor, range, props }: any) => {
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span>&nbsp;`;
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()
@@ -503,6 +514,7 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
@@ -559,6 +571,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

@@ -46,7 +46,8 @@ const Button: React.FC<{
onMouseEnter={handleMouseEnter}
onClick={handleClick}
className={twMerge(
"group flex h-[34px] items-center justify-between 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",
"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",

View File

@@ -9,6 +9,7 @@ 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";
@@ -225,6 +226,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

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

@@ -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()
@@ -94,6 +95,7 @@ export const env = createEnv({
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_APP_VERSION: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.transform((s) => (s === "" ? undefined : s))
@@ -131,6 +133,7 @@ export const env = createEnv({
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
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

@@ -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, ru, ptBR} 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,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 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 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

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 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 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 one or more lines are too long

View File

@@ -88,6 +88,7 @@ export default async function handler(
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [
{
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,

View File

@@ -0,0 +1,16 @@
import type { api } from "~/utils/api";
/**
* Invalidates all card-related queries for a given card.
* Use this after any mutation that affects card data or activities.
*/
export async function invalidateCard(
utils: ReturnType<typeof api.useUtils>,
cardPublicId: string,
) {
await Promise.all([
utils.card.byId.invalidate({ cardPublicId }),
utils.card.getActivities.invalidate({ cardPublicId }),
]);
}

View File

@@ -22,6 +22,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/ru/messages")).messages;
case "pl":
return (await import("~/locales/pl/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,10 +1,17 @@
import { format, isBefore, isSameYear, startOfDay } from "date-fns";
import { HiOutlinePaperClip } from "react-icons/hi";
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
import {
HiBars3BottomLeft,
HiChatBubbleLeft,
HiOutlineClock,
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
import CircularProgress from "~/components/CircularProgress";
import LabelIcon from "~/components/LabelIcon";
import { useLocalisation } from "~/hooks/useLocalisation";
import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
@@ -15,6 +22,7 @@ const Card = ({
description,
comments,
attachments,
dueDate,
}: {
title: string;
labels: { name: string; colourCode: string | null }[];
@@ -36,7 +44,11 @@ const Card = ({
description: string | null;
comments: { publicId: string }[];
attachments?: { publicId: string }[];
dueDate?: Date | null;
}) => {
const { dateLocale } = useLocalisation();
const showYear = dueDate ? !isSameYear(dueDate, new Date()) : false;
const isOverdue = dueDate ? isBefore(dueDate, startOfDay(new Date())) : false;
const completedItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.filter((item) => item.completed).length;
}, 0);
@@ -51,6 +63,7 @@ const Card = ({
const hasDescription =
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
const hasAttachments = attachments && attachments.length > 0;
const hasDueDate = !!dueDate;
return (
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
@@ -60,6 +73,7 @@ const Card = ({
checklists.length > 0 ||
hasDescription ||
comments.length > 0 ||
hasDueDate ||
hasAttachments ? (
<div className="mt-2 flex flex-col justify-end">
<div className="space-x-0.5">
@@ -77,6 +91,23 @@ const Card = ({
<HiBars3BottomLeft className="h-4 w-4" />
</div>
)}
{hasDueDate && dueDate && (
<div
className={twMerge(
"flex items-center gap-1",
isOverdue
? "text-red-600 dark:text-red-400"
: "text-light-800 dark:text-dark-800",
)}
>
<HiOutlineClock className="h-4 w-4" />
<span className="text-[11px]">
{format(dueDate, showYear ? "do MMM yyyy" : "do MMM", {
locale: dateLocale,
})}
</span>
</div>
)}
{comments.length > 0 && (
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
<HiChatBubbleLeft className="h-4 w-4" />

View File

@@ -2,6 +2,7 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiMiniXMark,
HiOutlineClock,
HiOutlineSquare3Stack3D,
HiOutlineTag,
HiOutlineUserCircle,
@@ -60,7 +61,13 @@ const Filters = ({
try {
await router.push({
pathname: router.pathname,
query: { ...router.query, members: [], labels: [], lists: [] },
query: {
...router.query,
members: [],
labels: [],
lists: [],
dueDate: [],
},
});
} catch (error) {
console.error(error);
@@ -99,6 +106,39 @@ const Filters = ({
selected: !!router.query.lists?.includes(list.publicId),
}));
const dueDateItems = [
{
key: "overdue",
value: t`Overdue`,
selected: !!router.query.dueDate?.includes("overdue"),
},
{
key: "today",
value: t`Due today`,
selected: !!router.query.dueDate?.includes("today"),
},
{
key: "tomorrow",
value: t`Due tomorrow`,
selected: !!router.query.dueDate?.includes("tomorrow"),
},
{
key: "next-week",
value: t`Due next week`,
selected: !!router.query.dueDate?.includes("next-week"),
},
{
key: "next-month",
value: t`Due next month`,
selected: !!router.query.dueDate?.includes("next-month"),
},
{
key: "no-due-date",
value: t`No dates`,
selected: !!router.query.dueDate?.includes("no-due-date"),
},
];
const groups = [
...(formattedMembers.length
? [
@@ -126,6 +166,12 @@ const Filters = ({
},
]
: []),
{
key: "dueDate",
label: t`Due date`,
icon: <HiOutlineClock size={16} />,
items: dueDateItems,
},
];
const handleSelect = async (
@@ -156,6 +202,7 @@ const Filters = ({
...formatToArray(router.query.members),
...formatToArray(router.query.labels),
...formatToArray(router.query.lists),
...formatToArray(router.query.dueDate),
].length;
return (

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useEffect } from "react";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import {
HiOutlineBarsArrowDown,
@@ -15,6 +16,7 @@ import type { WorkspaceMember } from "~/components/Editor";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import DateSelector from "~/components/DateSelector";
import Editor from "~/components/Editor";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
@@ -27,6 +29,7 @@ import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
type NewCardFormInput = NewCardInput & {
isCreateAnotherEnabled: boolean;
dueDate?: Date | null;
};
interface QueryParams {
@@ -65,6 +68,7 @@ export function NewCardForm({
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
dueDate: null,
},
resetOnClose: true,
});
@@ -80,6 +84,8 @@ export function NewCardForm({
const position = watch("position");
const title = watch("title");
const description = watch("description");
const dueDate = watch("dueDate");
const [isDateSelectorOpen, setIsDateSelectorOpen] = useState(false);
// saving form state whenever form values change
useEffect(() => {
@@ -137,6 +143,7 @@ export function NewCardForm({
title: args.title,
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),
@@ -193,6 +200,7 @@ export function NewCardForm({
memberPublicIds: [],
isCreateAnotherEnabled,
position,
dueDate: null,
};
reset(newFormState);
saveFormState(newFormState);
@@ -242,7 +250,7 @@ export function NewCardForm({
),
})) ?? [];
const onSubmit = (data: NewCardInput) => {
const onSubmit = (data: NewCardFormInput) => {
createCard.mutate({
title: data.title,
description: data.description,
@@ -250,6 +258,7 @@ export function NewCardForm({
labelPublicIds: data.labelPublicIds,
memberPublicIds: data.memberPublicIds,
position: data.position,
dueDate: data.dueDate ?? null,
});
};
@@ -326,7 +335,7 @@ export function NewCardForm({
saveFormState({ ...formState, description: value });
}}
workspaceMembers={
boardData?.workspace.members?.map(
boardData?.workspace.members.map(
(member): WorkspaceMember => ({
publicId: member.publicId,
email: member.email,
@@ -340,6 +349,7 @@ export function NewCardForm({
}),
) ?? []
}
enableYouTubeEmbed={false}
/>
</div>
</div>
@@ -448,6 +458,44 @@ export function NewCardForm({
</div>
</CheckboxDropdown>
</div>
<div className="relative w-fit">
<button
type="button"
onClick={() => setIsDateSelectorOpen(!isDateSelectorOpen)}
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500"
>
{dueDate ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
) : (
<>{t`Due date`}</>
)}
</button>
{isDateSelectorOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsDateSelectorOpen(false)}
/>
<div
className="absolute left-0 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
onClick={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<DateSelector
selectedDate={dueDate ?? undefined}
onDateSelect={(date) => {
setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false);
}}
/>
</div>
</>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();

View File

@@ -24,6 +24,7 @@ import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -87,18 +88,26 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
});
};
const queryParams: {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
type: "regular" | "template";
} = {
const semanticFilters = formatToArray(router.query.dueDate) as (
| "overdue"
| "today"
| "tomorrow"
| "next-week"
| "next-month"
| "no-due-date"
)[];
const boardType: "regular" | "template" = isTemplate ? "template" : "regular";
const queryParams = {
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
lists: formatToArray(router.query.lists),
type: isTemplate ? "template" : "regular",
...(semanticFilters.length > 0 && {
dueDateFilters: semanticFilters,
}),
type: boardType,
};
const {
@@ -364,6 +373,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
sourceBoardName={boardData?.name ?? ""}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
>
<EditYouTubeModal />
</Modal>
</>
);
};
@@ -567,6 +583,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
}
comments={card.comments ?? []}
attachments={card.attachments}
dueDate={card.dueDate ?? null}
/>
</Link>
)}

View File

@@ -1,11 +1,13 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { formatDistanceToNow } from "date-fns";
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
import { format, formatDistanceToNow, isSameYear } from "date-fns";
import { useEffect, useRef, useState } from "react";
import {
HiOutlineArrowLeft,
HiOutlineArrowRight,
HiOutlineCheckCircle,
HiOutlineClock,
HiOutlinePencil,
HiOutlinePlus,
HiOutlineTag,
@@ -14,25 +16,20 @@ import {
HiOutlineUserPlus,
} from "react-icons/hi2";
import type { GetCardByIdOutput } from "@kan/api/types";
import type {
GetCardActivitiesOutput,
GetCardByIdOutput,
} from "@kan/api/types";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import { useLocalisation } from "~/hooks/useLocalisation";
import { api } from "~/utils/api";
import Comment from "./Comment";
type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
const dateLocaleMap = {
en: enGB,
fr: fr,
de: de,
es: es,
it: it,
nl: nl,
} as const;
const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
@@ -47,6 +44,9 @@ const getActivityText = ({
isSelf,
label,
fromTitle,
toDueDate,
dateLocale,
mergedLabels,
}: {
type: ActivityType;
toTitle: string | null;
@@ -56,7 +56,45 @@ const getActivityText = ({
isSelf: boolean;
label: string | null;
fromTitle?: string | null;
fromDueDate?: Date | null;
toDueDate?: Date | null;
dateLocale: DateFnsLocale;
mergedLabels?: string[];
}) => {
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (
type === "card.updated.label.added" &&
mergedLabels &&
mergedLabels.length > 1
) {
const labelList = mergedLabels.join(", ");
return (
<Trans>
added {mergedLabels.length} labels:{" "}
<TextHighlight>{labelList}</TextHighlight>
</Trans>
);
}
if (
type === "card.updated.label.removed" &&
mergedLabels &&
mergedLabels.length > 1
) {
const labelList = mergedLabels.join(", ");
return (
<Trans>
removed {mergedLabels.length} labels:{" "}
<TextHighlight>{labelList}</TextHighlight>
</Trans>
);
}
const ACTIVITY_TYPE_MAP = {
"card.created": t`created the card`,
"card.updated.title": t`updated the title`,
@@ -74,17 +112,14 @@ const getActivityText = ({
"card.updated.checklist.item.completed": t`completed a checklist item`,
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
"card.updated.dueDate.added": t`set the due date`,
"card.updated.dueDate.updated": t`updated the due date`,
"card.updated.dueDate.removed": t`removed the due date`,
} as const;
if (!(type in ACTIVITY_TYPE_MAP)) return null;
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (type === "card.updated.title" && toTitle) {
return (
<Trans>
@@ -209,6 +244,38 @@ const getActivityText = ({
);
}
if (type === "card.updated.dueDate.added" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format(
toDueDate,
showYear ? "do MMM yyyy" : "do MMM",
{ locale: dateLocale },
);
return (
<Trans>
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.dueDate.updated" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format(
toDueDate,
showYear ? "do MMM yyyy" : "do MMM",
{ locale: dateLocale },
);
return (
<Trans>
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.dueDate.removed") {
return <Trans>removed the due date</Trans>;
}
return baseText;
};
@@ -229,6 +296,9 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
"card.updated.dueDate.added": <HiOutlineClock />,
"card.updated.dueDate.updated": <HiOutlineClock />,
"card.updated.dueDate.removed": <HiOutlineClock />,
} as const;
const getActivityIcon = (
@@ -246,36 +316,151 @@ const getActivityIcon = (
return ACTIVITY_ICON_MAP[type] ?? null;
};
const ACTIVITIES_PAGE_SIZE = 20;
const ActivityList = ({
activities,
cardPublicId,
isLoading,
isLoading: cardIsLoading,
isAdmin,
isViewOnly,
}: {
activities: NonNullable<GetCardByIdOutput>["activities"];
cardPublicId: string;
isLoading: boolean;
isAdmin?: boolean;
isViewOnly?: boolean;
}) => {
const { data } = authClient.useSession();
const { locale } = useLocalisation();
const { dateLocale, locale } = useLocalisation();
const { data: sessionData } = authClient.useSession();
const utils = api.useUtils();
const [allActivities, setAllActivities] = useState<
GetCardActivitiesOutput["activities"]
>([]);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const currentDateLocale = dateLocaleMap[locale] || enGB;
const isFullyExpandedRef = useRef(false);
const lastDataUpdatedAtRef = useRef<number | null>(null);
const {
data: firstPageData,
isFetching: isFetchingFirst,
dataUpdatedAt,
} = api.card.getActivities.useQuery(
{
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
},
{
enabled: !!cardPublicId,
},
);
useEffect(() => {
if (firstPageData && dataUpdatedAt !== lastDataUpdatedAtRef.current) {
lastDataUpdatedAtRef.current = dataUpdatedAt;
if (isFullyExpandedRef.current && firstPageData.hasMore) {
setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore);
const fetchAllRemaining = async () => {
let currentActivities = [...firstPageData.activities];
let currentHasMore = firstPageData.hasMore;
while (currentHasMore) {
const lastActivity =
currentActivities[currentActivities.length - 1];
if (!lastActivity) break;
const nextCursor = new Date(lastActivity.createdAt).toISOString();
const nextPage = await utils.card.getActivities.fetch({
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(
currentActivities.map((a) => a.publicId),
);
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
currentActivities = [...currentActivities, ...newActivities];
currentHasMore = nextPage.hasMore;
} else {
break;
}
}
setAllActivities(currentActivities);
setHasMore(false);
};
fetchAllRemaining();
} else {
setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore);
if (!firstPageData.hasMore) {
isFullyExpandedRef.current = true;
}
}
}
}, [firstPageData, dataUpdatedAt, cardPublicId, utils.card.getActivities]);
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore || allActivities.length === 0) return;
const lastActivity = allActivities[allActivities.length - 1];
if (!lastActivity) return;
setIsLoadingMore(true);
try {
const nextCursor = new Date(lastActivity.createdAt).toISOString();
const nextPage = await utils.card.getActivities.fetch({
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(allActivities.map((a) => a.publicId));
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
setAllActivities((prev) => [...prev, ...newActivities]);
setHasMore(nextPage.hasMore);
if (!nextPage.hasMore) {
isFullyExpandedRef.current = true;
}
}
} finally {
setIsLoadingMore(false);
}
};
const isFetching = isFetchingFirst || isLoadingMore;
const isLoading =
cardIsLoading || (isFetchingFirst && allActivities.length === 0);
return (
<div className="flex flex-col space-y-4 pt-4">
{activities.map((activity, index) => {
{allActivities.map((activity, index) => {
const activityText = getActivityText({
type: activity.type,
toTitle: activity.toTitle,
fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === data?.user.id,
isSelf: activity.member?.user?.id === sessionData?.user.id,
label: activity.label?.name ?? null,
fromTitle: activity.fromTitle ?? null,
fromDueDate: activity.fromDueDate ?? null,
toDueDate: activity.toDueDate ?? null,
dateLocale: dateLocale,
mergedLabels: (activity as any).mergedLabels,
});
if (activity.type === "card.updated.comment.added")
@@ -290,7 +475,7 @@ const ActivityList = ({
createdAt={activity.createdAt.toISOString()}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === data?.user.id}
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
isAdmin={isAdmin ?? false}
isViewOnly={!!isViewOnly}
/>
@@ -315,11 +500,9 @@ const ActivityList = ({
)}
isLoading={isLoading}
/>
{index !== activities.length - 1 &&
activities[index + 1]?.type !==
"card.updated.comment.added" && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
{index !== allActivities.length - 1 && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
@@ -330,13 +513,24 @@ const ActivityList = ({
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
locale: currentDateLocale,
locale: dateLocale,
})}
</span>
</p>
</div>
);
})}
{hasMore && (
<div className="flex justify-center pt-4">
<button
onClick={handleLoadMore}
disabled={isFetching}
className="text-sm font-medium text-light-900 hover:text-light-1000 disabled:opacity-50 dark:text-dark-800 dark:hover:text-dark-1000"
>
{isFetching ? t`Loading...` : t`Load more activities`}
</button>
</div>
)}
</div>
);
};

View File

@@ -13,6 +13,7 @@ import {
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface Attachment {
publicId: string;
@@ -81,7 +82,7 @@ export function AttachmentThumbnails({
},
onSettled: async () => {
if (isReadOnly) return;
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -8,6 +8,7 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const { openModal } = useModal();
@@ -20,7 +21,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
showPopup({
header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`,

View File

@@ -1,11 +1,14 @@
import type { DraggableProvided } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2";
import { RiDraggable } from "react-icons/ri";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface ChecklistItemRowProps {
item: {
@@ -14,13 +17,19 @@ interface ChecklistItemRowProps {
completed: boolean;
};
cardPublicId: string;
onCreateNewItem?: () => void;
viewOnly?: boolean;
dragHandleProps?: DraggableProvided["dragHandleProps"];
isDragging?: boolean;
}
export default function ChecklistItemRow({
item,
cardPublicId,
onCreateNewItem,
viewOnly = false,
dragHandleProps,
isDragging = false,
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -62,7 +71,7 @@ export default function ChecklistItemRow({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});
@@ -90,7 +99,7 @@ export default function ChecklistItemRow({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});
@@ -138,7 +147,23 @@ export default function ChecklistItemRow({
};
return (
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
<div
className={twMerge(
"group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100",
isDragging && "opacity-80",
)}
>
{!viewOnly && (
<div
{...dragHandleProps}
className="absolute left-0 top-1/2 flex h-[20px] w-[20px] -translate-x-full -translate-y-1/2 cursor-grab items-center justify-center pr-1 opacity-0 transition-opacity group-hover:opacity-75 hover:opacity-100 active:cursor-grabbing"
>
<RiDraggable className="h-4 w-4 text-light-700 dark:text-dark-700" />
</div>
)}
{viewOnly && <div className="w-[20px] flex-shrink-0" />}
<label
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
>
@@ -164,7 +189,10 @@ export default function ChecklistItemRow({
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)}
// @ts-expect-error - valid event
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)}
onBlur={(e: Event) => {
const innerHTML = (e.target as HTMLElement).innerHTML;
commitTitle(innerHTML);
}}
className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
viewOnly && "cursor-default",
@@ -174,7 +202,9 @@ export default function ChecklistItemRow({
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
commitTitle(title);
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
commitTitle(innerHTML);
onCreateNewItem?.();
}
if (e.key === "Escape") {
e.preventDefault();

View File

@@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export default function ChecklistNameInput({
checklistPublicId,
@@ -48,7 +49,7 @@ export default function ChecklistNameInput({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -1,7 +1,13 @@
import type { DropResult } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { HiPlus, HiXMark } from "react-icons/hi2";
import CircularProgress from "~/components/CircularProgress";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import ChecklistItemRow from "./ChecklistItemRow";
import ChecklistNameInput from "./ChecklistNameInput";
import NewChecklistItemForm from "./NewChecklistItemForm";
@@ -34,109 +40,206 @@ export default function Checklists({
viewOnly = false,
}: ChecklistsProps) {
const { openModal } = useModal();
const { showPopup } = usePopup();
if (!checklists || checklists.length === 0) return null;
const utils = api.useUtils();
const reorderItemMutation = api.checklist.updateItem.useMutation({
onMutate: async (vars) => {
await utils.card.byId.cancel({ cardPublicId });
const previous = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (old) => {
if (!old) return old;
const updatedChecklists = old.checklists.map((cl) => {
const itemIndex = cl.items.findIndex(
(item) => item.publicId === vars.checklistItemPublicId,
);
if (itemIndex === -1 || vars.index === undefined) return cl;
const newIndex = vars.index;
const items = Array.from(cl.items);
const [movedItem] = items.splice(itemIndex, 1);
if (!movedItem) return cl;
items.splice(newIndex, 0, movedItem);
return { ...cl, items };
});
return { ...old, checklists: updatedChecklists } as typeof old;
});
return { previous };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous)
utils.card.byId.setData({ cardPublicId }, ctx.previous);
showPopup({
header: t`Unable to reorder checklist item`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId !== destination.droppableId) return;
if (source.index === destination.index) return;
reorderItemMutation.mutate({
checklistItemPublicId: draggableId,
index: destination.index,
});
};
if (checklists.length === 0) return null;
return (
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;
<DragDropContext onDragEnd={onDragEnd}>
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
{!viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
<div>
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</div>
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
)}
{viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
{!viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
<div>
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</div>
</div>
)}
{viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
</div>
)}
</div>
<Droppable
droppableId={checklist.publicId}
type="CHECKLIST_ITEM"
isDropDisabled={viewOnly}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="ml-1"
>
{checklist.items.map((item, index) => (
<Draggable
key={item.publicId}
draggableId={item.publicId}
index={index}
isDragDisabled={viewOnly}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1,
}}
>
<ChecklistItemRow
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardPublicId}
onCreateNewItem={() =>
setActiveChecklistForm?.(checklist.publicId)
}
viewOnly={viewOnly}
dragHandleProps={provided.dragHandleProps}
isDragging={snapshot.isDragging}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>
<div className="ml-1">
{checklist.items.map((item) => (
<ChecklistItemRow
key={item.publicId}
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
))}
</div>
{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>
);
})}
);
})}
</div>
</div>
</div>
</DragDropContext>
);
}

View File

@@ -11,6 +11,7 @@ import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
comment: string;
@@ -55,7 +56,7 @@ const Comment = ({
const updateCommentMutation = api.card.updateComment.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
await invalidateCard(utils, cardPublicId);
setIsEditing(false);
},
onError: () => {

View File

@@ -4,6 +4,7 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export function DeleteChecklistConfirmation({
cardPublicId,
@@ -40,7 +41,7 @@ export function DeleteChecklistConfirmation({
},
onSettled: async () => {
closeModal();
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -4,6 +4,7 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface DeleteCommentConfirmationProps {
cardPublicId: string;
@@ -47,7 +48,7 @@ export function DeleteCommentConfirmation({
});
},
onSettled: async () => {
await utils.card.byId.invalidate(queryParams);
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -0,0 +1,142 @@
import { t } from "@lingui/core/macro";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { HiMiniPlus } from "react-icons/hi2";
import DateSelector from "~/components/DateSelector";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface DueDateSelectorProps {
cardPublicId: string;
dueDate: Date | null | undefined;
isLoading?: boolean;
}
export function DueDateSelector({
cardPublicId,
dueDate,
isLoading = false,
}: DueDateSelectorProps) {
const { showPopup } = usePopup();
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
dueDate,
);
// Sync pendingDate with dueDate when it changes externally
useEffect(() => {
if (!isOpen) {
setPendingDate(dueDate);
}
}, [dueDate, isOpen]);
const updateDueDate = api.card.update.useMutation({
onMutate: async (update) => {
await utils.card.byId.cancel();
const previousCard = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (oldCard) => {
if (!oldCard) return oldCard;
return {
...oldCard,
dueDate:
update.dueDate !== undefined
? (update.dueDate as Date | null)
: oldCard.dueDate,
};
});
return { previousCard };
},
onError: (_error, _update, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: t`Unable to update due date`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});
const handleDateSelect = (date: Date | undefined) => {
// Only update local state, don't fire mutation
setPendingDate(date ?? null);
};
const handleBackdropClick = () => {
// Only fire mutation if date actually changed
const pendingIsNull = pendingDate === null || pendingDate === undefined;
const dueIsNull = dueDate === null || dueDate === undefined;
let dateChanged = false;
if (pendingIsNull && !dueIsNull) {
dateChanged = true;
} else if (!pendingIsNull && dueIsNull) {
dateChanged = true;
} else if (!pendingIsNull && !dueIsNull) {
// Both are non-null at this point
if (pendingDate instanceof Date && dueDate instanceof Date) {
dateChanged = pendingDate.getTime() !== dueDate.getTime();
}
}
// Close popover immediately
setIsOpen(false);
// Fire mutation if date changed (optimistic update will handle UI)
if (dateChanged) {
updateDueDate.mutate({
cardPublicId,
dueDate: pendingDate ?? null,
});
}
};
return (
<div className="relative flex w-full items-center text-left">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
>
{dueDate ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
) : (
<>
<HiMiniPlus size={22} className="pr-2" />
{t`Set due date`}
</>
)}
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
<div
className="absolute -left-8 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
onClick={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<DateSelector
selectedDate={pendingDate ?? undefined}
onDateSelect={handleDateSelect}
/>
</div>
</>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface LabelSelectorProps {
cardPublicId: string;
@@ -74,7 +75,7 @@ export default function LabelSelector({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface ListSelectorProps {
cardPublicId: string;
@@ -54,7 +55,7 @@ export default function ListSelector({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});
@@ -78,7 +79,7 @@ export default function ListSelector({
}}
asChild
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedList?.value}
</div>
</CheckboxDropdown>

View File

@@ -7,6 +7,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface MemberSelectorProps {
cardPublicId: string;
@@ -81,7 +82,7 @@ export default function MemberSelector({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});
@@ -111,7 +112,7 @@ export default function MemberSelector({
createNewItemLabel={t`Invite member`}
asChild
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedMembers.length ? (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{selectedMembers.map(({ value, imageUrl }) => (

View File

@@ -10,6 +10,7 @@ import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface NewChecklistFormInput {
name: string;
@@ -72,7 +73,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
});
},
onSettled: async (_data, _error, vars) => {
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
await invalidateCard(utils, vars.cardPublicId);
},
});

View File

@@ -7,6 +7,7 @@ import { generateUID } from "@kan/shared/utils";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
title: string;
@@ -91,7 +92,7 @@ const NewChecklistItemForm = ({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -6,6 +6,7 @@ import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
comment: string;
@@ -34,10 +35,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
},
onSettled: async () => {
reset();
await utils.card.byId.invalidate(queryParams);
},
onSuccess: async () => {
await utils.card.byId.refetch();
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -13,10 +13,12 @@ import LabelIcon from "~/components/LabelIcon";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
import ActivityList from "./components/ActivityList";
@@ -27,6 +29,7 @@ import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
import Dropdown from "./components/Dropdown";
import { DueDateSelector } from "./components/DueDateSelector";
import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
@@ -124,7 +127,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
/>
</div>
{!isTemplate && (
<div className="flex w-full flex-row">
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId ?? ""}
@@ -133,6 +136,14 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
/>
</div>
)}
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
<DueDateSelector
cardPublicId={cardId ?? ""}
dueDate={card?.dueDate}
isLoading={!card}
/>
</div>
</div>
);
}
@@ -147,6 +158,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
getModalState,
clearModalState,
isOpen,
modalStates,
} = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
@@ -168,7 +180,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const board = card?.list.board;
const boardId = board?.publicId;
const activities = card?.activities;
const updateCard = api.card.update.useMutation({
onError: () => {
@@ -179,7 +190,22 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId: cardId });
if (cardId) await invalidateCard(utils, cardId);
},
});
const addOrRemoveLabel = api.card.addOrRemoveLabel.useMutation({
onError: () => {
showPopup({
header: t`Unable to add label`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
if (cardId) {
await utils.card.byId.invalidate({ cardPublicId: cardId });
}
},
});
@@ -199,6 +225,24 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
});
};
// this adds the new created label to selected labels
useEffect(() => {
const newLabelId = modalStates.NEW_LABEL_CREATED;
if (newLabelId && cardId) {
const isAlreadyAdded = card?.labels.some(
(label) => label.publicId === newLabelId,
);
if (!isAlreadyAdded) {
addOrRemoveLabel.mutate({
cardPublicId: cardId,
labelPublicId: newLabelId,
});
}
clearModalState("NEW_LABEL_CREATED");
}
}, [modalStates.NEW_LABEL_CREATED, card, cardId]);
// Open the new item form after creating a new checklist
useEffect(() => {
if (!card) return;
@@ -345,7 +389,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div>
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={!card}
isAdmin={workspace.role === "admin"}
/>
@@ -441,6 +484,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
checklistPublicId={entityId}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
>
<EditYouTubeModal />
</Modal>
</>
</div>
</>

View File

@@ -1,15 +1,14 @@
import type { FC, SVGProps } from "react";
import { Trans } from "@lingui/react/macro";
import AirbusLogo from "/public/logos/airbus.svg";
import BitwardenLogo from "/public/logos/bitwarden.svg";
import CouchbaseLogo from "/public/logos/couchbase.svg";
import DeloitteLogo from "/public/logos/deloitte.svg";
import FastCompanyLogo from "/public/logos/fast_company.svg";
import LegoLogo from "/public/logos/lego.svg";
import LinkedinLogo from "/public/logos/linkedin.svg";
import SanaLogo from "/public/logos/sana.svg";
import WakamLogo from "/public/logos/wakam.svg";
import AirbusLogo from "~/assets/logos/airbus.svg";
import CouchbaseLogo from "~/assets/logos/couchbase.svg";
import DeloitteLogo from "~/assets/logos/deloitte.svg";
import FastCompanyLogo from "~/assets/logos/fast_company.svg";
import LegoLogo from "~/assets/logos/lego.svg";
import LinkedinLogo from "~/assets/logos/linkedin.svg";
import SanaLogo from "~/assets/logos/sana.svg";
import WakamLogo from "~/assets/logos/wakam.svg";
type LogoComponent = FC<SVGProps<SVGSVGElement>>;
@@ -31,36 +30,31 @@ export default function Logos() {
},
{
id: 3,
component: BitwardenLogo as LogoComponent,
alt: "Bitwarden Logo",
},
{
id: 4,
component: CouchbaseLogo as LogoComponent,
alt: "Couchbase Logo",
},
{
id: 5,
id: 4,
component: LegoLogo as LogoComponent,
alt: "Lego Logo",
},
{
id: 6,
id: 5,
component: AirbusLogo as LogoComponent,
alt: "Airbus Logo",
},
{
id: 7,
id: 6,
component: DeloitteLogo as LogoComponent,
alt: "Deloitte Logo",
},
{
id: 8,
id: 7,
component: WakamLogo as LogoComponent,
alt: "Wakam Logo",
},
{
id: 9,
id: 8,
component: LinkedinLogo as LogoComponent,
alt: "Linked In Logo",
},
@@ -82,7 +76,7 @@ export default function Logos() {
<div className="absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-white/60 to-transparent dark:from-dark-50" />
<div className="absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-white/60 to-transparent dark:from-dark-50" />
<div className="animate-scroll flex" style={{ width: "max-content" }}>
<div className="flex animate-scroll" style={{ width: "max-content" }}>
<div className="flex flex-shrink-0 items-center space-x-12">
{logos.map((logo) => {
const LogoComponent: LogoComponent = logo.component;

View File

@@ -161,7 +161,6 @@ export function CardModal({
{cardPublicId && (
<ActivityList
cardPublicId={cardPublicId}
activities={data?.activities ?? []}
isLoading={isLoading}
isViewOnly={true}
/>

View File

@@ -38,6 +38,15 @@ export default function PublicBoardView() {
? router.query.workspaceSlug[0]
: router.query.workspaceSlug;
const dueDateFilters = formatToArray(router.query.dueDate) as (
| "overdue"
| "today"
| "tomorrow"
| "next-week"
| "next-month"
| "no-due-date"
)[];
const { data, isLoading } = api.board.bySlug.useQuery(
{
boardSlug: boardSlug ?? "",
@@ -45,6 +54,9 @@ export default function PublicBoardView() {
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
lists: formatToArray(router.query.lists),
...(dueDateFilters.length > 0 && {
dueDateFilters: dueDateFilters,
}),
},
{
enabled: router.isReady && !!boardSlug,
@@ -77,7 +89,7 @@ export default function PublicBoardView() {
};
const pathWithoutQuery = router.asPath.split("?")[0];
const splitPath = pathWithoutQuery.split("/");
const splitPath = pathWithoutQuery?.split("/") ?? [];
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
useEffect(() => {
@@ -199,6 +211,7 @@ export default function PublicBoardView() {
description={card.description}
comments={card.comments ?? []}
attachments={card.attachments}
dueDate={card.dueDate ?? null}
/>
</Link>
);

View File

@@ -13,6 +13,7 @@ services:
- .env
restart: unless-stopped
environment:
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}

View File

@@ -18,6 +18,9 @@ services:
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
# Admin API key (optional)
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
# SMTP (optional)
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}

View File

@@ -9,5 +9,15 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
version: "1.0.0",
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
docsUrl: "docs.kan.bn",
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
tags: [
"Auth",
"Users",
"Boards",
"Lists",
"Cards",
"Labels",
"Imports",
"Integrations",
"Health",
],
});

View File

@@ -3,6 +3,7 @@ import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card";
import { checklistRouter } from "./routers/checklist";
import { feedbackRouter } from "./routers/feedback";
import { healthRouter } from "./routers/health";
import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration";
import { labelRouter } from "./routers/label";
@@ -18,6 +19,7 @@ export const appRouter = createTRPCRouter({
card: cardRouter,
checklist: checklistRouter,
feedback: feedbackRouter,
health: healthRouter,
label: labelRouter,
list: listRouter,
member: memberRouter,

View File

@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
import { generateUploadUrl } from "../utils/s3";
import { deleteObject, generateUploadUrl } from "../utils/s3";
export const attachmentRouter = createTRPCRouter({
generateUploadUrl: protectedProcedure
@@ -189,6 +189,18 @@ export const attachmentRouter = createTRPCRouter({
await assertUserInWorkspace(ctx.db, userId, workspaceId);
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (bucket) {
try {
await deleteObject(bucket, attachment.s3Key);
} catch (error) {
console.error(
`Failed to delete attachment from S3: ${attachment.s3Key}`,
error,
);
}
}
await cardAttachmentRepo.softDelete(ctx.db, {
attachmentId: attachment.id,
deletedAt: new Date(),

View File

@@ -8,7 +8,11 @@ import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants";
import { generateSlug, generateUID } from "@kan/shared/utils";
import {
convertDueDateFiltersToRanges,
generateSlug,
generateUID,
} from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
@@ -79,6 +83,18 @@ export const boardRouter = createTRPCRouter({
members: z.array(z.string().min(12)).optional(),
labels: z.array(z.string().min(12)).optional(),
lists: z.array(z.string().min(12)).optional(),
dueDateFilters: z
.array(
z.enum([
"overdue",
"today",
"tomorrow",
"next-week",
"next-month",
"no-due-date",
]),
)
.optional(),
type: z.enum(["regular", "template"]).optional(),
}),
)
@@ -105,6 +121,11 @@ export const boardRouter = createTRPCRouter({
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
// Convert semantic string filters to date ranges expected by the repo
const dueDateFilters = input.dueDateFilters
? convertDueDateFiltersToRanges(input.dueDateFilters)
: [];
const result = await boardRepo.getByPublicId(
ctx.db,
input.boardPublicId,
@@ -112,6 +133,7 @@ export const boardRouter = createTRPCRouter({
members: input.members ?? [],
labels: input.labels ?? [],
lists: input.lists ?? [],
dueDate: dueDateFilters,
type: input.type,
},
);
@@ -145,6 +167,18 @@ export const boardRouter = createTRPCRouter({
members: z.array(z.string().min(12)).optional(),
labels: z.array(z.string().min(12)).optional(),
lists: z.array(z.string().min(12)).optional(),
dueDateFilters: z
.array(
z.enum([
"overdue",
"today",
"tomorrow",
"next-week",
"next-month",
"no-due-date",
]),
)
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
@@ -160,6 +194,11 @@ export const boardRouter = createTRPCRouter({
code: "NOT_FOUND",
});
// Convert semantic string filters to date ranges expected by the repo
const dueDateFilters = input.dueDateFilters
? convertDueDateFiltersToRanges(input.dueDateFilters)
: [];
const result = await boardRepo.getBySlug(
ctx.db,
input.boardSlug,
@@ -168,6 +207,7 @@ export const boardRouter = createTRPCRouter({
members: input.members ?? [],
labels: input.labels ?? [],
lists: input.lists ?? [],
dueDate: dueDateFilters,
},
);
@@ -239,6 +279,7 @@ export const boardRouter = createTRPCRouter({
members: [],
labels: [],
lists: [],
dueDate: [],
type: sourceBoardInfo.type,
},
);
@@ -281,12 +322,6 @@ export const boardRouter = createTRPCRouter({
sourceBoardId: sourceBoardInfo.id,
});
if (!result)
throw new TRPCError({
message: `Failed to create board from source`,
code: "INTERNAL_SERVER_ERROR",
});
return result;
}

View File

@@ -9,6 +9,7 @@ import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities";
import { assertUserInWorkspace } from "../utils/auth";
import { generateDownloadUrl } from "../utils/s3";
@@ -26,12 +27,13 @@ export const cardRouter = createTRPCRouter({
})
.input(
z.object({
title: z.string().min(1),
title: z.string().min(1).max(2000),
description: z.string().max(10000),
listPublicId: z.string().min(12),
labelPublicIds: z.array(z.string().min(12)),
memberPublicIds: z.array(z.string().min(12)),
position: z.enum(["start", "end"]),
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
@@ -69,6 +71,7 @@ export const cardRouter = createTRPCRouter({
createdBy: userId,
listId: list.id,
position: input.position,
dueDate: input.dueDate ?? null,
});
const newCardId = newCard.id;
@@ -668,6 +671,81 @@ export const cardRouter = createTRPCRouter({
return { ...result, attachments: [] };
}),
getActivities: publicProcedure
.meta({
openapi: {
summary: "Get paginated card activities",
method: "GET",
path: "/cards/{cardPublicId}/activities",
description:
"Retrieves paginated activities for a card with merged frequent changes",
tags: ["Cards"],
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
limit: z.number().min(1).max(100).optional().default(10),
cursor: z.string().datetime().optional(), // ISO datetime string
}),
)
.output(
z.object({
activities: z.array(
z.custom<
NonNullable<
Awaited<
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
>
>["activities"][number]
>(),
),
hasMore: z.boolean(),
nextCursor: z.string().datetime().nullable(),
}),
)
.query(async ({ ctx, input }) => {
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
input.cardPublicId,
);
if (!card)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
if (card.workspaceVisibility === "private") {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
}
const cursor = input.cursor ? new Date(input.cursor) : undefined;
const result = await cardActivityRepo.getPaginatedActivities(
ctx.db,
card.id,
{
limit: input.limit,
cursor,
},
);
const mergedActivities = mergeActivities(result.activities);
return {
activities: mergedActivities,
hasMore: result.hasMore,
nextCursor: result.nextCursor?.toISOString() ?? null,
};
}),
update: protectedProcedure
.meta({
openapi: {
@@ -682,10 +760,11 @@ export const cardRouter = createTRPCRouter({
.input(
z.object({
cardPublicId: z.string().min(12),
title: z.string().min(1).optional(),
title: z.string().min(1).max(2000).optional(),
description: z.string().optional(),
index: z.number().optional(),
listPublicId: z.string().min(12).optional(),
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
@@ -746,15 +825,19 @@ export const cardRouter = createTRPCRouter({
title: string;
description: string | null;
publicId: string;
dueDate: Date | null;
}
| undefined;
if (input.title || input.description) {
const previousDueDate = existingCard.dueDate;
if (input.title || input.description || input.dueDate !== undefined) {
result = await cardRepo.update(
ctx.db,
{
...(input.title && { title: input.title }),
...(input.description && { description: input.description }),
...(input.dueDate !== undefined && { dueDate: input.dueDate }),
},
{ cardPublicId: input.cardPublicId },
);
@@ -796,6 +879,32 @@ export const cardRouter = createTRPCRouter({
});
}
if (
input.dueDate !== undefined &&
previousDueDate?.getTime() !== input.dueDate?.getTime()
) {
let activityType:
| "card.updated.dueDate.added"
| "card.updated.dueDate.updated"
| "card.updated.dueDate.removed";
if (!previousDueDate) {
activityType = "card.updated.dueDate.added";
} else if (!input.dueDate) {
activityType = "card.updated.dueDate.removed";
} else {
activityType = "card.updated.dueDate.updated";
}
activities.push({
type: activityType,
cardId: result.id,
createdBy: userId,
fromDueDate: previousDueDate ?? undefined,
toDueDate: input.dueDate ?? undefined,
});
}
if (newListId && existingCard.listId !== newListId) {
activities.push({
type: "card.updated.list" as const,

View File

@@ -269,7 +269,7 @@ export const checklistRouter = createTRPCRouter({
.meta({
openapi: {
summary: "Update a checklist item",
method: "PUT",
method: "PATCH",
path: "/checklists/items/{checklistItemPublicId}",
description: "Updates a checklist item (title/completed)",
tags: ["Cards"],
@@ -281,6 +281,7 @@ export const checklistRouter = createTRPCRouter({
checklistItemPublicId: z.string().length(12),
title: z.string().min(1).max(500).optional(),
completed: z.boolean().optional(),
index: z.number().int().min(0).optional(),
}),
)
.output(checklistItemSchema)
@@ -312,17 +313,30 @@ export const checklistRouter = createTRPCRouter({
const previousTitle = item.title;
const updated = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
let updatedItem;
if (input.title !== undefined || input.completed !== undefined) {
updatedItem = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
}
if (input.index !== undefined) {
updatedItem = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});
}
if (!updatedItem) {
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
}
if (!updated)
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
// Log completion toggle
if (input.completed !== undefined) {
@@ -331,7 +345,7 @@ export const checklistRouter = createTRPCRouter({
? "card.updated.checklist.item.completed"
: "card.updated.checklist.item.uncompleted",
cardId: item.checklist.cardId,
toTitle: updated.title,
toTitle: updatedItem.title,
createdBy: userId,
});
}
@@ -342,12 +356,13 @@ export const checklistRouter = createTRPCRouter({
type: "card.updated.checklist.item.updated",
cardId: item.checklist.cardId,
fromTitle: previousTitle,
toTitle: updated.title,
toTitle: updatedItem.title,
createdBy: userId,
});
}
return updated;
return updatedItem;
}),
deleteItem: protectedProcedure
.meta({

View File

@@ -0,0 +1,203 @@
import { HeadBucketCommand } from "@aws-sdk/client-s3";
import { TRPCError } from "@trpc/server";
import { sql } from "drizzle-orm";
import { env } from "next-runtime-env";
import { z } from "zod";
import type { dbClient } from "@kan/db/client";
import * as boardRepo from "@kan/db/repository/board.repo";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
import * as cardCommentRepo from "@kan/db/repository/cardComment.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import * as importRepo from "@kan/db/repository/import.repo";
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import {
adminProtectedProcedure,
createTRPCRouter,
publicProcedure,
} from "../trpc";
import { createS3Client } from "../utils/s3";
const checkDatabaseConnection = async (db: dbClient) => {
try {
await db.execute(sql`SELECT 1`);
return true;
} catch {
return false;
}
};
const checkS3Connection = async () => {
try {
// Check if S3 is configured
if (
!process.env.S3_ENDPOINT ||
!process.env.S3_ACCESS_KEY_ID ||
!process.env.S3_SECRET_ACCESS_KEY
) {
// S3 is optional, so return true if not configured
return true;
}
const client = createS3Client();
const avatarBucketName = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
const attachmentsBucketName = env("NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME");
await client.send(new HeadBucketCommand({ Bucket: avatarBucketName }));
await client.send(new HeadBucketCommand({ Bucket: attachmentsBucketName }));
return true;
} catch (error) {
console.error(error);
return false;
}
};
export const healthRouter = createTRPCRouter({
health: publicProcedure
.meta({
openapi: {
method: "GET",
path: "/health",
summary: "Health check",
description:
"Returns the health status of the application and its dependencies",
tags: ["Health"],
protect: false,
},
})
.input(z.void())
.output(
z.object({
status: z.enum(["ok", "error"]),
database: z.enum(["ok", "error"]).optional(),
storage: z.enum(["ok", "error", "not_configured"]).optional(),
}),
)
.query(async ({ ctx }) => {
const dbHealthy = await checkDatabaseConnection(ctx.db);
const s3Healthy = await checkS3Connection();
const s3Configured = !!(
process.env.S3_ENDPOINT &&
process.env.S3_ACCESS_KEY_ID &&
process.env.S3_SECRET_ACCESS_KEY
);
const database = dbHealthy ? "ok" : "error";
const storage = !s3Configured
? "not_configured"
: s3Healthy
? "ok"
: "error";
// Overall status is "ok" only if database is healthy and (storage is not configured or storage is healthy)
const status = dbHealthy && (!s3Configured || s3Healthy) ? "ok" : "error";
return {
status,
database,
storage,
};
}),
stats: adminProtectedProcedure
.meta({
openapi: {
method: "GET",
path: "/stats",
summary: "Get statistics",
description:
"Returns statistics about the application (workspaces, users, cards, etc.)",
tags: ["Health"],
protect: true,
},
})
.input(z.void())
.output(
z.object({
users: z.number(),
workspaces: z.number(),
boards: z.number(),
lists: z.number(),
cards: z.number(),
cardComments: z.number(),
cardAttachments: z.number(),
cardActivityLogs: z.number(),
labels: z.number(),
checklists: z.number(),
checklistItems: z.number(),
activeMembers: z.number(),
activeInviteLinks: z.number(),
imports: z.number(),
}),
)
.query(async ({ ctx }) => {
try {
// Execute all count queries in parallel
const [
usersCount,
workspacesCount,
boardsCount,
listsCount,
cardsCount,
cardCommentsCount,
checklistItemsCount,
checklistsCount,
labelsCount,
cardAttachmentsCount,
cardActivitiesCount,
activeMembersCount,
activeInviteLinksCount,
importsCount,
cardActivityLogsCount,
] = await Promise.all([
userRepo.getCount(ctx.db),
workspaceRepo.getCount(ctx.db),
boardRepo.getCount(ctx.db),
listRepo.getCount(ctx.db),
cardRepo.getCount(ctx.db),
cardCommentRepo.getCount(ctx.db),
checklistRepo.getCountItems(ctx.db),
checklistRepo.getCount(ctx.db),
labelRepo.getCount(ctx.db),
cardAttachmentRepo.getCount(ctx.db),
cardActivityRepo.getCount(ctx.db),
memberRepo.getActiveCount(ctx.db),
inviteLinkRepo.getActiveCount(ctx.db),
importRepo.getCount(ctx.db),
memberRepo.getActiveCount(ctx.db),
cardActivityRepo.getCount(ctx.db),
]);
return {
users: usersCount,
workspaces: workspacesCount,
boards: boardsCount,
lists: listsCount,
cards: cardsCount,
cardComments: cardCommentsCount,
checklistItems: checklistItemsCount,
checklists: checklistsCount,
labels: labelsCount,
cardAttachments: cardAttachmentsCount,
cardActivities: cardActivitiesCount,
activeMembers: activeMembersCount,
activeInviteLinks: activeInviteLinksCount,
imports: importsCount,
cardActivityLogs: cardActivityLogsCount,
};
} catch (error) {
throw new TRPCError({
message: `Failed to retrieve statistics: ${error instanceof Error ? error.message : "Unknown error"}`,
code: "INTERNAL_SERVER_ERROR",
});
}
}),
});

View File

@@ -52,7 +52,7 @@ interface TrelloCheckItem {
interface TrelloCard {
id: string;
name: string;
name: string | null;
desc: string;
idList: string;
labels: TrelloLabel[];
@@ -290,7 +290,7 @@ export const importRouter = createTRPCRouter({
if (list.cards.length && newListId) {
const cardsInsert = list.cards.map((card, index) => ({
publicId: generateUID(),
title: card.name,
title: (card.name?.trim() ?? "Untitled Card").slice(0, 2000),
description: card.description,
createdBy: userId,
listId: newListId,

View File

@@ -2,6 +2,7 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { NextApiRequest } from "next";
import type { OpenApiMeta } from "trpc-to-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import superjson from "superjson";
import { ZodError } from "zod";
@@ -45,6 +46,7 @@ interface CreateContextOptions {
user: User | null | undefined;
db: dbClient;
auth: ReturnType<typeof createAuthWithHeaders>;
headers: Headers;
}
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
@@ -52,6 +54,7 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
user: opts.user,
db: opts.db,
auth: opts.auth,
headers: opts.headers,
};
};
@@ -63,7 +66,7 @@ export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth });
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
};
export const createNextApiContext = async (req: NextApiRequest) => {
@@ -74,7 +77,7 @@ export const createNextApiContext = async (req: NextApiRequest) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth });
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
};
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
@@ -91,7 +94,7 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
throw error;
}
return createInnerTRPCContext({ db, user: session?.user, auth });
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
};
const t = initTRPC
@@ -129,9 +132,28 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
});
});
const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
if (ctx.headers.get("x-admin-api-key") !== env("KAN_ADMIN_API_KEY")) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx,
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
openapi: {
method: "GET",
path: "/protected",
},
});
export const adminProtectedProcedure = t.procedure
.use(enforceUserIsAdmin)
.meta({
openapi: {
method: "GET",
path: "/admin/protected",
},
});

View File

@@ -2,6 +2,7 @@ import type { RouterInputs, RouterOutputs } from "../index";
export type GetBoardByIdOutput = RouterOutputs["board"]["byId"];
export type GetCardByIdOutput = RouterOutputs["card"]["byId"];
export type GetCardActivitiesOutput = RouterOutputs["card"]["getActivities"];
export type UpdateBoardInput = RouterInputs["board"]["update"];
export type NewLabelInput = RouterInputs["label"]["create"];
export type NewListInput = RouterInputs["list"]["create"];

View File

@@ -0,0 +1,109 @@
import type { PaginatedActivitiesResult } from "@kan/db/repository/cardActivity.repo";
import type { ActivityType } from "@kan/db/schema";
type ActivityBase = PaginatedActivitiesResult["activities"][number];
interface Activity extends ActivityBase {
mergeCount?: number; // number of activities merged into this one
mergedLabels?: string[]; // list of label names when merging label activities
}
// types that can be merged with simple count
const MERGEABLE_COUNT_TYPES: readonly ActivityType[] = [
"card.updated.description",
];
// types that merge with a list of items
const MERGEABLE_LIST_TYPES: readonly ActivityType[] = [
"card.updated.label.added",
"card.updated.label.removed",
];
const MERGE_TIME_WINDOW_MS = 5 * 60 * 1000; // 5 minutes window for merging activities
export function mergeActivities(activities: Activity[]): Activity[] {
if (activities.length === 0) return [];
const merged: Activity[] = [];
let i = 0;
while (i < activities.length) {
const current = activities[i];
if (!current) {
i++;
continue;
}
const currentType = current.type;
const isCountMergeable = MERGEABLE_COUNT_TYPES.includes(currentType);
const isListMergeable = MERGEABLE_LIST_TYPES.includes(currentType);
const canMerge = (isCountMergeable || isListMergeable) && current.user?.id;
if (!canMerge) {
merged.push(current);
i++;
continue;
}
const group: Activity[] = [current];
let j = i + 1;
while (j < activities.length) {
const next = activities[j];
if (!next) break;
const timeDiff =
new Date(next.createdAt).getTime() -
new Date(current.createdAt).getTime();
if (
next.type === current.type &&
next.user?.id === current.user?.id &&
timeDiff >= 0 && // next is newer or same time
timeDiff <= MERGE_TIME_WINDOW_MS
) {
group.push(next);
j++;
} else {
break;
}
}
if (group.length > 1) {
const oldest = group[0];
const latest = group[group.length - 1];
if (oldest && latest) {
if (isListMergeable) {
const labelNames = group
.map((a) => a.label?.name)
.filter((name): name is string => !!name);
merged.push({
...oldest,
createdAt: oldest.createdAt,
mergeCount: group.length,
mergedLabels: labelNames,
});
} else {
merged.push({
...oldest,
createdAt: oldest.createdAt,
toDescription: latest.toDescription,
fromDescription: oldest.fromDescription,
mergeCount: group.length,
});
}
} else {
merged.push(current);
}
} else {
merged.push(current);
}
i = j;
}
return merged;
}

View File

@@ -1,4 +1,5 @@
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
@@ -56,3 +57,13 @@ export async function generateDownloadUrl(
{ expiresIn },
);
}
export async function deleteObject(bucket: string, key: string) {
const client = createS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
}),
);
}

View File

@@ -37,7 +37,7 @@
},
"prettier": "@kan/prettier-config",
"dependencies": {
"@better-auth/stripe": "^1.3.7",
"better-auth": "^1.3.7"
"@better-auth/stripe": "^1.4.6",
"better-auth": "^1.4.6"
}
}

View File

@@ -78,8 +78,7 @@ export const configuredProviders = socialProviderList.reduce<
Object.keys(acc).includes("google") &&
acc[provider]
) {
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS
?.split(",")
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
if (allowed && allowed.length > 0) {
@@ -299,9 +298,8 @@ export const initAuth = (db: dbClient) => {
}),
]
: []),
// @todo: hasing is disabled due to a bug in the api key plugin
apiKey({
disableKeyHashing: true,
enableSessionForAPIKeys: true,
rateLimit: {
enabled: true,
timeWindow: 1000 * 60, // 1 minute
@@ -341,6 +339,37 @@ export const initAuth = (db: dbClient) => {
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
scopes: ["openid", "email", "profile"],
pkce: true,
mapProfileToUser: (profile: {
name?: string;
display_name?: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
email?: string;
email_verified?: boolean;
sub?: string;
picture?: string;
avatar?: string;
}) => {
console.log("OIDC profile:", profile);
const name =
profile.name ??
profile.display_name ??
profile.preferred_username ??
(profile.given_name && profile.family_name
? `${profile.given_name} ${profile.family_name}`.trim()
: (profile.given_name ?? profile.family_name)) ??
profile.sub ??
"";
return {
email: profile.email,
name: name,
emailVerified: profile.email_verified ?? false,
image: profile.picture ?? profile.avatar ?? null,
};
},
},
],
}),
@@ -365,8 +394,7 @@ export const initAuth = (db: dbClient) => {
// Fall through to any additional checks below
}
// Enforce allowed domains (OIDC/social) if configured
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS
?.split(",")
const allowed = process.env.BETTER_AUTH_ALLOWED_DOMAINS?.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
if (allowed && allowed.length > 0) {

View File

@@ -0,0 +1,6 @@
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.dueDate.added' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.dueDate.updated' BEFORE 'card.archived';--> statement-breakpoint
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.dueDate.removed' BEFORE 'card.archived';--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "fromDueDate" timestamp;--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "toDueDate" timestamp;--> statement-breakpoint
ALTER TABLE "card" ADD COLUMN "dueDate" timestamp;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "card_activity" ALTER COLUMN "fromTitle" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "card_activity" ALTER COLUMN "toTitle" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "card" ALTER COLUMN "title" SET DATA TYPE text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,20 @@
"when": 1762807463569,
"tag": "20251110204423_AddCardAttachments",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1764621815672,
"tag": "20251201204335_AddCardDueDates",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1767045713686,
"tag": "20251229220153_UpdateCardTitleFromVarcharToText",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,16 @@
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import {
and,
asc,
count,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
lt,
or,
} from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import type { BoardVisibilityStatus } from "@kan/db/schema";
@@ -18,6 +30,15 @@ import {
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const getCount = async (db: dbClient) => {
const result = await db
.select({ count: count() })
.from(boards)
.where(isNull(boards.deletedAt));
return result[0]?.count ?? 0;
};
export const getAllByWorkspaceId = (
db: dbClient,
workspaceId: number,
@@ -65,6 +86,39 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
return board;
};
interface DueDateFilter {
startDate?: Date;
endDate?: Date;
hasNoDueDate?: boolean;
}
const buildDueDateWhere = (filters: DueDateFilter[]) => {
if (!filters.length) return undefined;
const clauses = filters
.map((filter) => {
const conditions: ReturnType<typeof and>[] = [];
if (filter.hasNoDueDate) {
conditions.push(isNull(cards.dueDate));
} else {
conditions.push(isNotNull(cards.dueDate));
if (filter.startDate)
conditions.push(gte(cards.dueDate, filter.startDate));
if (filter.endDate) conditions.push(lt(cards.dueDate, filter.endDate));
}
return conditions.length > 0 ? and(...conditions) : undefined;
})
.filter((clause): clause is NonNullable<typeof clause> => !!clause);
if (!clauses.length) return undefined;
return or(...clauses);
};
export const getByPublicId = async (
db: dbClient,
boardPublicId: string,
@@ -72,6 +126,7 @@ export const getByPublicId = async (
members: string[];
labels: string[];
lists: string[];
dueDate: DueDateFilter[];
type: "regular" | "template" | undefined;
},
) => {
@@ -164,6 +219,7 @@ export const getByPublicId = async (
description: true,
listId: true,
index: true,
dueDate: true,
},
with: {
labels: {
@@ -236,6 +292,7 @@ export const getByPublicId = async (
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
isNull(cards.deletedAt),
buildDueDateWhere(filters.dueDate),
),
orderBy: [asc(cards.index)],
},
@@ -291,6 +348,7 @@ export const getBySlug = async (
members: string[];
labels: string[];
lists: string[];
dueDate: DueDateFilter[];
},
) => {
let cardIds: string[] = [];
@@ -353,6 +411,7 @@ export const getBySlug = async (
description: true,
listId: true,
index: true,
dueDate: true,
},
with: {
labels: {
@@ -405,6 +464,7 @@ export const getBySlug = async (
where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
isNull(cards.deletedAt),
buildDueDateWhere(filters.dueDate),
),
orderBy: [asc(cards.index)],
},

View File

@@ -1,4 +1,14 @@
import { and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import {
and,
asc,
count,
desc,
eq,
gt,
inArray,
isNull,
sql,
} from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import {
@@ -15,6 +25,15 @@ import {
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const getCount = async (db: dbClient) => {
const result = await db
.select({ count: count() })
.from(cards)
.where(isNull(cards.deletedAt));
return result[0]?.count ?? 0;
};
export const create = async (
db: dbClient,
cardInput: {
@@ -23,6 +42,7 @@ export const create = async (
createdBy: string;
listId: number;
position: "start" | "end";
dueDate?: Date | null;
},
) => {
return db.transaction(async (tx) => {
@@ -71,6 +91,7 @@ export const create = async (
createdBy: cardInput.createdBy,
listId: cardInput.listId,
index: index,
dueDate: cardInput.dueDate ?? null,
})
.returning({ id: cards.id, listId: cards.listId });
@@ -163,6 +184,7 @@ export const update = async (
cardInput: {
title?: string;
description?: string;
dueDate?: Date | null;
},
args: {
cardPublicId: string;
@@ -173,6 +195,8 @@ export const update = async (
.set({
title: cardInput.title,
description: cardInput.description,
dueDate: cardInput.dueDate !== undefined ? cardInput.dueDate : undefined,
updatedAt: new Date(),
})
.where(and(eq(cards.publicId, args.cardPublicId), isNull(cards.deletedAt)))
.returning({
@@ -180,6 +204,7 @@ export const update = async (
publicId: cards.publicId,
title: cards.title,
description: cards.description,
dueDate: cards.dueDate,
});
return result;
@@ -214,6 +239,7 @@ export const getByPublicId = (db: dbClient, cardPublicId: string) => {
title: true,
description: true,
listId: true,
dueDate: true,
},
where: eq(cards.publicId, cardPublicId),
});
@@ -397,6 +423,7 @@ export const getWithListAndMembersByPublicId = async (
publicId: true,
title: true,
description: true,
dueDate: true,
},
with: {
labels: {
@@ -531,6 +558,8 @@ export const getWithListAndMembersByPublicId = async (
toTitle: true,
fromDescription: true,
toDescription: true,
fromDueDate: true,
toDueDate: true,
},
with: {
fromList: {
@@ -780,6 +809,7 @@ export const reorder = async (
publicId: true,
title: true,
description: true,
dueDate: true,
},
where: eq(cards.id, card.id),
});

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