Compare commits

...

27 Commits

Author SHA1 Message Date
Henry
4ac89ba8c0 Fix build and add dyanmic imports 2025-06-25 17:45:38 +01:00
Henry
a2f7582999 Extend lang support 2025-06-25 13:20:07 +01:00
Henry
8a93951464 Update lang support 2025-06-25 12:12:47 +01:00
Henry
bf2db74528 Extend language support 2025-06-24 22:41:06 +01:00
Henry
f564b52159 Add missing translations and tweak selector styling 2025-06-24 22:15:32 +01:00
Henry
2ef2493bb5 Fix hydration and locale issues 2025-06-24 21:37:03 +01:00
Henry
f105e951e6 feat: setup localisation with lingui 2025-06-23 23:43:16 +01:00
Henry
014fdbb7d8 feat: render URLs as links in card description (#93) 2025-06-21 23:15:07 +01:00
Henry
500fe8af95 feat: make SMTP auth optional and add SMTP_SECURE config (#92)
* feat: add SMTP_SECURE option to email config

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

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

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

* fix: copy from root

* fix: set workdir

* fix: copy files from inside the container

* fix: add logs to debug

* fix: remove workdir

* fix: log contents of build

* fix: copy static files

* fix: remove log

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

* feat: run migrations via entrypoint

* chore: tidy .env.example

* fix: allow empty strings for optional envs

* fix: remove external from kan-network

* chore: update readme
2025-06-14 22:52:27 +01:00
Henry
3a48c797b7 feat: set web port with env variable 2025-06-13 22:03:57 +01:00
Henry
b838e8040a fix: remove version from docker compose (#81) 2025-06-13 21:43:05 +01:00
Henry
f0e3c4c3e2 fix: render comments inside contenteditable (#79) 2025-06-13 09:23:55 +01:00
88 changed files with 12231 additions and 985 deletions

View File

@@ -1,28 +1,40 @@
POSTGRES_URL=
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
EMAIL_FROM=
# Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
# Only required if deploying from compose
POSTGRES_PASSWORD=
# SMTP (optional)
SMTP_HOST=
SMTP_PORT=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
NEXT_PUBLIC_BASE_URL=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# S3 storage (optional)
S3_REGION=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
BETTER_AUTH_SECRET=
BETTER_AUTH_TRUSTED_ORIGINS=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=

3
.gitignore vendored
View File

@@ -48,4 +48,5 @@ dist/
docker-compose.override.yml
# runtime env
__ENV.js
__ENV.js
i18n.cache

128
README.md
View File

@@ -47,34 +47,67 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳
### PostgreSQL Database Setup
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
1. Create a new file called `docker-compose.yml` and paste the following configuration:
```bash
# Run PostgreSQL in a container using the official postgres image
docker run -d \
--name kan-db \
-e POSTGRES_DB=kan \
-e POSTGRES_USER=kan_user \
-e POSTGRES_PASSWORD=your_secure_password \
-p 5432:5432 \
-v kan_postgres_data:/var/lib/postgresql/data \
postgres:15
```yaml
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
networks:
- kan-network
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
- postgres
restart: unless-stopped
# Your POSTGRES_URL should be:
# postgres://kan_user:your_secure_password@your_host:5432/kan
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
volumes:
kan_postgres_data:
```
### Kan Application Deployment
Deploy Kan with Docker using our pre-built image:
2. Start the containers in detached mode:
```bash
docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
docker compose up -d
```
Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
3. Access Kan at http://localhost:3000
The application will be running in the background. You can manage the containers using these commands:
- To stop the containers: `docker compose down`
- To view logs: `docker compose logs -f`
- To restart the containers: `docker compose restart`
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
## Local Development 🧑‍💻
@@ -105,34 +138,35 @@ pnpm dev
## Environment Variables 🔐
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | 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 | For Email | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| Variable | Description | Required | Example |
| -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
See `.env.example` for a complete list of supported environment variables.

View File

@@ -53,8 +53,14 @@ RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfi
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
RUN pnpm build --filter=${PROJECT}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
@@ -75,6 +81,5 @@ ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
# Run migration on start for now (until we have a better way to handle this)
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]

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

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

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

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

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

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

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

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

View File

@@ -10,6 +10,10 @@ configureRuntimeEnv();
/** @type {import("next").NextConfig} */
const config = {
output:
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
? "standalone"
: undefined,
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
@@ -42,7 +46,8 @@ const config = {
],
},
experimental: {
instrumentationHook: true,
// instrumentationHook: true,
swcPlugins: [["@lingui/swc-plugin", {}]],
},
};

View File

@@ -4,14 +4,17 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm next build",
"build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev | pino-pretty",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
"with-env": "dotenv -e ../../.env --",
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile --typescript",
"lingui:extract:watch": "lingui extract --watch"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
@@ -22,9 +25,14 @@
"@kan/auth": "workspace:*",
"@kan/db": "workspace:^",
"@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",
"@lingui/macro": "^5.3.2",
"@lingui/react": "^5.3.2",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:",
"@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
@@ -37,11 +45,12 @@
"aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"next": "^14.2.15",
"next": "^15.3.4",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"posthog-js": "^1.254.0",
"react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1",
"react-contenteditable": "^3.3.7",
@@ -61,6 +70,9 @@
"@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2",
"@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@types/node": "^20.17.7",
"@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7",

View File

@@ -1,5 +1,7 @@
import type { SocialProvider } from "better-auth/social-providers";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useState } from "react";
@@ -183,8 +185,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been signed up successfully.",
header: t`Success`,
message: t`You have been signed up successfully.`,
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
@@ -200,8 +202,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{
onSuccess: () =>
showPopup({
header: "Success",
message: "You have been logged in successfully.",
header: t`Success`,
message: t`You have been logged in successfully.`,
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
@@ -236,7 +238,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
if (error) {
setLoginError(
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
);
}
};
@@ -263,7 +265,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
fullWidth
size="lg"
>
Continue with {provider.name}
<Trans>Continue with {provider.name}</Trans>
</Button>
);
})}
@@ -274,7 +276,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
or
{t`or`}
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
@@ -284,11 +286,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div>
<Input
{...register("name", { required: true })}
placeholder="Enter your name"
placeholder={t`Enter your name`}
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid name
{t`Please enter a valid name`}
</p>
)}
</div>
@@ -296,11 +298,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div>
<Input
{...register("email", { required: true })}
placeholder="Enter your email address"
placeholder={t`Enter your email address`}
/>
{errors.email && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid email address
{t`Please enter a valid email address`}
</p>
)}
</div>
@@ -309,11 +311,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<Input
type="password"
{...register("password", { required: true })}
placeholder="Enter your password"
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid password
{t`Please enter a valid password`}
</p>
)}
</div>
@@ -329,10 +331,10 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
size="lg"
variant="secondary"
>
{isSignUp ? "Sign up with " : "Continue with "}
{isSignUp ? t`Sign up with ` : t`Continue with `}
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
? t`email`
: t`magic link`}
</Button>
</div>
</form>

View File

@@ -5,6 +5,8 @@ import type {
} from "@tiptap/suggestion";
import type { Instance as TippyInstance } from "tippy.js";
import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import {
BubbleMenu,
@@ -297,7 +299,17 @@ export default function Editor({
Placeholder.configure({
placeholder: readOnly
? ""
: "Add description... (type '/' to open commands)",
: t`Add description... (type '/' to open commands)`,
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
class: "text-blue-600 hover:text-blue-800 underline cursor-pointer",
target: "_blank",
rel: "noopener noreferrer",
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
}),
SlashCommands.configure({
commandItems: CommandItems,

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@@ -34,15 +35,15 @@ const FeedbackButton: React.FC = () => {
onSuccess: async () => {
reset();
showPopup({
header: "Feedback sent",
message: "Thank you for your feedback!",
header: t`Feedback sent`,
message: t`Thank you for your feedback!`,
icon: "success",
});
},
onError: async () => {
showPopup({
header: "Unable to send feedback",
message: "Please try again later, or contact customer support.",
header: t`Unable to send feedback`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -74,7 +75,7 @@ const FeedbackButton: React.FC = () => {
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
isPlaying={isHovered}
/>
<span className="ml-1">Feedback</span>
<span className="ml-1">{t`Feedback`}</span>
</Menu.Button>
</div>
@@ -94,7 +95,7 @@ const FeedbackButton: React.FC = () => {
>
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
<Input
placeholder="Ideas to improve this page..."
placeholder={t`Ideas to improve this page...`}
onChange={(e) => {
setValue("feedback", e.target.value);
}}
@@ -112,28 +113,28 @@ const FeedbackButton: React.FC = () => {
<div className="flex flex-row items-center justify-between pt-2">
<div>
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
Need help?{" "}
{t`Need help?`}{" "}
<Link
href="mailto:support@kan.bn"
className="text-blue-600 underline dark:text-blue-300"
>
Contact us
{t`Contact us`}
</Link>
, or see our{" "}
{t`, or see our`}{" "}
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline dark:text-blue-300"
>
docs
{t`docs`}
</Link>
.
</p>
</div>
<div className="justify-end">
<Button size="sm" isLoading={createFeedback.isPending}>
Send
{t`Send`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { Listbox, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
@@ -114,7 +115,7 @@ export function LabelForm({
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">
{isEdit ? "Edit label" : "New label"}
{isEdit ? t`Edit label` : t`New label`}
</h2>
<button
type="button"
@@ -130,7 +131,7 @@ export function LabelForm({
<Input
id="label-name"
placeholder="Name"
placeholder={t`Name`}
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -208,7 +209,7 @@ export function LabelForm({
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && (
<Toggle
label="Create another"
label={t`Create another`}
isChecked={!!isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
@@ -222,14 +223,14 @@ export function LabelForm({
variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)}
>
Delete
{t`Delete`}
</Button>
)}
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
>
{isEdit ? "Update label" : "Create label"}
{isEdit ? t`Update label` : t`Create label`}
</Button>
</div>
</div>

View File

@@ -0,0 +1,26 @@
import { HiLanguage } from "react-icons/hi2";
import { useLocalisation } from "~/hooks/useLocalisation";
import { localeNames } from "~/locales";
export function LanguageSelector() {
const { locale, setLocale, availableLocales } = useLocalisation();
return (
<div className="relative">
<HiLanguage className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as any)}
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500 sm:text-sm"
>
{availableLocales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
@@ -34,8 +35,8 @@ export function NewWorkspaceForm() {
},
onError: () => {
showPopup({
header: "Unable to create workspace",
message: "Please try again later, or contact customer support.",
header: t`Unable to create workspace`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -58,7 +59,7 @@ export function NewWorkspaceForm() {
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New workspace
{t`New workspace`}
</h2>
<button
type="button"
@@ -74,7 +75,7 @@ export function NewWorkspaceForm() {
<Input
id="workspace-name"
placeholder="Workspace name"
placeholder={t`Workspace name`}
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -87,7 +88,7 @@ export function NewWorkspaceForm() {
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createWorkspace.isPending}>
Create workspace
{t`Create workspace`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
@@ -35,17 +36,17 @@ export default function SideNavigation({
const navigation = [
{
name: "Boards",
name: t`Boards`,
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
},
{
name: "Members",
name: t`Members`,
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
},
{
name: "Settings",
name: t`Settings`,
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
},

View File

@@ -1,6 +1,7 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { authClient } from "@kan/auth/client";
@@ -81,7 +82,7 @@ export default function UserMenu({
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
<div className="p-1">
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
<span>Theme</span>
<span>{t`Theme`}</span>
</div>
<Menu.Item>
<button
@@ -94,7 +95,7 @@ export default function UserMenu({
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
System
{t`System`}
</button>
</Menu.Item>
<Menu.Item>
@@ -108,7 +109,7 @@ export default function UserMenu({
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Dark
{t`Dark`}
</button>
</Menu.Item>
<Menu.Item>
@@ -122,7 +123,7 @@ export default function UserMenu({
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
Light
{t`Light`}
</button>
</Menu.Item>
</div>
@@ -132,7 +133,7 @@ export default function UserMenu({
onClick={handleLogout}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
Logout
{t`Logout`}
</button>
</Menu.Item>
</div>

View File

@@ -1,4 +1,5 @@
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment } from "react";
import { HiCheck } from "react-icons/hi2";
@@ -81,7 +82,7 @@ export default function WorkspaceMenu() {
onClick={() => openModal("NEW_WORKSPACE")}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
Create workspace
{t`Create workspace`}
</button>
</Menu.Item>
</div>

View File

@@ -17,8 +17,11 @@ export const env = createEnv({
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z
.string()
.refine((s) =>
s.split(",").every((l) => z.string().url().safeParse(l).success),
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) =>
!s ||
s.split(",").every((l) => z.string().url().safeParse(l).success),
)
.optional(),
POSTGRES_URL: z.string().url(),
@@ -76,17 +79,26 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url(),
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_DISABLE_SIGN_UP: z
.string()
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
},
/**
@@ -96,12 +108,16 @@ export const env = createEnv({
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

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

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

@@ -0,0 +1,14 @@
export const locales = ["en", "fr", "de", "es", "it", "nl"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = {
en: "English",
fr: "Français",
de: "Deutsch",
es: "Español",
it: "Italiano",
nl: "Nederlands",
};

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

@@ -1,9 +1,15 @@
import "~/styles/globals.css";
import "~/utils/i18n";
import type { AppType } from "next/app";
import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script";
import { env } from "next-runtime-env";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "~/providers/theme";
@@ -21,6 +27,21 @@ export const metadata = {
};
const MyApp: AppType = ({ Component, pageProps }) => {
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
useEffect(() => {
if (posthogKey) {
posthog.init(posthogKey, {
api_host: env("NEXT_PUBLIC_POSTHOG_HOST"),
person_profiles: "identified_only",
defaults: "2025-05-24",
loaded: (posthog) => {
if (process.env.NODE_ENV === "development") posthog.debug();
},
});
}
}, [posthogKey]);
return (
<>
<style jsx global>{`
@@ -32,7 +53,7 @@ const MyApp: AppType = ({ Component, pageProps }) => {
}
`}</style>
{env("NEXT_PUBLIC_UMAMI_ID") && (
<script
<Script
defer
src="https://cloud.umami.is/script.js"
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
@@ -40,13 +61,21 @@ const MyApp: AppType = ({ Component, pageProps }) => {
)}
<script src="/__ENV.js" />
<main className="font-sans">
<ThemeProvider>
<ModalProvider>
<PopupProvider>
<Component {...pageProps} />
</PopupProvider>
</ModalProvider>
</ThemeProvider>
<LinguiProviderWrapper>
<ThemeProvider>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
<Component {...pageProps} />
</PostHogProvider>
) : (
<Component {...pageProps} />
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</main>
</>
);

View File

@@ -0,0 +1,88 @@
import type { ReactNode } from "react";
import { I18nProvider } from "@lingui/react";
import { createContext, useContext, useEffect, useState } from "react";
import type { Locale } from "~/locales";
import { defaultLocale, locales } from "~/locales";
import { activateLocale, i18n, initializeI18n } from "~/utils/i18n";
interface LinguiContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
availableLocales: Locale[];
}
const LinguiContext = createContext<LinguiContextType | undefined>(undefined);
export function useLinguiContext() {
const context = useContext(LinguiContext);
if (!context) {
throw new Error("useLinguiContext must be used within a LinguiProvider");
}
return context;
}
interface LinguiProviderProps {
children: ReactNode;
initialLocale?: Locale;
}
function detectBrowserLocale(availableLocales: readonly string[]): Locale {
if (typeof navigator === "undefined") {
return defaultLocale;
}
const browserLanguages = navigator.languages || [navigator.language];
for (const browserLang of browserLanguages) {
const langCode = browserLang.split("-")[0];
if (langCode && availableLocales.includes(langCode.toLowerCase())) {
return langCode.toLowerCase() as Locale;
}
}
return defaultLocale;
}
export function LinguiProviderWrapper({
children,
initialLocale = defaultLocale,
}: LinguiProviderProps) {
const [locale, setLocale] = useState<Locale>(defaultLocale);
const [isHydrated, setIsHydrated] = useState(false);
initializeI18n();
useEffect(() => {
const savedLocale = localStorage.getItem("locale") as Locale;
if (savedLocale && locales.includes(savedLocale)) {
setLocale(savedLocale);
} else {
const detectedLocale = detectBrowserLocale(locales);
setLocale(detectedLocale);
}
setIsHydrated(true);
}, [initialLocale]);
useEffect(() => {
if (isHydrated && locale !== defaultLocale) {
activateLocale(locale).then(() => {
localStorage.setItem("locale", locale);
});
} else if (isHydrated) {
localStorage.setItem("locale", locale);
}
}, [locale, isHydrated]);
return (
<LinguiContext.Provider
value={{ locale, setLocale, availableLocales: [...locales] }}
>
<I18nProvider i18n={i18n} key={locale}>
{children}
</I18nProvider>
</LinguiContext.Provider>
);
}

View File

@@ -0,0 +1,53 @@
import { i18n } from "@lingui/core";
import type { Locale } from "~/locales";
import { defaultLocale } from "~/locales";
import { messages as enMessages } from "~/locales/en/messages";
const loadMessages = async (locale: Locale) => {
switch (locale) {
case "en":
return enMessages;
case "fr":
return (await import("~/locales/fr/messages")).messages;
case "de":
return (await import("~/locales/de/messages")).messages;
case "es":
return (await import("~/locales/es/messages")).messages;
case "it":
return (await import("~/locales/it/messages")).messages;
case "nl":
return (await import("~/locales/nl/messages")).messages;
default:
return enMessages;
}
};
let isInitialized = false;
const loadedLocales = new Set<string>();
export function initializeI18n(locale: Locale = defaultLocale) {
if (!isInitialized) {
i18n.load(defaultLocale, enMessages);
i18n.activate(defaultLocale);
loadedLocales.add(defaultLocale);
isInitialized = true;
}
return i18n;
}
export async function activateLocale(locale: Locale) {
if (!loadedLocales.has(locale)) {
const messages = await loadMessages(locale);
i18n.load(locale, messages);
loadedLocales.add(locale);
}
i18n.activate(locale);
return i18n;
}
initializeI18n();
export { i18n };

View File

@@ -1,5 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { env } from "next-runtime-env";
import { useState } from "react";
@@ -26,7 +28,7 @@ export default function LoginPage() {
return (
<>
<PageHead title="Login | kan.bn" />
<PageHead title={t`Login | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
@@ -36,12 +38,15 @@ export default function LoginPage() {
</h1>
</Link>
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{isMagicLinkSent ? "Check your inbox" : "Welcome back"}
{isMagicLinkSent ? t`Check your inbox` : t`Welcome back`}
</p>
{isMagicLinkSent ? (
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
<Trans>
Click on the link we've sent to {magicLinkRecipient} to sign
in.
</Trans>
</p>
</div>
) : (
@@ -53,10 +58,12 @@ export default function LoginPage() {
)}
{!isSignUpDisabled && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
<Trans>
Don't have an account?{" "}
<span className="underline">
<Link href="/signup">Sign up</Link>
</span>
</Trans>
</p>
)}
</div>

View File

@@ -1,5 +1,8 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { env } from "next-runtime-env";
import { useState } from "react";
import { authClient } from "@kan/auth/client";
@@ -7,34 +10,51 @@ import { authClient } from "@kan/auth/client";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { env } from "next-runtime-env";
export default function SignupPage() {
export default function SignUpPage() {
const router = useRouter();
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
const { data } = authClient.useSession();
if (data?.user.id) router.push("/boards");
const handleMagicLinkSent = (value: boolean, recipient: string) => {
setIsMagicLinkSent(value);
setMagicLinkRecipient(recipient);
};
const { data } = authClient.useSession();
if (data?.user.id) router.push("/boards");
if (env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true") {
if (isSignUpDisabled) {
return (
<div className="flex flex-col bg-light-100 dark:bg-dark-50 h-screen items-center justify-center">
<p className="mb-2 text-light-1000 dark:text-dark-1000 font-semibold text-sm">Sign up is disabled</p>
<Link href="/login" className="text-light-1000 dark:text-dark-1000 font-semibold text-sm rounded-md border border-light-900 dark:border-dark-900 px-4 py-2">Login</Link>
</div>
)
<>
<PageHead title={t`Sign up | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
<Link href="/">
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{t`Sign up disabled`}
</p>
<p className="text-md text-center text-light-1000 dark:text-dark-1000">
{t`Sign up is currently disabled. Please try again later.`}
</p>
</div>
<PatternedBackground />
</div>
</main>
</>
);
}
return (
<>
<PageHead title="Sign up | kan.bn" />
<PageHead title={t`Sign up | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
@@ -44,12 +64,15 @@ export default function SignupPage() {
</h1>
</Link>
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{isMagicLinkSent ? "Check your inbox" : "Create your account"}
{isMagicLinkSent ? t`Check your inbox` : t`Get started`}
</p>
{isMagicLinkSent ? (
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
<Trans>
Click on the link we've sent to {magicLinkRecipient} to sign
in.
</Trans>
</p>
</div>
) : (
@@ -60,10 +83,12 @@ export default function SignupPage() {
</div>
)}
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
Already have an account?{" "}
<span className="underline">
<Link href="/login">Sign in</Link>
</span>
<Trans>
Already have an account?{" "}
<span className="underline">
<Link href="/login">Sign in</Link>
</span>
</Trans>
</p>
</div>
<PatternedBackground />

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
@@ -11,12 +12,12 @@ export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
disabled={isLoading}
items={[
{
label: "Edit board URL",
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
{
label: "Delete board",
label: t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiMiniXMark,
HiOutlineTag,
@@ -89,7 +90,7 @@ const Filters = ({
? [
{
key: "members",
label: "Members",
label: t`Members`,
icon: <HiOutlineUserCircle size={16} />,
items: formattedMembers,
},
@@ -97,7 +98,7 @@ const Filters = ({
: []),
{
key: "labels",
label: "Labels",
label: t`Labels`,
icon: <HiOutlineTag size={16} />,
items: formattedLabels,
},
@@ -145,13 +146,13 @@ const Filters = ({
disabled={isLoading}
iconLeft={<IoFilterOutline />}
>
Filter
{t`Filter`}
</Button>
{numOfFilters > 0 && (
<button
type="button"
onClick={clearFilters}
aria-label="Clear filters"
aria-label={t`Clear filters`}
className="group absolute -right-[8px] -top-[8px] flex h-5 w-5 items-center justify-center rounded-full border-2 border-light-100 bg-light-1000 text-[8px] font-[700] text-light-600 dark:border-dark-50 dark:bg-dark-1000 dark:text-dark-600"
>
<span className="group-hover:hidden">{numOfFilters}</span>

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
import {
@@ -106,14 +107,14 @@ export default function List({
<Dropdown
items={[
{
label: "Add a card",
label: t`Add a card`,
action: () => openNewCardForm(list.publicId),
icon: (
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
),
},
{
label: "Delete list",
label: t`Delete list`,
action: handleOpenDeleteListConfirmation,
icon: (
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
@@ -90,9 +92,14 @@ export function NewCardForm({
args.labelPublicIds.includes(label.publicId),
),
members:
oldBoard.workspace.members.filter((member) =>
args.memberPublicIds.includes(member.publicId),
) ?? [],
oldBoard.workspace.members
.filter((member) =>
args.memberPublicIds.includes(member.publicId),
)
.map((member) => ({
...member,
deletedAt: null,
})) ?? [],
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
index: position === "start" ? 0 : list.cards.length,
@@ -115,10 +122,10 @@ export function NewCardForm({
onError: (error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to create card",
header: t`Unable to create card`,
message: error.data?.zodError?.fieldErrors.title?.[0]
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
: "Please try again later, or contact customer support.",
: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -166,6 +173,7 @@ export function NewCardForm({
member.user?.name ?? null,
member.user?.email ?? member.email,
),
selected: memberPublicIds.includes(member.publicId),
leftIcon: (
<Avatar
size="xs"
@@ -226,7 +234,7 @@ export function NewCardForm({
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-5">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New card
{t`New card`}
</h2>
<button
type="button"
@@ -243,7 +251,7 @@ export function NewCardForm({
<div>
<Input
id="title"
placeholder="Card title"
placeholder={t`Card title`}
{...register("title")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -279,7 +287,7 @@ export function NewCardForm({
>
<div 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">
{!memberPublicIds.length ? (
"Members"
t`Members`
) : (
<div className="flex -space-x-1 overflow-hidden">
{memberPublicIds.map((memberPublicId) => {
@@ -316,11 +324,11 @@ export function NewCardForm({
openModal("EDIT_LABEL", labelPublicId)
}
handleCreate={() => openModal("NEW_LABEL")}
createNewItemLabel="Create new label"
createNewItemLabel={t`Create new label`}
>
<div 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">
{!labelPublicIds.length ? (
"Labels"
t`Labels`
) : (
<>
<div
@@ -353,7 +361,9 @@ export function NewCardForm({
})}
</div>
{labelPublicIds.length > 1 && (
<div className="ml-1">{`${labelPublicIds.length} labels`}</div>
<div className="ml-1">
<Trans>{`${labelPublicIds.length} labels`}</Trans>
</div>
)}
</>
)}
@@ -378,7 +388,7 @@ export function NewCardForm({
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label="Create another"
label={t`Create another`}
isChecked={isCreateAnotherEnabled}
onChange={handleToggleCreateAnother}
/>
@@ -388,7 +398,7 @@ export function NewCardForm({
type="submit"
disabled={title.length === 0 || createCard.isPending}
>
Create card
{t`Create card`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
@@ -73,8 +74,8 @@ export function NewListForm({
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to create list",
message: "Please try again later, or contact customer support.",
header: t`Unable to create list`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -108,7 +109,7 @@ export function NewListForm({
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
New list
{t`New list`}
</h2>
<button
type="button"
@@ -124,7 +125,7 @@ export function NewListForm({
<Input
id="list-name"
placeholder="List name"
placeholder={t`List name`}
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -136,7 +137,7 @@ export function NewListForm({
</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">
<Toggle
label="Create another"
label={t`Create another`}
isChecked={isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
@@ -144,7 +145,7 @@ export function NewListForm({
/>
<div>
<Button type="submit">Create list</Button>
<Button type="submit">{t`Create list`}</Button>
</div>
</div>
</form>

View File

@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -12,20 +13,6 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const schema = z.object({
slug: z
.string()
.min(3, {
message: "Board URL must be at least 3 characters long",
})
.max(60, { message: "Board URL cannot exceed 60 characters" })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: "Board URL can only contain letters, numbers, and hyphens",
}),
});
type FormValues = z.infer<typeof schema>;
interface QueryParams {
boardPublicId: string;
members: string[];
@@ -47,6 +34,20 @@ export function UpdateBoardSlugForm({
const { showPopup } = usePopup();
const utils = api.useUtils();
const schema = z.object({
slug: z
.string()
.min(3, {
message: t`Board URL must be at least 3 characters long`,
})
.max(60, { message: t`Board URL cannot exceed 60 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`Board URL can only contain letters, numbers, and hyphens`,
}),
});
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
@@ -67,8 +68,8 @@ export function UpdateBoardSlugForm({
const updateBoardSlug = api.board.update.useMutation({
onError: () => {
showPopup({
header: "Unable to update board URL",
message: "Please try again later, or contact customer support.",
header: t`Unable to update board URL`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -78,17 +79,15 @@ export function UpdateBoardSlugForm({
},
});
const checkBoardSlugAvailability =
api.board.checkSlugAvailability.useQuery(
{
boardSlug: debouncedSlug,
boardPublicId,
},
{
enabled:
!!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
},
);
const checkBoardSlugAvailability = api.board.checkSlugAvailability.useQuery(
{
boardSlug: debouncedSlug,
boardPublicId,
},
{
enabled: !!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
},
);
const isBoardSlugAvailable = checkBoardSlugAvailability.data;
@@ -100,7 +99,7 @@ export function UpdateBoardSlugForm({
const onSubmit = (data: FormValues) => {
if (!isBoardSlugAvailable) return;
if (isBoardSlugAvailable?.isReserved) return;
if (isBoardSlugAvailable.isReserved) return;
updateBoardSlug.mutate({
slug: data.slug,
@@ -113,7 +112,7 @@ export function UpdateBoardSlugForm({
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
Edit board URL
{t`Edit board URL`}
</h2>
<button
type="button"
@@ -130,9 +129,12 @@ export function UpdateBoardSlugForm({
<Input
id="board-slug"
{...register("slug")}
errorMessage={errors.slug?.message || (isBoardSlugAvailable?.isReserved
? "This board URL has already been taken"
: undefined)}
errorMessage={
errors.slug?.message ||
(isBoardSlugAvailable?.isReserved
? t`This board URL has already been taken`
: undefined)
}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -161,7 +163,7 @@ export function UpdateBoardSlugForm({
isBoardSlugAvailable?.isReserved
}
>
Update
{t`Update`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
@@ -41,15 +42,15 @@ const VisibilityButton = ({
const updateBoardVisibility = api.board.update.useMutation({
onSuccess: () => {
showPopup({
header: "Board visibility updated",
message: `The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
header: t`Board visibility updated`,
message: t`The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: "Unable to update board visibility",
message: "Please try again later, or contact customer support.",
header: t`Unable to update board visibility`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -64,12 +65,12 @@ const VisibilityButton = ({
items={[
{
key: "public",
value: "Public",
value: t`Public`,
selected: isPublic,
},
{
key: "private",
value: "Private",
value: t`Private`,
selected: !isPublic,
},
]}
@@ -87,7 +88,7 @@ const VisibilityButton = ({
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !isAdmin}
>
Visibility
{t`Visibility`}
</Button>
</CheckboxDropdown>
</div>

View File

@@ -2,6 +2,7 @@ import type { DropResult } from "react-beautiful-dnd";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
@@ -128,8 +129,8 @@ export default function BoardPage() {
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to update list",
message: "Please try again later, or contact customer support.",
header: t`Unable to update list`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -184,8 +185,8 @@ export default function BoardPage() {
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to update card",
message: "Please try again later, or contact customer support.",
header: t`Unable to update card`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -235,7 +236,7 @@ export default function BoardPage() {
return (
<>
<PageHead
title={`${boardData?.name ?? "Board"} | ${workspace.name ?? "Workspace"}`}
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
@@ -261,7 +262,7 @@ export default function BoardPage() {
)}
{!boardData && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Board not found
{t`Board not found`}
</p>
)}
@@ -292,7 +293,7 @@ export default function BoardPage() {
}}
disabled={!boardData}
>
New list
{t`New list`}
</Button>
<BoardDropdown isLoading={!boardData} />
</div>
@@ -307,15 +308,15 @@ export default function BoardPage() {
</div>
) : boardData ? (
<>
{boardData?.lists.length === 0 ? (
{boardData.lists.length === 0 ? (
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
<div className="flex flex-col items-center">
<HiOutlineSquare3Stack3D className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
No lists
{t`No lists`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
Get started by creating a new list
{t`Get started by creating a new list`}
</p>
</div>
<Button
@@ -323,7 +324,7 @@ export default function BoardPage() {
if (boardId) openNewListForm(boardId);
}}
>
Create new list
{t`Create new list`}
</Button>
</div>
) : (
@@ -340,7 +341,7 @@ export default function BoardPage() {
{...provided.droppableProps}
>
<div className="min-w-[2rem]" />
{boardData?.lists.map((list, index) => (
{boardData.lists.map((list, index) => (
<List
index={index}
key={index}

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { HiOutlineRectangleStack } from "react-icons/hi2";
import Button from "~/components/Button";
@@ -18,7 +19,7 @@ export function BoardsList() {
if (isLoading)
return (
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xxl:grid-cols-7">
<div className="xxl:grid-cols-7 grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
@@ -31,13 +32,15 @@ export function BoardsList() {
<div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
No boards
{t`No boards`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
Get started by creating a new board
{t`Get started by creating a new board`}
</p>
</div>
<Button onClick={() => openModal("NEW_BOARD")}>Create new board</Button>
<Button onClick={() => openModal("NEW_BOARD")}>
{t`Create new board`}
</Button>
</div>
);

View File

@@ -1,5 +1,7 @@
import Link from "next/link";
import { Listbox, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaTrello } from "react-icons/fa";
@@ -152,7 +154,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
}
>
{hasIntegrations ? "Select source" : "Connect Trello"}
{hasIntegrations ? t`Select source` : t`Connect Trello`}
</Button>
</div>
</div>
@@ -186,8 +188,8 @@ const ImportTrello: React.FC = () => {
const importBoards = api.import.trello.importBoards.useMutation({
onSuccess: async () => {
showPopup({
header: "Import complete",
message: "Your boards have been imported.",
header: t`Import complete`,
message: t`Your boards have been imported.`,
icon: "success",
});
try {
@@ -199,8 +201,8 @@ const ImportTrello: React.FC = () => {
},
onError: () => {
showPopup({
header: "Import failed",
message: "Please try again later, or contact customer support.",
header: t`Import failed`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -211,6 +213,8 @@ const ImportTrello: React.FC = () => {
value: watch(board.id),
}));
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
const onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true);
@@ -235,7 +239,7 @@ const ImportTrello: React.FC = () => {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-dark-900">
No boards found
{t`No boards found`}
</p>
</div>
);
@@ -267,7 +271,7 @@ const ImportTrello: React.FC = () => {
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label="Select all"
label={t`Select all`}
isChecked={!!isSelectAllEnabled}
onChange={() => {
const newState = !isSelectAllEnabled;
@@ -292,8 +296,13 @@ const ImportTrello: React.FC = () => {
)
}
>
Import boards (
{boardWatchers?.filter((w) => w.value === true).length || 0})
<Trans>
<Plural
value={boardCount}
one={`Import board (1)`}
other={`Import boards (${boardCount})`}
/>
</Trans>
</Button>
</div>
</div>
@@ -310,7 +319,7 @@ export function ImportBoardsForm() {
<div className="flex w-full items-center justify-between px-5 pb-4 pt-5">
<div className="flex items-center">
<h2 className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
New import
{t`New import`}
</h2>
<Link
href="https://docs.kan.bn/imports/trello"

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
@@ -45,7 +46,7 @@ export function NewBoardForm() {
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">New board</h2>
<h2 className="text-sm font-bold">{t`New board`}</h2>
<button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
@@ -59,7 +60,7 @@ export function NewBoardForm() {
</div>
<Input
id="name"
placeholder="Name"
placeholder={t`Name`}
{...register("name", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -73,7 +74,7 @@ export function NewBoardForm() {
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createBoard.isPending}>
Create board
{t`Create board`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import Modal from "~/components/modal";
@@ -17,11 +18,11 @@ export default function BoardsPage() {
return (
<>
<PageHead title={`Boards | ${workspace.name ?? "Workspace"}`} />
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
<div className="h-full p-8">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Boards
{t`Boards`}
</h1>
<div className="flex">
<button
@@ -35,7 +36,7 @@ export default function BoardsPage() {
aria-hidden="true"
/>
</div>
Import
{t`Import`}
</button>
<button
type="button"
@@ -48,7 +49,7 @@ export default function BoardsPage() {
aria-hidden="true"
/>
</div>
New
{t`New`}
</button>
</div>
</div>

View File

@@ -1,4 +1,7 @@
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 {
HiOutlineArrowLeft,
HiOutlineArrowRight,
@@ -8,24 +11,24 @@ import {
HiOutlineUserMinus,
HiOutlineUserPlus,
} from "react-icons/hi2";
import type { GetCardByIdOutput } from "@kan/api/types";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import { useLocalisation } from "~/hooks/useLocalisation";
import Comment from "./Comment";
import { authClient } from "@kan/auth/client";
type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
const ACTIVITY_TYPE_MAP = {
"card.created": "created the card",
"card.updated.title": "updated the title",
"card.updated.description": "updated the description",
"card.updated.list": "moved the card to another list",
"card.updated.label.added": "added a label to the card",
"card.updated.label.removed": "removed a label from the card",
"card.updated.member.added": "added a member to the card",
"card.updated.member.removed": "removed a member from the card",
const dateLocaleMap = {
en: enGB,
fr: fr,
de: de,
es: es,
it: it,
nl: nl,
} as const;
const getActivityText = ({
@@ -45,6 +48,17 @@ const getActivityText = ({
isSelf: boolean;
label: string | null;
}) => {
const ACTIVITY_TYPE_MAP = {
"card.created": t`created the card`,
"card.updated.title": t`updated the title`,
"card.updated.description": t`updated the description`,
"card.updated.list": t`moved the card to another list`,
"card.updated.label.added": t`added a label to the card`,
"card.updated.label.removed": t`removed a label from the card`,
"card.updated.member.added": t`added a member to the card`,
"card.updated.member.removed": t`removed a member from the card`,
} as const;
if (!(type in ACTIVITY_TYPE_MAP)) return null;
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
@@ -56,54 +70,54 @@ const getActivityText = ({
if (type === "card.updated.title" && toTitle) {
return (
<>
<Trans>
updated the title to <TextHighlight>{toTitle}</TextHighlight>
</>
</Trans>
);
}
if (type === "card.updated.list" && fromList && toList) {
return (
<>
<Trans>
moved the card from <TextHighlight>{fromList}</TextHighlight> to
<TextHighlight>{toList}</TextHighlight>
</>
</Trans>
);
}
if (type === "card.updated.member.added" && memberName) {
if (isSelf) return <>self-assigned the card</>;
if (isSelf) return <Trans>self-assigned the card</Trans>;
return (
<>
<Trans>
assigned <TextHighlight>{memberName}</TextHighlight> to the card
</>
</Trans>
);
}
if (type === "card.updated.member.removed" && memberName) {
if (isSelf) return <>unassigned themselves from the card</>;
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
return (
<>
<Trans>
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
</>
</Trans>
);
}
if (type === "card.updated.label.added" && label) {
return (
<>
<Trans>
added label <TextHighlight>{label}</TextHighlight>
</>
</Trans>
);
}
if (type === "card.updated.label.removed" && label) {
return (
<>
<Trans>
removed label <TextHighlight>{label}</TextHighlight>
</>
</Trans>
);
}
@@ -148,6 +162,9 @@ const ActivityList = ({
isAdmin?: boolean;
}) => {
const { data } = authClient.useSession();
const { locale } = useLocalisation();
const currentDateLocale = dateLocaleMap[locale] || enGB;
return (
<div className="flex flex-col space-y-4 pt-4">
@@ -158,7 +175,7 @@ const ActivityList = ({
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 === data?.user.id,
label: activity.label?.name ?? null,
});
@@ -171,10 +188,10 @@ const ActivityList = ({
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
isLoading={isLoading}
createdAt={activity.createdAt}
createdAt={activity.createdAt.toISOString()}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === data?.user?.id}
isAuthor={activity.comment?.createdBy === data?.user.id}
isAdmin={isAdmin ?? false}
/>
);
@@ -213,6 +230,7 @@ const ActivityList = ({
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
locale: currentDateLocale,
})}
</span>
</p>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns";
import { useState } from "react";
import ContentEditable from "react-contenteditable";
@@ -57,8 +58,8 @@ const Comment = ({
},
onError: () => {
showPopup({
header: "Unable to update comment",
message: "Please try again later, or contact customer support.",
header: t`Unable to update comment`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -76,7 +77,7 @@ const Comment = ({
...(isAuthor
? [
{
label: "Edit comment",
label: t`Edit comment`,
action: () => setIsEditing(true),
icon: <HiPencil className="h-[16px] w-[16px] text-dark-900" />,
},
@@ -85,7 +86,7 @@ const Comment = ({
...(isAuthor || isAdmin
? [
{
label: "Delete comment",
label: t`Delete comment`,
action: () => openModal("DELETE_COMMENT", publicId),
icon: <HiTrash className="h-[16px] w-[16px] text-dark-900" />,
},
@@ -117,7 +118,7 @@ const Comment = ({
</span>
{isEdited && (
<span className="text-light-900 dark:text-dark-800">
{" (edited)"}
{t` (edited)`}
</span>
)}
</p>
@@ -132,11 +133,15 @@ const Comment = ({
)}
</div>
{!isEditing ? (
<p className="mt-2 text-sm">{comment}</p>
<ContentEditable
html={comment ?? ""}
disabled={true}
className="mt-2 text-sm"
/>
) : (
<form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable
placeholder="Add a comment..."
placeholder={t`Add a comment...`}
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
@@ -148,14 +153,14 @@ const Comment = ({
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancel
{t`Cancel`}
</Button>
<Button
isLoading={updateCommentMutation.isPending}
type="submit"
size="sm"
>
Save
{t`Save`}
</Button>
</div>
</form>

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
@@ -47,8 +48,8 @@ export function DeleteCardConfirmation({
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to delete card",
message: "Please try again later, or contact customer support.",
header: t`Unable to delete card`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -71,10 +72,10 @@ export function DeleteCardConfirmation({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this card?
{t`Are you sure you want to delete this card?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end sm:mt-6">
@@ -82,13 +83,13 @@ export function DeleteCardConfirmation({
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
onClick={() => closeModal()}
>
Cancel
{t`Cancel`}
</button>
<Button
onClick={handleDeleteCard}
isLoading={deleteCardMutation.isPending}
>
Delete
{t`Delete`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -39,8 +41,8 @@ export function DeleteCommentConfirmation({
onError: (_error, _newList, context) => {
utils.card.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to delete comment",
message: "Please try again later, or contact customer support.",
header: t`Unable to delete comment`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -60,10 +62,10 @@ export function DeleteCommentConfirmation({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this comment?
{t`Are you sure you want to delete this comment?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end sm:mt-6">
@@ -71,13 +73,13 @@ export function DeleteCommentConfirmation({
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
onClick={() => closeModal()}
>
Cancel
{t`Cancel`}
</button>
<Button
onClick={handleDeleteComment}
isLoading={deleteCommentMutation.isPending}
>
Delete
{t`Delete`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
@@ -10,7 +11,7 @@ export default function BoardDropdown() {
<Dropdown
items={[
{
label: "Delete card",
label: t`Delete card`,
action: () => openModal("DELETE_CARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},

View File

@@ -1,4 +1,5 @@
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiMiniPlus } from "react-icons/hi2";
import Badge from "~/components/Badge";
@@ -68,8 +69,8 @@ export default function LabelSelector({
onError: (_error, _newList, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: "Unable to update labels",
message: "Please try again later, or contact customer support.",
header: t`Unable to update labels`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -94,7 +95,7 @@ export default function LabelSelector({
}}
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={() => openModal("NEW_LABEL")}
createNewItemLabel="Create new label"
createNewItemLabel={t`Create new label`}
asChild
>
{selectedLabels.length ? (
@@ -105,13 +106,16 @@ export default function LabelSelector({
</Menu.Button>
))}
<Menu.Button>
<Badge value="Add label" iconLeft={<HiMiniPlus size={14} />} />
<Badge
value={t`Add label`}
iconLeft={<HiMiniPlus size={14} />}
/>
</Menu.Button>
</div>
) : (
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
<HiMiniPlus size={22} className="pr-2" />
Add label
{t`Add label`}
</Menu.Button>
)}
</CheckboxDropdown>

View File

@@ -1,4 +1,5 @@
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { usePopup } from "~/providers/popup";
@@ -48,8 +49,8 @@ export default function ListSelector({
onError: (_error, _newList, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: "Unable to update list",
message: "Please try again later, or contact customer support.",
header: t`Unable to update list`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},

View File

@@ -1,5 +1,6 @@
import { useRouter } from "next/router";
import { Menu } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiMiniPlus } from "react-icons/hi2";
import Avatar from "~/components/Avatar";
@@ -55,6 +56,8 @@ export default function MemberSelector({
...oldCard.members,
{
publicId: update.workspaceMemberPublicId,
email: memberToAdd?.email ?? "",
deletedAt: null,
user: {
id: memberToAdd?.user?.id ?? "",
name: memberToAdd?.user?.name ?? "",
@@ -73,8 +76,8 @@ export default function MemberSelector({
onError: (_error, _newList, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: "Unable to update members",
message: "Please try again later, or contact customer support.",
header: t`Unable to update members`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -106,7 +109,7 @@ export default function MemberSelector({
});
}}
handleCreate={handleInviteMember}
createNewItemLabel="Invite member"
createNewItemLabel={t`Invite member`}
asChild
>
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
@@ -124,7 +127,7 @@ export default function MemberSelector({
) : (
<>
<HiMiniPlus size={22} className="pr-2" />
{"Add member"}
{t`Add member`}
</>
)}
</Menu.Button>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2";
@@ -26,8 +27,8 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
const addCommentMutation = api.card.addComment.useMutation({
onError: (_error, _newList) => {
showPopup({
header: "Unable to add comment",
message: "Please try again later, or contact customer support.",
header: t`Unable to add comment`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -53,7 +54,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
className="flex w-full max-w-[1000px] flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<ContentEditable
placeholder="Add a comment..."
placeholder={t`Add a comment...`}
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}

View File

@@ -1,9 +1,11 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5";
import Avatar from "~/components/Avatar";
import Editor from "~/components/Editor";
import { LabelForm } from "~/components/LabelForm";
import LabelIcon from "~/components/LabelIcon";
import Modal from "~/components/modal";
@@ -23,7 +25,6 @@ import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
import NewCommentForm from "./components/NewCommentForm";
import Editor from "~/components/Editor";
interface FormValues {
cardId: string;
@@ -111,8 +112,8 @@ export default function CardPage() {
const updateCard = api.card.update.useMutation({
onError: () => {
showPopup({
header: "Unable to update card",
message: "Please try again later, or contact customer support.",
header: t`Unable to update card`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -144,7 +145,7 @@ export default function CardPage() {
return (
<>
<PageHead
title={`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
/>
<div className="flex h-full flex-1 flex-row">
<div className="flex h-full w-full flex-col overflow-hidden">
@@ -189,7 +190,7 @@ export default function CardPage() {
)}
{!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Card not found
{t`Card not found`}
</p>
)}
</div>
@@ -211,7 +212,7 @@ export default function CardPage() {
</div>
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
Activity
{t`Activity`}
</h2>
<div>
<ActivityList
@@ -231,7 +232,7 @@ export default function CardPage() {
</div>
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">List</p>
<p className="my-2 w-[100px] text-sm">{t`List`}</p>
<ListSelector
cardPublicId={cardId}
lists={formattedLists}
@@ -239,7 +240,7 @@ export default function CardPage() {
/>
</div>
<div className="mb-4 flex w-full">
<p className="my-2 w-[100px] text-sm">Labels</p>
<p className="my-2 w-[100px] text-sm">{t`Labels`}</p>
<LabelSelector
cardPublicId={cardId}
labels={formattedLabels}
@@ -247,7 +248,7 @@ export default function CardPage() {
/>
</div>
<div className="flex w-full">
<p className="my-2 w-[100px] text-sm">Members</p>
<p className="my-2 w-[100px] text-sm">{t`Members`}</p>
<MemberSelector
cardPublicId={cardId}
members={formattedMembers}

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import Button from "~/components/Button";
@@ -50,15 +51,14 @@ const Cta = ({ theme }: { theme: string }) => {
<p>kan.bn/{currentWorkspaceSlug}</p>
</div>
<h2 className="text-balance text-4xl font-bold tracking-tight text-light-1000 dark:text-dark-1000 sm:text-4xl">
Get started for free today
{t`Get started for free today`}
</h2>
<p className="text-md/8 mx-auto mt-6 max-w-[375px] text-pretty text-light-900 dark:text-dark-900">
Unlimited boards, unlimited lists, unlimited cards. No credit card
required.
{t`Unlimited boards, unlimited lists, unlimited cards. No credit card required.`}
</p>
<Link href="/signup">
<div className="mt-10 flex items-center justify-center gap-x-6">
<Button size="lg">Get started</Button>
<Button size="lg">{t`Get started`}</Button>
</div>
</Link>
</div>

View File

@@ -4,6 +4,8 @@ import {
DisclosureButton,
DisclosurePanel,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { HiMiniMinusSmall, HiMiniPlusSmall } from "react-icons/hi2";
const Text = ({ children }: { children: React.ReactNode }) => {
@@ -14,143 +16,145 @@ const Text = ({ children }: { children: React.ReactNode }) => {
);
};
const faqs = [
{
question: "Why make an open source Trello?",
answer: (
<Text>
When Trello launched in 2011, it blew everyone away with its carefully
designed simplicity, but over time (and atlassification) it lost its
magic and grew into something closer to "Jira Lite". This project is our
attempt to recapture the original magic of Trellos simplicity, in open
source.
</Text>
),
},
{
question: "What's the difference between Kan and Trello?",
answer: (
<Text>
The main difference between Kan and Trello is that Kan is open source,
allowing anyone to view, modify, and contribute to our code. Our cloud
offering also offers no restrictions on features for individual use,
whereas Trello locks basic features such as the number of boards you can
create behind a paywall.
</Text>
),
},
{
question: "How is the project funded?",
answer: (
<Text>
For long-term sustainability, we recognise all good open source projects
need a source of revenue. Ours comes from the paid cloud offering for
workspaces with multiple users (free during the beta) and for custom
workspace slugs. There's no obligation to use it, and you can self-host
the software on your own infrastructure free of charge.
</Text>
),
},
{
question: "How do I import my Trello boards?",
answer: (
<Text>
Importing your Trello boards into Kan is easy. You can follow our
step-by-step guide{" "}
<Link href="https://docs.kan.bn/imports/trello" className="underline">
here
</Link>
.
</Text>
),
},
{
question: "How do I get a custom URL?",
answer: (
<Text>
You can get a custom workspace URL, like{" "}
<Link href="https://kan.bn/kan" className="underline">
kan.bn/kan
</Link>
, by going into your{" "}
<Link href="https://kan.bn/settings" className="underline">
workspace settings
</Link>{" "}
and purchasing a pro workspace subscription. All subscriptions help fund
the development of the project!
</Text>
),
},
{
question: "Do you offer a free plan?",
answer: (
<Text>
Yes, we offer an forever free plan for individual use. No restrictions,
no paywalls, no limits.
</Text>
),
},
{
question: "What license are you using?",
answer: (
<Text>
We are using the{" "}
<Link
href="https://github.com/kan-bn/kan/blob/main/LICENSE"
className="underline"
>
AGPL-3.0 license
</Link>
.
</Text>
),
},
{
question: "How do I invite team members?",
answer: (
<Text>
You can invite team members by clicking the "Invite" button in the top
right corner of the{" "}
<Link href="https://kan.bn/members" className="underline">
members page
</Link>{" "}
and entering their email address. They will receive an email with a link
to join the workspace.
</Text>
),
},
{
question: "How do I self-host?",
answer: (
<Text>
You can self-host by following the instructions in our{" "}
<Link href="https://github.com/kanbn/kan" className="underline">
repo
</Link>
.
</Text>
),
},
];
const Faqs = () => {
const faqs = [
{
question: t`Why make an open source Trello?`,
answer: (
<Text>
{t`When Trello launched in 2011, it blew everyone away with its carefully designed simplicity, but over time (and atlassification) it lost its magic and grew into something closer to "Jira Lite". This project is our attempt to recapture the original magic of Trello's simplicity, in open source.`}
</Text>
),
},
{
question: t`What's the difference between Kan and Trello?`,
answer: (
<Text>
{t`The main difference between Kan and Trello is that Kan is open source, allowing anyone to view, modify, and contribute to our code. Our cloud offering also offers no restrictions on features for individual use, whereas Trello locks basic features such as the number of boards you can create behind a paywall.`}
</Text>
),
},
{
question: t`How is the project funded?`,
answer: (
<Text>
{t`For long-term sustainability, we recognise all good open source projects need a source of revenue. Ours comes from the paid cloud offering for workspaces with multiple users (free during the beta) and for custom workspace slugs. There's no obligation to use it, and you can self-host the software on your own infrastructure free of charge.`}
</Text>
),
},
{
question: t`How do I import my Trello boards?`,
answer: (
<Text>
<Trans>
Importing your Trello boards into Kan is easy. You can follow our
step-by-step guide{" "}
<Link
href="https://docs.kan.bn/imports/trello"
className="underline"
>
here
</Link>
.
</Trans>
</Text>
),
},
{
question: t`How do I get a custom URL?`,
answer: (
<Text>
<Trans>
You can get a custom workspace URL, like{" "}
<Link href="https://kan.bn/kan" className="underline">
kan.bn/kan
</Link>
, by going into your{" "}
<Link href="https://kan.bn/settings" className="underline">
workspace settings
</Link>{" "}
and purchasing a pro workspace subscription. All subscriptions help
fund the development of the project!
</Trans>
</Text>
),
},
{
question: t`Do you offer a free plan?`,
answer: (
<Text>
{t`Yes, we offer an forever free plan for individual use. No restrictions, no paywalls, no limits.`}
</Text>
),
},
{
question: t`What license are you using?`,
answer: (
<Text>
<Trans>
We are using the{" "}
<Link
href="https://github.com/kan-bn/kan/blob/main/LICENSE"
className="underline"
>
AGPL-3.0 license
</Link>
.
</Trans>
</Text>
),
},
{
question: t`How do I invite team members?`,
answer: (
<Text>
<Trans>
You can invite team members by clicking the "Invite" button in the
top right corner of the{" "}
<Link href="https://kan.bn/members" className="underline">
members page
</Link>{" "}
and entering their email address. They will receive an email with a
link to join the workspace.
</Trans>
</Text>
),
},
{
question: t`How do I self-host?`,
answer: (
<Text>
<Trans>
You can self-host by following the instructions in our{" "}
<Link href="https://github.com/kanbn/kan" className="underline">
repo
</Link>
.
</Trans>
</Text>
),
},
];
return (
<div className="mx-auto max-w-[900px] px-4">
<div className="flex flex-col items-center justify-center pb-12">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
<p>FAQs</p>
<p>{t`FAQs`}</p>
</div>
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
Questions?
{t`Questions?`}
</p>
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
Find answers to common questions about the project. Can't find what
you're looking for? Feel free to{" "}
<Link href="mailto:support@kan.bn" className="underline">
contact us
</Link>
.
<Trans>
Find answers to common questions about the project. Can't find what
you're looking for? Feel free to{" "}
<Link href="mailto:support@kan.bn" className="underline">
contact us
</Link>
.
</Trans>
</p>
</div>
<div className="rounded-2xl bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-400">

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { useState } from "react";
import activityLogsIconDark from "~/assets/activity-logs-dark.json";
@@ -62,7 +63,7 @@ const FeatureItem = ({
{feature.comingSoon && (
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
Coming soon
{t`Coming soon`}
</div>
)}
</div>
@@ -71,47 +72,47 @@ const FeatureItem = ({
const Features = ({ theme }: { theme: "light" | "dark" }) => {
const isDark = theme === "dark";
const features = [
{
title: "Board visibility",
description: "Control who can view and edit your boards.",
title: t`Board visibility`,
description: t`Control who can view and edit your boards.`,
icon: isDark ? boardVisibilityIconDark : boardVisibilityIconLight,
},
{
title: "Workspace members",
description: "Collaborate seamlessly with your team.",
title: t`Workspace members`,
description: t`Collaborate seamlessly with your team.`,
icon: isDark ? membersIconDark : membersIconLight,
},
{
title: "Trello imports",
description: "Import your Trello boards and hit the ground running.",
title: t`Trello imports`,
description: t`Import your Trello boards and hit the ground running.`,
icon: isDark ? importsIconDark : importsIconLight,
},
{
title: "Labels & Filters",
description:
"Organize and find cards quickly with powerful filtering tools.",
title: t`Labels & Filters`,
description: t`Organize and find cards quickly with powerful filtering tools.`,
icon: isDark ? labelsIconDark : labelsIconLight,
},
{
title: "Comments",
description: "Discuss and collaborate on cards.",
title: t`Comments`,
description: t`Discuss and collaborate on cards.`,
icon: isDark ? commentsIconDark : commentsIconLight,
},
{
title: "Activity logs",
description: "Track all card changes with detailed activity history.",
title: t`Activity logs`,
description: t`Track all card changes with detailed activity history.`,
icon: isDark ? activityLogsIconDark : activityLogsIconLight,
},
{
title: "Templates",
description: "Save time with reusable board templates.",
title: t`Templates`,
description: t`Save time with reusable board templates.`,
icon: isDark ? templatesIconDark : templatesIconLight,
comingSoon: true,
},
{
title: "Integrations",
description: "Connect your favorite tools to streamline your workflow.",
title: t`Integrations`,
description: t`Connect your favorite tools to streamline your workflow.`,
icon: isDark ? integrationsIconDark : integrationsIconLight,
comingSoon: true,
},
@@ -121,15 +122,14 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
<>
<div className="flex flex-col items-center justify-center px-4 pb-24">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
<p>Features</p>
<p>{t`Features`}</p>
</div>
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
Kanban reimagined
{t`Kanban reimagined`}
</p>
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
Simple, visual task management that just works. Drag and drop cards,
collaborate with your team, and get more done.
{t`Simple, visual task management that just works. Drag and drop cards, collaborate with your team, and get more done.`}
</p>
<div className="mx-auto mt-16 w-full max-w-7xl">
<div className="grid w-full grid-cols-1 gap-4 [mask-image:linear-gradient(to_bottom,black_92%,transparent_100%)] sm:grid-cols-2 md:grid-cols-3 md:[mask-image:linear-gradient(to_bottom,black_85%,transparent_100%)] lg:grid-cols-4 lg:[mask-image:linear-gradient(to_bottom,black_85%,transparent_100%)]">
@@ -142,9 +142,9 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
<div>
<div className="mt-8 flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p className="text-xs lg:text-sm">
{`We're just getting started. `}
{t`We're just getting started. `}
<Link href="/kan/roadmap" className="underline">
View our roadmap.
{t`View our roadmap.`}
</Link>
</p>
</div>

View File

@@ -1,37 +1,8 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { FaDiscord, FaGithub } from "react-icons/fa";
const navigation = {
documentation: [
{ name: "Getting started", href: "https://docs.kan.bn/introduction" },
{
name: "Importing from Trello",
href: "https://docs.kan.bn/imports/trello",
},
{
name: "API Reference",
href: "https://docs.kan.bn/api-reference/introduction",
},
],
company: [
{ name: "Roadmap", href: "/kan/roadmap" },
{ name: "GitHub", href: "https://github.com/kanbn/kan" },
{ name: "Contact", href: "mailto:support@kan.bn" },
],
legal: [
{ name: "Terms of service", href: "/terms" },
{ name: "Privacy policy", href: "/privacy" },
{
name: "License",
href: "https://github.com/kanbn/kan?tab=AGPL-3.0-1-ov-file#readme",
},
],
resources: [
{ name: "Features", href: "/#features" },
{ name: "Pricing", href: "/#pricing" },
{ name: "FAQs", href: "/#faq" },
],
};
import { LanguageSelector } from "~/components/LanguageSelector";
const StatusMarker = () => (
<Link
@@ -44,11 +15,43 @@ const StatusMarker = () => (
<span className="absolute -inset-[1px] animate-[ping_1s_infinite] rounded-full bg-green-500/30"></span>
<span className="absolute inset-0 rounded-full bg-green-500"></span>
</span>
All systems operational
{t`All systems operational`}
</Link>
);
const Footer = () => {
const navigation = {
documentation: [
{ name: t`Getting started`, href: "https://docs.kan.bn/introduction" },
{
name: t`Importing from Trello`,
href: "https://docs.kan.bn/imports/trello",
},
{
name: t`API Reference`,
href: "https://docs.kan.bn/api-reference/introduction",
},
],
company: [
{ name: t`Roadmap`, href: "/kan/roadmap" },
{ name: t`GitHub`, href: "https://github.com/kanbn/kan" },
{ name: t`Contact`, href: "mailto:support@kan.bn" },
],
legal: [
{ name: t`Terms of service`, href: "/terms" },
{ name: t`Privacy policy`, href: "/privacy" },
{
name: t`License`,
href: "https://github.com/kanbn/kan?tab=AGPL-3.0-1-ov-file#readme",
},
],
resources: [
{ name: t`Features`, href: "/#features" },
{ name: t`Pricing`, href: "/#pricing" },
{ name: t`FAQs`, href: "/#faq" },
],
};
return (
<footer className="z-10 w-full border-t border-light-300 bg-light-50 py-8 dark:border-dark-300 dark:bg-dark-50">
<div className="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 lg:py-24">
@@ -64,13 +67,14 @@ const Footer = () => {
</div>
<StatusMarker />
<LanguageSelector />
</div>
<div className="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Documentation
{t`Documentation`}
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.documentation.map((item) => (
@@ -87,7 +91,7 @@ const Footer = () => {
</div>
<div>
<h3 className="mt-10 text-sm/6 font-semibold text-light-1000 dark:text-dark-1000 md:mt-0">
Company
{t`Company`}
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.company.map((item) => (
@@ -106,7 +110,7 @@ const Footer = () => {
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
Resources
{t`Resources`}
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.resources.map((item) => (
@@ -123,7 +127,7 @@ const Footer = () => {
</div>
<div>
<h3 className="mt-10 text-sm/6 font-semibold text-light-1000 dark:text-dark-1000 md:mt-0">
Legal
{t`Legal`}
</h3>
<ul role="list" className="mt-6 space-y-4">
{navigation.legal.map((item) => (

View File

@@ -1,4 +1,5 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
@@ -9,10 +10,10 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuItems = [
{ label: "Roadmap", href: "/kan/roadmap", openInNewTab: true },
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ label: "Docs", href: "https://docs.kan.bn", openInNewTab: true },
{ label: t`Roadmap`, href: "/kan/roadmap", openInNewTab: true },
{ label: t`Features`, href: "#features" },
{ label: t`Pricing`, href: "#pricing" },
{ label: t`Docs`, href: "https://docs.kan.bn", openInNewTab: true },
];
useEffect(() => {
@@ -69,7 +70,7 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
<button
onClick={toggleMenu}
className="z-50 p-2 lg:hidden"
aria-label="Toggle menu"
aria-label={t`Toggle menu`}
>
<div
className={twMerge(
@@ -92,13 +93,13 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
</button>
<div className="hidden w-[200px] justify-end gap-2 lg:flex">
{isLoggedIn ? (
<Button href="/boards">Go to app</Button>
<Button href="/boards">{t`Go to app`}</Button>
) : (
<>
<Button href="/login" variant="ghost">
Sign in
{t`Sign in`}
</Button>
<Button href="/signup">Get started</Button>
<Button href="/signup">{t`Get started`}</Button>
</>
)}
</div>
@@ -129,15 +130,15 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
<div className="mt-8 flex flex-col gap-4">
{isLoggedIn ? (
<Button href="/boards" onClick={toggleMenu}>
Go to app
{t`Go to app`}
</Button>
) : (
<>
<Button href="/login" variant="ghost" onClick={toggleMenu}>
Sign in
{t`Sign in`}
</Button>
<Button href="/signup" onClick={toggleMenu}>
Get started
{t`Get started`}
</Button>
</>
)}

View File

@@ -1,108 +1,104 @@
import Link from "next/link";
import { Radio, RadioGroup } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useState } from "react";
import { HiCheckCircle } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
type Frequency = "monthly" | "annually";
const frequencies = [
{
value: "monthly" as Frequency,
label: "Monthly",
priceSuffix: "per user/month",
},
{
value: "annually" as Frequency,
label: "Yearly",
priceSuffix: "per user/month",
},
];
const tiers = [
{
name: "Individuals",
id: "tier-individuals",
href: "signup",
buttonText: "Get Started",
price: { monthly: "Free", annually: "Free" },
description:
"Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.",
featureHeader: "Free, forever",
features: [
"1 user",
"Unlimited boards",
"Unlimited lists",
"Unlimited cards",
"Unlimited comments",
"Unlimited activity log",
],
showPrice: true,
},
{
name: "Teams",
id: "tier-teams",
href: "signup",
buttonText: "Get Started",
price: { monthly: "$10.00", annually: "$8.00" },
description:
"Kanban is better with a team. Perfect for small and growing teams looking to collaborate.",
featureHeader: "Everything in the free plan, plus:",
features: [
"Workspace members",
"Admin roles",
"Priority email support",
"Support the development of the project",
],
highlighted: true,
showPrice: true,
showPriceSuffix: true,
},
{
name: "Self Host",
id: "tier-self-host",
href: "https://github.com/kanbn/kan",
buttonText: "View docs",
price: { monthly: "-", annually: "-" },
description:
"Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.",
featureHeader: "Complete control and ownership:",
features: [
"Run on your own infrastructure",
"Own your data",
"Custom domain",
],
mostPopular: false,
showPrice: false,
},
];
const Pricing = () => {
const initialFrequency = frequencies[1]!;
const [frequency, setFrequency] = useState(initialFrequency);
const frequencies = [
{
value: "monthly" as Frequency,
label: t`Monthly`,
priceSuffix: t`per user/month`,
},
{
value: "annually" as Frequency,
label: t`Yearly`,
priceSuffix: t`per user/month`,
},
];
const [frequency, setFrequency] = useState(frequencies[1]);
const tiers = [
{
name: t`Individuals`,
id: "tier-individuals",
href: "signup",
buttonText: t`Get Started`,
price: { monthly: t`Free`, annually: t`Free` },
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
featureHeader: t`Free, forever`,
features: [
t`1 user`,
t`Unlimited boards`,
t`Unlimited lists`,
t`Unlimited cards`,
t`Unlimited comments`,
t`Unlimited activity log`,
],
showPrice: true,
},
{
name: t`Teams`,
id: "tier-teams",
href: "signup",
buttonText: t`Get Started`,
price: { monthly: "$10.00", annually: "$8.00" },
description: t`Kanban is better with a team. Perfect for small and growing teams looking to collaborate.`,
featureHeader: t`Everything in the free plan, plus:`,
features: [
t`Workspace members`,
t`Admin roles`,
t`Priority email support`,
t`Support the development of the project`,
],
highlighted: true,
showPrice: true,
showPriceSuffix: true,
},
{
name: t`Self Host`,
id: "tier-self-host",
href: "https://github.com/kanbn/kan",
buttonText: t`View docs`,
price: { monthly: "-", annually: "-" },
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
featureHeader: t`Complete control and ownership:`,
features: [
t`Run on your own infrastructure`,
t`Own your data`,
t`Custom domain`,
],
mostPopular: false,
showPrice: false,
},
];
return (
<>
<div className="flex flex-col items-center justify-center px-4 pb-10">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
<p>Pricing</p>
<p>{t`Pricing`}</p>
</div>
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
Simple pricing
{t`Simple pricing`}
</p>
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
Get started for free, with no usage limits. For collaboration, upgrade
to a plan that fits the size of your team.
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
</p>
{/* Beta free pricing pill */}
<div className="mt-4 flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800 dark:bg-green-700 dark:text-green-100">
All seats are free during the beta until Sept 1st!
{t`All seats are free during the beta until Sept 1st!`}
</div>
<div className="mt-16 flex justify-center">
<fieldset aria-label="Payment frequency">
<fieldset aria-label={t`Payment frequency`}>
<RadioGroup
value={frequency}
onChange={(value) => setFrequency(value)}
@@ -145,7 +141,7 @@ const Pricing = () => {
>
{tier.name}
</h3>
{tier.highlighted && frequency.value === "annually" ? (
{tier.highlighted && frequency?.value === "annually" ? (
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
-20%
</p>
@@ -169,11 +165,11 @@ const Pricing = () => {
!tier.showPrice && "opacity-0",
)}
>
{tier.price[frequency.value]}
{tier.price[frequency?.value || "monthly"]}
</span>
{tier.showPriceSuffix && (
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
{frequency.priceSuffix}
{frequency?.priceSuffix}
</span>
)}
</p>

View File

@@ -1,5 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
import Button from "~/components/Button";
@@ -32,7 +34,7 @@ export default function HomeView() {
target="_blank"
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
>
Star on Github
{t`Star on Github`}
<IoLogoGithub size={20} />
</Link>
</div>
@@ -46,7 +48,7 @@ export default function HomeView() {
target="_blank"
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
>
#1 Hacker News
{t`#1 Hacker News`}
<div className="relative">
<div className="absolute inset-1 bg-white" />
<IoLogoHackernews
@@ -60,27 +62,27 @@ export default function HomeView() {
</div>
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
The open source <br />
alternative to Trello
<Trans>
The open source <br /> alternative to Trello
</Trans>
</p>
<p className="text-md mt-3 max-w-[450px] text-center text-dark-900 lg:max-w-[600px] lg:text-lg">
A powerful, flexible kanban app that helps you organise work,
track progress, and deliver resultsall in one place.
{t`A powerful, flexible kanban app that helps you organise work, track progress, and deliver results—all in one place.`}
</p>
<div className="mt-6 flex gap-2">
<Button href="/signup">Get started on Cloud</Button>
<Button href="/signup">{t`Get started on Cloud`}</Button>
<Button
variant="secondary"
href="https://github.com/kanbn/kan"
openInNewTab
>
Self host with Github
{t`Self host with Github`}
</Button>
</div>
<p className="mt-4 text-center text-sm text-dark-900">
No credit card required
{t`No credit card required`}
</p>
</div>
</div>

View File

@@ -1,3 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -21,8 +24,8 @@ export function DeleteMemberConfirmation() {
},
onError: () => {
showPopup({
header: "Unable to remove member",
message: "Please try again later, or contact customer support.",
header: t`Unable to remove member`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
closeModal();
@@ -41,18 +44,18 @@ export function DeleteMemberConfirmation() {
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{`Are you sure want to remove ${entityLabel}?`}
<Trans>Are you sure want to remove {entityLabel}?</Trans>
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"They won't be able to access this workspace."}
{t`They won't be able to access this workspace.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
{t`Cancel`}
</Button>
<Button onClick={handleDeleteMember} isLoading={deleteMember.isPending}>
Remove
{t`Remove`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
@@ -46,7 +47,7 @@ export function InviteMemberForm() {
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">Add member</h2>
<h2 className="text-sm font-bold">{t`Add member`}</h2>
<button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
@@ -60,7 +61,7 @@ export function InviteMemberForm() {
</div>
<Input
id="email"
placeholder="Email"
placeholder={t`Email`}
{...register("email", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -78,7 +79,7 @@ export function InviteMemberForm() {
isLoading={createBoard.isPending}
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
Invite member
{t`Invite member`}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
@@ -103,7 +104,7 @@ export default function MembersPage() {
</span>
{memberStatus === "invited" && (
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
Pending
{t`Pending`}
</span>
)}
</div>
@@ -116,7 +117,7 @@ export default function MembersPage() {
<Dropdown
items={[
{
label: "Remove member",
label: t`Remove member`,
action: () =>
openModal(
"REMOVE_MEMBER",
@@ -140,11 +141,11 @@ export default function MembersPage() {
return (
<>
<PageHead title={`Members | ${workspace.name ?? "Workspace"}`} />
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
<div className="m-auto max-w-[1600px] px-28 py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Members
{t`Members`}
</h1>
<div className="flex">
<Button
@@ -152,7 +153,7 @@ export default function MembersPage() {
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
disabled={workspace.role !== "admin"}
>
Invite
{t`Invite`}
</Button>
</div>
</div>
@@ -168,13 +169,13 @@ export default function MembersPage() {
scope="col"
className="w-[65%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
>
User
{t`User`}
</th>
<th
scope="col"
className="w-[35%] rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
Role
{t`Role`}
</th>
</tr>
</thead>

View File

@@ -1,8 +1,10 @@
import { authClient } from "@kan/auth/client";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { HiXMark } from "react-icons/hi2";
import Editor from "~/components/Editor";
import { authClient } from "@kan/auth/client";
import Editor from "~/components/Editor";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList";
@@ -99,7 +101,7 @@ export function CardModal({
)}
<div className="border-t-[1px] border-light-600 pb-4 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
Activity
{t`Activity`}
</h2>
<div>
{cardPublicId && (

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { HiLink } from "react-icons/hi2";
@@ -47,9 +48,9 @@ export default function PublicBoardView() {
}
showPopup({
header: "Link copied",
header: t`Link copied`,
icon: "success",
message: "Board URL copied to clipboard",
message: t`Board URL copied to clipboard`,
});
}}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
@@ -82,7 +83,7 @@ export default function PublicBoardView() {
return (
<>
<PageHead
title={`${data?.name ?? "Board"} | ${data?.workspace?.name ?? "Workspace"}`}
title={`${data?.name ?? "Board"} | ${data?.workspace.name ?? "Workspace"}`}
/>
<style jsx global>{`
html {
@@ -137,7 +138,7 @@ export default function PublicBoardView() {
{list.cards.map((card) => (
<Link
key={card.publicId}
href={`/${data.workspace?.slug}/${data.slug}/${card.publicId}`}
href={`/${data.workspace.slug}/${data.slug}/${card.publicId}`}
className={`mb-2 flex !cursor-pointer flex-col`}
shallow={true}
onClick={() => {
@@ -177,7 +178,7 @@ export default function PublicBoardView() {
<Modal modalSize={"md"} positionFromTop={"sm"}>
<CardModal
cardPublicId={cardPublicId}
workspaceSlug={data?.workspace?.slug}
workspaceSlug={data?.workspace.slug}
boardSlug={data?.slug}
/>
</Modal>

View File

@@ -1,4 +1,5 @@
import Image from "next/image";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useState } from "react";
@@ -20,8 +21,8 @@ export default function Avatar({
const updateUser = api.user.update.useMutation({
onSuccess: async () => {
showPopup({
header: "Profile image updated",
message: "Your profile image has been updated.",
header: t`Profile image updated`,
message: t`Your profile image has been updated.`,
icon: "success",
});
try {
@@ -33,8 +34,8 @@ export default function Avatar({
},
onError: () => {
showPopup({
header: "Error updating profile image",
message: "Please try again later, or contact customer support.",
header: t`Error updating profile image`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -50,8 +51,8 @@ export default function Avatar({
if (!file || !userId) {
return showPopup({
header: "Error uploading profile image",
message: "Please select a file to upload.",
header: t`Error uploading profile image`,
message: t`Please select a file to upload.`,
icon: "error",
});
}
@@ -91,8 +92,8 @@ export default function Avatar({
} catch (error) {
console.error(error);
showPopup({
header: "Error uploading profile image",
message: "Please try again later, or contact customer support.",
header: t`Error uploading profile image`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
} finally {

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
@@ -44,12 +46,12 @@ const CreateAPIKeyForm = ({
</div>
<div>
<Button variant="danger" onClick={handleRevokeAPIKey}>
Revoke
{t`Revoke`}
</Button>
</div>
</div>
) : (
<Button onClick={handleCreateAPIKey}>Create new key</Button>
<Button onClick={handleCreateAPIKey}>{t`Create new key`}</Button>
)}
</div>
);

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
@@ -37,19 +39,17 @@ export function CustomURLConfirmation({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{`Confirm URL change`}
{t`Confirm URL change`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{
"Custom URLs are a premium feature. You'll be directed to upgrade your account."
}
{t`Custom URLs are a premium feature. You'll be directed to upgrade your account.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
{t`Cancel`}
</Button>
<Button onClick={handleUpgrade}>Upgrade</Button>
<Button onClick={handleUpgrade}>{t`Upgrade`}</Button>
</div>
</div>
);

View File

@@ -1,8 +1,10 @@
import { authClient } from "@kan/auth/client";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -22,20 +24,20 @@ export function DeleteAccountConfirmation() {
onSuccess: async () => {
closeModal();
showPopup({
header: "Account deleted",
message: "Your account has been deleted.",
header: t`Account deleted`,
message: t`Your account has been deleted.`,
icon: "success",
});
utils.invalidate();
router.push("/");
},
onError: () => {
closeModal();
showPopup({
header: "Error deleting account",
message: "Please try again later, or contact customer support.",
header: t`Error deleting account`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -49,14 +51,13 @@ export function DeleteAccountConfirmation() {
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{`Are you sure you want to delete your account?`}
{t`Are you sure you want to delete your account?`}
</h2>
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
Keep in mind that this action is irreversible.
{t`Keep in mind that this action is irreversible.`}
</p>
<p className="text-sm text-light-900 dark:text-dark-900">
This will result in the permanent deletion of all data associated with
your account.
{t`This will result in the permanent deletion of all data associated with your account.`}
</p>
</div>
<div className="relative flex items-start">
@@ -78,14 +79,13 @@ export function DeleteAccountConfirmation() {
id="comments-description"
className="text-light-900 dark:text-dark-1000"
>
I acknowledge that all of my account data will be permanently
deleted and want to proceed.
{t`I acknowledge that all of my account data will be permanently deleted and want to proceed.`}
</p>
</div>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
{t`Cancel`}
</Button>
<Button
variant="danger"
@@ -93,7 +93,7 @@ export function DeleteAccountConfirmation() {
disabled={!isAcknowledgmentChecked}
isLoading={deleteAccountMutation.isPending}
>
Delete account
{t`Delete account`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useState } from "react";
import Button from "~/components/Button";
@@ -21,13 +23,13 @@ export function DeleteWorkspaceConfirmation() {
onSuccess: async () => {
closeModal();
showPopup({
header: "Workspace deleted",
message: "Your workspace has been deleted.",
header: t`Workspace deleted`,
message: t`Your workspace has been deleted.`,
icon: "success",
});
await utils.workspace.all.refetch();
const filteredWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace.publicId,
);
@@ -40,8 +42,8 @@ export function DeleteWorkspaceConfirmation() {
onError: () => {
closeModal();
showPopup({
header: "Error deleting workspace",
message: "Please try again later, or contact customer support.",
header: t`Error deleting workspace`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -57,14 +59,15 @@ export function DeleteWorkspaceConfirmation() {
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{`Are you sure you want to delete the workspace ${workspace.name}?`}
<Trans>
Are you sure you want to delete the workspace {workspace.name}?
</Trans>
</h2>
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
Keep in mind that this action is irreversible.
{t`Keep in mind that this action is irreversible.`}
</p>
<p className="text-sm text-light-900 dark:text-dark-900">
This will result in the permanent deletion of all data associated with
this workspace.
{t`This will result in the permanent deletion of all data associated with this workspace.`}
</p>
</div>
<div className="relative flex items-start">
@@ -86,14 +89,13 @@ export function DeleteWorkspaceConfirmation() {
id="comments-description"
className="text-light-900 dark:text-dark-1000"
>
I acknowledge that all of the workspace data will be permanently
deleted and want to proceed.
{t`I acknowledge that all of the workspace data will be permanently deleted and want to proceed.`}
</p>
</div>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
{t`Cancel`}
</Button>
<Button
variant="danger"
@@ -101,7 +103,7 @@ export function DeleteWorkspaceConfirmation() {
disabled={!isAcknowledgmentChecked}
isLoading={deleteWorkspaceMutation.isPending}
>
Delete workspace
{t`Delete workspace`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -7,22 +8,22 @@ import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const schema = z.object({
name: z
.string()
.min(3, {
message: "Display namemust be at least 3 characters long",
})
.max(280, {
message: "Display name cannot exceed 280 characters",
}),
});
type FormValues = z.infer<typeof schema>;
const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const schema = z.object({
name: z
.string()
.min(3, {
message: t`Display name must be at least 3 characters long`,
})
.max(280, {
message: t`Display name cannot exceed 280 characters`,
}),
});
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
@@ -37,8 +38,8 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
const updateDisplayName = api.user.update.useMutation({
onSuccess: async () => {
showPopup({
header: "Display name updated",
message: "Your display name has been updated.",
header: t`Display name updated`,
message: t`Your display name has been updated.`,
icon: "success",
});
try {
@@ -50,8 +51,8 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
},
onError: () => {
showPopup({
header: "Error updating display name",
message: "Please try again later, or contact customer support.",
header: t`Error updating display name`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -75,7 +76,7 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
disabled={!isDirty || updateDisplayName.isPending}
isLoading={updateDisplayName.isPending}
>
Update
{t`Update`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -7,19 +8,6 @@ import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const schema = z.object({
description: z
.string()
.min(3, {
message: "Workspace description must be at least 3 characters long",
})
.max(280, {
message: "Workspace description cannot exceed 280 characters",
}),
});
type FormValues = z.infer<typeof schema>;
const UpdateWorkspaceDescriptionForm = ({
workspacePublicId,
workspaceDescription,
@@ -29,6 +17,20 @@ const UpdateWorkspaceDescriptionForm = ({
}) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const schema = z.object({
description: z
.string()
.min(3, {
message: t`Workspace description must be at least 3 characters long`,
})
.max(280, {
message: t`Workspace description cannot exceed 280 characters`,
}),
});
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
@@ -43,8 +45,8 @@ const UpdateWorkspaceDescriptionForm = ({
const updateWorkspaceDescription = api.workspace.update.useMutation({
onSuccess: async () => {
showPopup({
header: "Workspace description updated",
message: "Your workspace description has been updated.",
header: t`Workspace description updated`,
message: t`Your workspace description has been updated.`,
icon: "success",
});
try {
@@ -56,8 +58,8 @@ const UpdateWorkspaceDescriptionForm = ({
},
onError: () => {
showPopup({
header: "Error updating workspace description",
message: "Please try again later, or contact customer support.",
header: t`Error updating workspace description`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -85,7 +87,7 @@ const UpdateWorkspaceDescriptionForm = ({
disabled={!isDirty || updateWorkspaceDescription.isPending}
isLoading={updateWorkspaceDescription.isPending}
>
Update
{t`Update`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -10,8 +11,8 @@ import { api } from "~/utils/api";
const schema = z.object({
name: z
.string()
.min(3, { message: "Workspace name must be at least 3 characters long" })
.max(24, { message: "Workspace name cannot exceed 24 characters" }),
.min(3, { message: t`Workspace name must be at least 3 characters long` })
.max(24, { message: t`Workspace name cannot exceed 24 characters` }),
});
type FormValues = z.infer<typeof schema>;
@@ -39,8 +40,8 @@ const UpdateWorkspaceNameForm = ({
const updateWorkspaceName = api.workspace.update.useMutation({
onSuccess: async () => {
showPopup({
header: "Workspace name updated",
message: "Your workspace name has been updated.",
header: t`Workspace name updated`,
message: t`Your workspace name has been updated.`,
icon: "success",
});
try {
@@ -52,8 +53,8 @@ const UpdateWorkspaceNameForm = ({
},
onError: () => {
showPopup({
header: "Error updating workspace name",
message: "Please try again later, or contact customer support.",
header: t`Error updating workspace name`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -78,7 +79,7 @@ const UpdateWorkspaceNameForm = ({
disabled={!isDirty || updateWorkspaceName.isPending}
isLoading={updateWorkspaceName.isPending}
>
Update
{t`Update`}
</Button>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useForm } from "react-hook-form";
import { HiCheck, HiMiniStar } from "react-icons/hi2";
@@ -11,20 +12,6 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const schema = z.object({
slug: z
.string()
.min(3, {
message: "URL must be at least 3 characters long",
})
.max(24, { message: "URL cannot exceed 24 characters" })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: "URL can only contain letters, numbers, and hyphens",
}),
});
type FormValues = z.infer<typeof schema>;
const UpdateWorkspaceUrlForm = ({
workspacePublicId,
workspaceUrl,
@@ -37,6 +24,21 @@ const UpdateWorkspaceUrlForm = ({
const utils = api.useUtils();
const { showPopup } = usePopup();
const { openModal } = useModal();
const schema = z.object({
slug: z
.string()
.min(3, {
message: t`URL must be at least 3 characters long`,
})
.max(24, { message: t`URL cannot exceed 24 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
}),
});
type FormValues = z.infer<typeof schema>;
const {
register,
handleSubmit,
@@ -55,8 +57,8 @@ const UpdateWorkspaceUrlForm = ({
const updateWorkspaceSlug = api.workspace.update.useMutation({
onSuccess: async () => {
showPopup({
header: "Workspace slug updated",
message: "Your workspace slug has been updated.",
header: t`Workspace slug updated`,
message: t`Your workspace slug has been updated.`,
icon: "success",
});
try {
@@ -68,8 +70,8 @@ const UpdateWorkspaceUrlForm = ({
},
onError: () => {
showPopup({
header: "Error updating workspace URL",
message: "Please try again later, or contact customer support.",
header: t`Error updating workspace URL`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
@@ -118,7 +120,7 @@ const UpdateWorkspaceUrlForm = ({
errorMessage={
errors.slug?.message ||
(isWorkspaceSlugAvailable?.isAvailable === false
? "This workspace username has already been taken"
? t`This workspace username has already been taken`
: undefined)
}
prefix={
@@ -149,7 +151,7 @@ const UpdateWorkspaceUrlForm = ({
}
isLoading={updateWorkspaceSlug.isPending}
>
Update
{t`Update`}
</Button>
</div>
</div>

View File

@@ -1,8 +1,10 @@
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import Button from "~/components/Button";
import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
@@ -35,12 +37,17 @@ export default function SettingsPage() {
} = api.integration.providers.useQuery();
const { data: trelloUrl, refetch: refetchTrelloUrl } =
api.integration.getAuthorizationUrl.useQuery({ provider: "trello" }, {
enabled:
!integrationsLoading &&
!integrations?.some((integration) => integration.provider === "trello"),
refetchOnWindowFocus: true,
});
api.integration.getAuthorizationUrl.useQuery(
{ provider: "trello" },
{
enabled:
!integrationsLoading &&
!integrations?.some(
(integration) => integration.provider === "trello",
),
refetchOnWindowFocus: true,
},
);
useEffect(() => {
const handleFocus = () => {
@@ -52,25 +59,26 @@ export default function SettingsPage() {
};
}, [refetchIntegrations]);
const { mutateAsync: disconnectTrello } = api.integration.disconnect.useMutation({
onSuccess: () => {
refetchUser();
refetchIntegrations();
refetchTrelloUrl();
showPopup({
header: "Trello disconnected",
message: "Your Trello account has been disconnected.",
icon: "success",
});
},
onError: () => {
showPopup({
header: "Error disconnecting Trello",
message: "An error occurred while disconnecting your Trello account.",
icon: "error",
});
},
});
const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({
onSuccess: () => {
refetchUser();
refetchIntegrations();
refetchTrelloUrl();
showPopup({
header: t`Trello disconnected`,
message: t`Your Trello account has been disconnected.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Error disconnecting Trello`,
message: t`An error occurred while disconnecting your Trello account.`,
icon: "error",
});
},
});
const refetchUser = () => utils.user.getUser.refetch();
@@ -97,29 +105,29 @@ export default function SettingsPage() {
<>
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto">
<PageHead title={`Settings | ${workspace.name ?? "Workspace"}`} />
<PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} />
<div className="px-28 py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
Settings
{t`Settings`}
</h1>
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Profile picture
{t`Profile picture`}
</h2>
<Avatar userId={data?.id} userImage={data?.image} />
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Display name
{t`Display name`}
</h2>
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Workspace name
{t`Workspace name`}
</h2>
<UpdateWorkspaceNameForm
workspacePublicId={workspace.publicId}
@@ -127,16 +135,16 @@ export default function SettingsPage() {
/>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Workspace URL
{t`Workspace URL`}
</h2>
<UpdateWorkspaceUrlForm
workspacePublicId={workspace.publicId}
workspaceUrl={workspace.slug}
workspacePlan={workspace.plan}
workspaceUrl={workspace.slug ?? ""}
workspacePlan={workspace.plan ?? "free"}
/>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Workspace description
{t`Workspace description`}
</h2>
<UpdateWorkspaceDescriptionForm
workspacePublicId={workspace.publicId}
@@ -144,20 +152,30 @@ export default function SettingsPage() {
/>
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
{t`Language`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Change the language of the app.`}
</p>
<LanguageSelector />
</div>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Billing
{t`Billing`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
View and manage your billing and subscription.
{t`View and manage your billing and subscription.`}
</p>
<Button
variant="primary"
iconRight={<HiMiniArrowTopRightOnSquare />}
onClick={handleOpenBillingPortal}
>
Billing portal
{t`Billing portal`}
</Button>
</div>
)}
@@ -171,7 +189,7 @@ export default function SettingsPage() {
) && trelloUrl ? (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
Connect your Trello account to import boards.
{t`Connect your Trello account to import boards.`}
</p>
<Button
variant="primary"
@@ -184,32 +202,34 @@ export default function SettingsPage() {
)
}
>
Connect Trello
{t`Connect Trello`}
</Button>
</>
) : (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
You are already connected to Trello.
</p>
<Button
variant="primary"
onClick={() => {
disconnectTrello({ provider: "trello" });
}}
>
Disconnect Trello
</Button>
</>
integrations?.some(
(integration) => integration.provider === "trello",
) && (
<>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Your Trello account is connected.`}
</p>
<Button
variant="secondary"
onClick={() => disconnectTrello({ provider: "trello" })}
>
{t`Disconnect Trello`}
</Button>
</>
)
)}
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
API keys
{t`API keys`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
View and manage your API keys.
{t`View and manage your API keys.`}
</p>
<CreateAPIKeyForm
apiKey={data?.apiKey}
@@ -219,36 +239,35 @@ export default function SettingsPage() {
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Delete workspace
{t`Delete workspace`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
Once you delete your workspace, there is no going back. This
action cannot be undone.
{t`Once you delete your workspace, there is no going back. This action cannot be undone.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_WORKSPACE")}
disabled={workspace.role !== "admin"}
>
Delete workspace
{t`Delete workspace`}
</Button>
</div>
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Delete account
{t`Delete account`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
Once you delete your account, there is no going back. This
action cannot be undone.
{t`Once you delete your account, there is no going back. This action cannot be undone.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("DELETE_ACCOUNT")}
>
Delete account
{t`Delete account`}
</Button>
</div>
</div>

64
cloud/docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
ports:
- "${WEB_PORT:-3000}:3000"
networks:
- dokploy-network
build:
context: ..
dockerfile: apps/web/Dockerfile
env_file:
- .env
restart: unless-stopped
environment:
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
# SMTP
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_SECURE=${SMTP_SECURE}
- EMAIL_FROM=${EMAIL_FROM}
# S3 storage
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# Auth config
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
# Integration providers
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
# OAuth providers (optional)
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
# Analytics
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
networks:
dokploy-network:
external: true
#bump

View File

@@ -1,31 +1,49 @@
version: "3.7"
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web}
ports:
- "3001:3000"
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
- dokploy-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
dockerfile: ./apps/web/Dockerfile.new
env_file:
- .env
command: ["pnpm", "start"]
environment:
- EMAIL_FROM=${EMAIL_FROM}
# Required environment variables
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
# SMTP (optional)
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- EMAIL_FROM=${EMAIL_FROM}
# S3 storage (optional)
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
# Auth config (optional)
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
# Integration providers (optional)
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
# OAuth providers (optional)
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
@@ -63,18 +81,29 @@ services:
- APPLE_CLIENT_ID=${APPLE_CLIENT_ID}
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT}
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
dokploy-network:
external: true
volumes:
kan_postgres_data:

View File

@@ -16,11 +16,14 @@ const emailTemplates: Record<Templates, React.FC> = {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
secure: process.env.SMTP_SECURE !== "false",
...(process.env.SMTP_USER &&
process.env.SMTP_PASSWORD && {
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
}),
});
export const sendEmail = async (
@@ -29,22 +32,34 @@ export const sendEmail = async (
template: Templates,
data: Record<string, string>,
) => {
const EmailTemplate = emailTemplates[template];
try {
const EmailTemplate = emailTemplates[template];
const html = await render(<EmailTemplate {...data} />, { pretty: true });
const html = await render(<EmailTemplate {...data} />, { pretty: true });
const options = {
from: process.env.EMAIL_FROM,
to,
subject,
html,
};
const options = {
from: process.env.EMAIL_FROM,
to,
subject,
html,
};
const response = await transporter.sendMail(options);
const response = await transporter.sendMail(options);
if (!response.accepted.length) {
throw new Error(`Failed to send email: ${response.response}`);
if (!response.accepted.length) {
throw new Error(`Failed to send email: ${response.response}`);
}
return response;
} catch (error) {
console.error("Email sending failed:", {
to,
from: process.env.EMAIL_FROM,
subject,
template,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
return response;
};

1855
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -91,6 +91,7 @@
"SMTP_PORT",
"SMTP_USER",
"SMTP_PASSWORD",
"SMTP_SECURE",
"NEXT_PUBLIC_KAN_ENV",
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
@@ -100,6 +101,7 @@
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
"NEXT_PUBLIC_ALLOW_CREDENTIALS",
"NEXT_PUBLIC_DISABLE_SIGN_UP",
"NEXT_PUBLIC_USE_STANDALONE_OUTPUT",
"S3_REGION",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",