Compare commits
169 Commits
fix/migrat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cdf44afc5b | |||
| 8f5bf806e1 | |||
| 0f57279c77 | |||
| ed5b8d1644 | |||
| 994bf5c7e0 | |||
| 61123f708d | |||
| 5a8a478386 | |||
|
|
02b367fcaa | ||
|
|
f9aaebc4b5 | ||
|
|
56af094c50 | ||
|
|
e9ebd06eaa | ||
|
|
b4e9dd3811 | ||
|
|
d6aa82e070 | ||
|
|
4291d7bc8c | ||
|
|
5c72968d44 | ||
|
|
5b586f4871 | ||
|
|
fee82e5e73 | ||
|
|
03a043784a | ||
|
|
395dbfae04 | ||
|
|
9ec4bedd9a | ||
|
|
ca694595c7 | ||
|
|
0500d5171d | ||
|
|
a15e193f66 | ||
|
|
019ecbc364 | ||
|
|
82b2b4a8e1 | ||
|
|
c1adb2d4dd | ||
|
|
25ed4e39b3 | ||
|
|
a994c061ac | ||
|
|
6e1d821b35 | ||
|
|
120504ab58 | ||
|
|
ebb19584a2 | ||
|
|
410f4c481e | ||
|
|
9d7a82d381 | ||
|
|
f369ebeaa8 | ||
|
|
0fbe198fa2 | ||
|
|
81b67df9e3 | ||
|
|
4f3b49716d | ||
|
|
1347cc562c | ||
|
|
2893e95597 | ||
|
|
89654c20b0 | ||
|
|
4442e67832 | ||
|
|
d7e8b5ae6b | ||
|
|
03e1e19464 | ||
|
|
a3353acf44 | ||
|
|
a4a807ed1d | ||
|
|
6b8ada8d90 | ||
|
|
22899cd14c | ||
|
|
833c69a73e | ||
|
|
d9cf363302 | ||
|
|
9212b1ca2e | ||
|
|
ede2475796 | ||
|
|
e32ad1cd32 | ||
|
|
bac22021ad | ||
|
|
8d8cfa3bae | ||
|
|
971348a397 | ||
|
|
d3920cd246 | ||
|
|
5af375825b | ||
|
|
861416a18e | ||
|
|
a7e78ad580 | ||
|
|
f288047a6a | ||
|
|
edb88525c0 | ||
|
|
d69051611c | ||
|
|
cf566e04c6 | ||
|
|
6ea622e7f7 | ||
|
|
0e7b7a072c | ||
|
|
f801d2a7eb | ||
|
|
d9157b6218 | ||
|
|
711ff7f610 | ||
|
|
742e8a1fab | ||
|
|
69ed5fd8f2 | ||
|
|
e21830138d | ||
|
|
f38533590d | ||
|
|
7d52e4eb88 | ||
|
|
692123433b | ||
|
|
8436644966 | ||
|
|
0c2b670baf | ||
|
|
584fe13244 | ||
|
|
cc6804c21d | ||
|
|
8512946102 | ||
|
|
6bebb14855 | ||
|
|
0adb68d547 | ||
|
|
e26c122d76 | ||
|
|
95321c1c0d | ||
|
|
f459c489f9 | ||
|
|
460008d340 | ||
|
|
4371fe2d4d | ||
|
|
60d8e1bd83 | ||
|
|
4459fbb4b2 | ||
|
|
507c553778 | ||
|
|
3d9e859b31 | ||
|
|
54dcd08d9a | ||
|
|
468aeef8b6 | ||
|
|
71205b67dc | ||
|
|
607672718c | ||
|
|
dd262ee50f | ||
|
|
82c8eef0c2 | ||
|
|
ea116268ff | ||
|
|
74384d841e | ||
|
|
c0962e9f70 | ||
|
|
3e6aec2d6b | ||
|
|
4658b2961c | ||
|
|
bc8b5463b3 | ||
|
|
9eee59a877 | ||
|
|
e32e400976 | ||
|
|
b4b07f3120 | ||
|
|
e39faa1172 | ||
|
|
b46979f539 | ||
|
|
f4237fffe6 | ||
|
|
1d9bb96367 | ||
|
|
bcb449e394 | ||
|
|
ae4bc77407 | ||
|
|
6056167063 | ||
|
|
adf91c7dfd | ||
|
|
bba9a94e45 | ||
|
|
e36eb64c59 | ||
|
|
201fe05493 | ||
|
|
25490b2223 | ||
|
|
b3108942db | ||
|
|
2ba759966e | ||
|
|
7045cd8e25 | ||
|
|
603cc46b7d | ||
|
|
e9d338384a | ||
|
|
8a2a9ecef5 | ||
|
|
3a44957662 | ||
|
|
20e6c99e32 | ||
|
|
c0aae5449f | ||
|
|
596f346181 | ||
|
|
7b59e2ac07 | ||
|
|
2a9b1fd2b5 | ||
|
|
a2d4314e14 | ||
|
|
f94e4d6da2 | ||
|
|
97f4defc0e | ||
|
|
dd29dc89fd | ||
|
|
486cdf8313 | ||
|
|
67cf523ee8 | ||
|
|
0b45ceff7f | ||
|
|
76b2c58461 | ||
|
|
b8d1fe230d | ||
|
|
bd652ba6c9 | ||
|
|
1fb56aafd0 | ||
|
|
91deaca6ec | ||
|
|
ab81db0f56 | ||
|
|
29080fa851 | ||
|
|
2360c5075d | ||
|
|
79c2b5f3d5 | ||
|
|
8e7a95ff2b | ||
|
|
94fd2cb11f | ||
|
|
2a8af514d1 | ||
|
|
0b405e0456 | ||
|
|
f1f3a00c17 | ||
|
|
0fb2bac102 | ||
|
|
a7b71ae764 | ||
|
|
a246514b0b | ||
|
|
53397d8e81 | ||
|
|
1f9f07df20 | ||
|
|
1d5e3a936c | ||
|
|
0b49f502a9 | ||
|
|
400dcec56d | ||
|
|
dfcdc5e47e | ||
|
|
32e77e0291 | ||
|
|
a6780e3e28 | ||
|
|
2c5195a2b8 | ||
|
|
793fa30162 | ||
|
|
af63719de5 | ||
|
|
52fd624b81 | ||
|
|
b4d4810f45 | ||
|
|
239d152340 | ||
|
|
f294760747 | ||
|
|
280d8f66dd |
@@ -60,3 +60,7 @@ pnpm-debug.log
|
|||||||
|
|
||||||
# Cloud compose (separate deployment)
|
# Cloud compose (separate deployment)
|
||||||
cloud/
|
cloud/
|
||||||
|
|
||||||
|
# Local temporary caches
|
||||||
|
.pnpm-store
|
||||||
|
v8-compile-cache-0
|
||||||
|
|||||||
11
.env.example
11
.env.example
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Required environment variables
|
# Required environment variables
|
||||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||||
|
|
||||||
# Fill if you want to use an external database
|
# Fill if you want to use an external database
|
||||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||||
@@ -23,17 +23,19 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
|||||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||||
|
|
||||||
# S3 storage (optional)
|
# S3 storage (optional)
|
||||||
S3_REGION=
|
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
|
||||||
S3_ENDPOINT=
|
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
|
||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
S3_FORCE_PATH_STYLE=
|
S3_FORCE_PATH_STYLE=
|
||||||
|
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||||
|
|
||||||
|
|
||||||
# Auth config (optional)
|
# Auth config (optional)
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
@@ -49,6 +51,9 @@ TRELLO_APP_SECRET=
|
|||||||
# If not provided, rate limiting will use in-memory storage
|
# If not provided, rate limiting will use in-memory storage
|
||||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
||||||
|
|
||||||
|
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||||
|
LOG_LEVEL=
|
||||||
|
|
||||||
# OAuth providers (optional)
|
# OAuth providers (optional)
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -7,6 +7,9 @@ assignees: ''
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Wait for a maintainer to approve this issue before opening a PR. We'll signal approval with a comment or label. Feature PRs without an approved issue will be closed.
|
||||||
|
|
||||||
## ✨ Feature Request
|
## ✨ Feature Request
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
|||||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- What does this PR do? Why? -->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature (requires an approved issue — see below)
|
||||||
|
- [ ] Refactor / chore
|
||||||
|
- [ ] Documentation
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I have linked the related issue below
|
||||||
|
- [ ] My code follows the existing style and conventions
|
||||||
|
- [ ] I have tested my changes locally
|
||||||
|
- [ ] I have included screenshots for any UI changes
|
||||||
|
|
||||||
|
## Linked issue
|
||||||
|
|
||||||
|
Closes #<!-- issue number -->
|
||||||
|
|
||||||
|
> **Feature PRs without a linked, approved issue will be closed without review.**
|
||||||
|
> Open or comment on a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) first and wait for maintainer approval before building.
|
||||||
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -6,8 +6,6 @@ name: Docker
|
|||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
|
||||||
- cron: "21 21 * * *"
|
|
||||||
push:
|
push:
|
||||||
# Only trigger on tags (releases), not main branch pushes
|
# Only trigger on tags (releases), not main branch pushes
|
||||||
# Main branch builds are handled by workflow_run after Translate completes
|
# Main branch builds are handled by workflow_run after Translate completes
|
||||||
@@ -84,7 +82,8 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||||
|
type=raw,value=edge,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Extract Docker metadata (migrate)
|
- name: Extract Docker metadata (migrate)
|
||||||
id: meta-migrate
|
id: meta-migrate
|
||||||
@@ -97,7 +96,8 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||||
|
type=raw,value=edge,enable={{is_default_branch}}
|
||||||
|
|
||||||
# Extract version from git tag or ref
|
# Extract version from git tag or ref
|
||||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,6 +4,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
.pnpm/
|
||||||
|
.pnpm-store/
|
||||||
|
v8-compile-cache-0/
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
coverage
|
coverage
|
||||||
@@ -54,3 +57,8 @@ i18n.cache
|
|||||||
|
|
||||||
# pgdata
|
# pgdata
|
||||||
/apps/web/pgdata
|
/apps/web/pgdata
|
||||||
|
|
||||||
|
# local scripts
|
||||||
|
scripts/
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|||||||
20
AGENTS.md
20
AGENTS.md
@@ -185,9 +185,17 @@ Use `assertUserInWorkspace` helper for workspace checks.
|
|||||||
|
|
||||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
||||||
- Provide user-friendly error messages
|
- Provide user-friendly error messages
|
||||||
- Log errors appropriately
|
- Log errors appropriately using the `@kan/logger` package
|
||||||
- Show popup notifications for user-facing errors
|
- Show popup notifications for user-facing errors
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
|
||||||
|
- Create a module-scoped logger: `const logger = createLogger("module-name")`
|
||||||
|
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
|
||||||
|
- Defaults to `debug` in development, `info` in production
|
||||||
|
- Never use `console.log` — always use the logger
|
||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
|
|
||||||
### Creating a Card
|
### Creating a Card
|
||||||
@@ -219,6 +227,16 @@ Use `assertUserInWorkspace` helper for workspace checks.
|
|||||||
5. **Frontend**: Add UI components in `apps/web/src/`
|
5. **Frontend**: Add UI components in `apps/web/src/`
|
||||||
6. **i18n**: Add translations for new strings
|
6. **i18n**: Add translations for new strings
|
||||||
|
|
||||||
|
## Adding a New Environment Variable
|
||||||
|
|
||||||
|
Update all of the following:
|
||||||
|
|
||||||
|
1. `.env.example` — add the variable with an empty value and a comment explaining it
|
||||||
|
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
|
||||||
|
3. `docker-compose.yml` — add to the `web` service `environment` section
|
||||||
|
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
|
||||||
|
5. `README.md` — add a row to the Environment Variables table
|
||||||
|
|
||||||
## Database Changes
|
## Database Changes
|
||||||
|
|
||||||
- Always create migrations (never modify existing migrations)
|
- Always create migrations (never modify existing migrations)
|
||||||
|
|||||||
@@ -1,52 +1,54 @@
|
|||||||
# Contributing to Kan
|
# Contributing to Kan
|
||||||
|
|
||||||
Thank you for your interest in contributing to Kan! This document provides guidelines and instructions for contributing to the project.
|
Thank you for your interest in contributing to Kan!
|
||||||
|
|
||||||
## Getting Started
|
## Before you start
|
||||||
|
|
||||||
|
**Open an issue before writing code for a new feature.**
|
||||||
|
|
||||||
|
Feature PRs opened without a prior approved issue will be closed without review - not because the idea is bad, but because we can't review code for features we haven't aligned on yet. This protects your time as much as ours.
|
||||||
|
|
||||||
|
The process is:
|
||||||
|
|
||||||
|
1. Open a [feature request](https://github.com/kanbn/kan/issues/new?template=feature_request.md) and describe what you want to build
|
||||||
|
2. Wait for a maintainer to respond and signal approval (we aim to respond within a few days)
|
||||||
|
3. Once approved, open a PR that links the issue
|
||||||
|
|
||||||
|
Bug fixes and documentation improvements don't need prior approval - just open a PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/kan.git`
|
2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/kan.git`
|
||||||
3. Create a new branch: `git checkout -b feat/your-feature-name`
|
3. Create a branch: `git checkout -b feat/your-feature-name` or `fix/your-fix-name`
|
||||||
4. Install dependencies: `pnpm install`
|
4. Install dependencies: `pnpm install`
|
||||||
5. Copy `.env.example` to `.env` and configure your environment variables
|
5. Copy `.env.example` to `.env` and configure your environment variables
|
||||||
6. Make your changes
|
6. Make your changes
|
||||||
7. Commit your changes: `git commit -m "feat: description of changes"`
|
7. Commit using [conventional commits](https://www.conventionalcommits.org/): `git commit -m "feat: description"`
|
||||||
8. Push to your fork: `git push origin feat/your-feature-name`
|
8. Push and open a Pull Request
|
||||||
9. Open a Pull Request
|
|
||||||
|
|
||||||
## Development Guidelines
|
## Pull request expectations
|
||||||
|
|
||||||
### Code Style
|
- Link the related issue (required for features)
|
||||||
|
- Write a clear description of what changed and why
|
||||||
|
- Include screenshots for any UI changes
|
||||||
|
- Keep PRs focused - one feature or fix per PR
|
||||||
|
- All CI checks must pass before review
|
||||||
|
|
||||||
- Follow the existing code style
|
## Code style
|
||||||
- Use meaningful variable and function names
|
|
||||||
- Add comments for complex logic
|
- Follow the existing patterns in the codebase
|
||||||
|
- Use meaningful names - avoid comments that just restate what the code does
|
||||||
- Keep functions focused and concise
|
- Keep functions focused and concise
|
||||||
|
|
||||||
### Commits
|
## Need help?
|
||||||
|
|
||||||
- Use clear and descriptive commit messages
|
- Join our [Discord server](https://discord.gg/e6ejRb6CmT)
|
||||||
- Reference issue numbers when applicable
|
- Check existing issues and PRs before opening new ones
|
||||||
- Keep commits focused on single changes
|
|
||||||
|
|
||||||
### Pull Requests
|
|
||||||
|
|
||||||
- Provide a clear description of the changes
|
|
||||||
- Include screenshots for UI changes
|
|
||||||
- Keep PRs focused on a single feature/fix
|
|
||||||
|
|
||||||
## Need Help?
|
|
||||||
|
|
||||||
- Join our [Discord server](https://discord.gg/e6ejRb6CmT) for questions
|
|
||||||
- Check existing issues and pull requests
|
|
||||||
- Email [henry@kan.bn](mailto:henry@kan.bn) for major concerns
|
- Email [henry@kan.bn](mailto:henry@kan.bn) for major concerns
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
|
|
||||||
|
|
||||||
We aim to foster an inclusive and welcoming community. Harassment and abusive behavior will not be tolerated.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
By contributing to Kan, you agree that your contributions will be licensed under the AGPLv3 License.
|
By contributing to Kan, you agree that your contributions will be licensed under the AGPLv3 License.
|
||||||
|
|||||||
447
README.md
447
README.md
@@ -1,234 +1,315 @@
|
|||||||

|
# Kan
|
||||||
|
|
||||||
<div align="center">
|
Kan 是一个开源的看板式项目管理工具,可作为 Trello 的自托管替代方案。它支持工作区、看板、列表、卡片、标签、成员、评论、检查清单和活动记录。
|
||||||
<h3 align="center">Kan</h3>
|
|
||||||
<p>The open-source project management alternative to Trello.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p align="center">
|
Kan 使用 AGPLv3 协议发布。
|
||||||
<a href="https://kan.bn/kan/roadmap">Roadmap</a>
|
|
||||||
·
|
|
||||||
<a href="https://kan.bn">Website</a>
|
|
||||||
·
|
|
||||||
<a href="https://docs.kan.bn">Docs</a>
|
|
||||||
·
|
|
||||||
<a href="https://discord.gg/e6ejRb6CmT">Discord</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div align="center">
|
- [项目主页](https://kan.bn)
|
||||||
<a href="https://github.com/kanbn/kan/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPLv3-purple"></a>
|
- [在线文档](https://docs.kan.bn)
|
||||||
</div>
|
- [路线图](https://kan.bn/kan/roadmap)
|
||||||
|
- [Discord 社区](https://discord.gg/e6ejRb6CmT)
|
||||||
|
|
||||||
## Features 💫
|

|
||||||
|
|
||||||
- 👁️ **Board Visibility**: Control who can view and edit your boards
|
## 功能
|
||||||
- 🤝 **Workspace Members**: Invite members and collaborate with your team
|
|
||||||
- 🚀 **Trello Imports**: Easily import your Trello boards
|
|
||||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
|
||||||
- 💬 **Comments**: Discuss and collaborate with your team
|
|
||||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
|
||||||
- 🎨 **Templates** : Save time with reusable custom board templates
|
|
||||||
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
|
||||||
|
|
||||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
- 看板可见性控制
|
||||||
|
- 工作区成员和协作
|
||||||
|
- Trello 看板导入
|
||||||
|
- 卡片标签和筛选
|
||||||
|
- 评论、活动记录和检查清单
|
||||||
|
- 可复用的看板模板
|
||||||
|
- 卡片截止日期、成员和附件
|
||||||
|
- 内置 REST API 和 MCP 服务
|
||||||
|
|
||||||
## Screenshot 👁️
|
## 使用 Docker Compose 自托管
|
||||||
|
|
||||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/8490104a-cd5d-49de-afc2-152fd8a93119" />
|
### 环境要求
|
||||||
|
|
||||||
## Made With 🛠️
|
- Docker Engine
|
||||||
|
- Docker Compose v2
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/?ref=kan.bn)
|
### 启动
|
||||||
- [tRPC](https://trpc.io/?ref=kan.bn)
|
|
||||||
- [Better Auth](https://better-auth.com/?ref=kan.bn)
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/?ref=kan.bn)
|
|
||||||
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
|
||||||
- [React Email](https://react.email/?ref=kan.bn)
|
|
||||||
|
|
||||||
## Self Hosting 🐳
|
在仓库根目录执行:
|
||||||
|
|
||||||
### One-click Deployments
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
|
编辑 `.env`,至少设置以下变量:
|
||||||
|
|
||||||
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
```dotenv
|
||||||
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
</a>
|
POSTGRES_PASSWORD=请填写数据库密码
|
||||||
|
POSTGRES_URL=postgresql://kan:请填写数据库密码@postgres:5432/kan_db
|
||||||
|
BETTER_AUTH_SECRET=请填写一个随机的 32 位以上密钥
|
||||||
|
```
|
||||||
|
|
||||||
### Docker Compose
|
构建本地镜像并启动:
|
||||||
|
|
||||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
仓库自带的 `docker-compose.yml` 使用本地镜像标签:
|
||||||
|
|
||||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
migrate:
|
migrate:
|
||||||
image: ghcr.io/kanbn/kan-migrate:latest
|
image: kan-migrate:local
|
||||||
container_name: kan-migrate
|
build:
|
||||||
networks:
|
context: .
|
||||||
- kan-network
|
dockerfile: ./apps/web/Dockerfile
|
||||||
environment:
|
target: migrate
|
||||||
- POSTGRES_URL=${POSTGRES_URL}
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: "no"
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: kan:local
|
||||||
container_name: kan-web
|
build:
|
||||||
ports:
|
context: .
|
||||||
- "${WEB_PORT:-3000}:3000"
|
dockerfile: ./apps/web/Dockerfile
|
||||||
networks:
|
target: web
|
||||||
- kan-network
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
|
||||||
- POSTGRES_URL=${POSTGRES_URL}
|
|
||||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
|
||||||
depends_on:
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:15
|
|
||||||
container_name: kan-db
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=kan_db
|
|
||||||
- POSTGRES_USER=kan
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
volumes:
|
|
||||||
- kan_postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- kan-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
kan-network:
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
kan_postgres_data:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the containers in detached mode:
|
不会拉取远端 Web 镜像。数据库迁移会在 Web 服务启动前自动执行。启动完成后访问 <http://localhost:3000>。如修改端口,请同步修改 `WEB_PORT` 和 `NEXT_PUBLIC_BASE_URL`。
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
# 查看状态
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 查看 Web 日志
|
||||||
|
docker compose logs -f web
|
||||||
|
|
||||||
|
# 查看迁移日志
|
||||||
|
docker compose logs -f migrate
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 停止服务并删除数据库卷(会删除本地数据)
|
||||||
|
docker compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
|
检查 API 健康状态:
|
||||||
|
|
||||||
**Managing containers:**
|
```bash
|
||||||
|
curl http://localhost:3000/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
- To stop the containers: `docker compose down`
|
预期返回:
|
||||||
- To view logs: `docker compose logs -f`
|
|
||||||
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
|
||||||
- To restart the containers: `docker compose restart`
|
|
||||||
- To rebuild after code changes: `docker compose up -d --build`
|
|
||||||
|
|
||||||
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
```json
|
||||||
|
{ "status": "ok", "database": "ok", "storage": "not_configured" }
|
||||||
|
```
|
||||||
|
|
||||||
## Local Development 🧑💻
|
默认文件存储状态为 `not_configured`。如需上传头像和附件,请按照 `.env.example` 配置 S3 兼容存储。
|
||||||
|
|
||||||
1. Clone the repository (or fork)
|
## 本地开发
|
||||||
|
|
||||||
|
项目是 pnpm monorepo,需要 Node.js 20.18.1 或更高版本,以及 pnpm 9.14.2。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/kanbn/kan.git
|
git clone https://github.com/kanbn/kan.git
|
||||||
```
|
cd kan
|
||||||
|
|
||||||
2. Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
cp .env.example .env
|
||||||
|
|
||||||
3. Copy `.env.example` to `.env` and configure your environment variables
|
|
||||||
4. Migrate database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
```
|
|
||||||
|
|
||||||
5. Start the development server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables 🔐
|
开发服务器默认地址为 <http://localhost:3000>。常用检查命令:
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
```bash
|
||||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
pnpm typecheck
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
pnpm lint
|
||||||
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
pnpm format
|
||||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
```
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
|
||||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
|
||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
|
||||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
|
||||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
|
||||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
|
||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
|
||||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
|
||||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
|
||||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
|
||||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
|
||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
|
||||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
|
||||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
|
||||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
|
||||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
|
||||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
|
||||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
|
||||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
## 环境变量
|
||||||
|
|
||||||
## Contributing 🤝
|
完整变量列表见 [`.env.example`](.env.example)。
|
||||||
|
|
||||||
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
| 变量 | 用途 | 示例 |
|
||||||
|
| ------------------------------- | ---------------------------------- | -------------------------------------------- |
|
||||||
|
| `NEXT_PUBLIC_BASE_URL` | 当前 Kan 实例的访问地址 | `http://localhost:3000` |
|
||||||
|
| `POSTGRES_URL` | PostgreSQL 连接地址 | `postgresql://kan:密码@postgres:5432/kan_db` |
|
||||||
|
| `POSTGRES_PASSWORD` | Compose 创建 PostgreSQL 使用的密码 | `change-me` |
|
||||||
|
| `BETTER_AUTH_SECRET` | 登录会话加密密钥 | 随机 32 位以上字符串 |
|
||||||
|
| `WEB_PORT` | 宿主机映射端口 | `3000` |
|
||||||
|
| `REDIS_URL` | 可选的限流 Redis 地址 | `redis://redis:6379` |
|
||||||
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | 是否允许账号密码登录 | `true` |
|
||||||
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | 是否关闭注册 | `false` |
|
||||||
|
| `KAN_ADMIN_API_KEY` | 管理和监控接口密钥 | 自定义密钥 |
|
||||||
|
|
||||||
## Contributors 👥
|
## MCP 服务
|
||||||
|
|
||||||
<a href="https://github.com/kanbn/kan/graphs/contributors">
|
Kan 仓库内置了基于 Model Context Protocol 的 MCP 服务。连接后,Claude Desktop、Cursor、VS Code、Codex 等 MCP 客户端可以通过自然语言读取和管理 Kan 数据。
|
||||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## License 📝
|
### 先看这里
|
||||||
|
|
||||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
MCP 源码位于 `packages/mcp`,目前是仓库内的工作区包,并未发布为 npm 公共包。因此以下命令不能使用:
|
||||||
|
|
||||||
## Contact 📧
|
```bash
|
||||||
|
npx -y @kan/mcp
|
||||||
|
bunx kan-mcp
|
||||||
|
```
|
||||||
|
|
||||||
For support or to get in touch, please email [henry@kan.bn](mailto:henry@kan.bn) or join our [Discord server](https://discord.gg/e6ejRb6CmT).
|
请从当前仓库构建 MCP 服务,再让客户端启动生成的文件。
|
||||||
|
|
||||||
|
### 获取 API 密钥
|
||||||
|
|
||||||
|
1. 登录 Kan。
|
||||||
|
2. 打开设置中的 API 密钥页面,地址通常为 `/settings/api`。
|
||||||
|
3. 创建一个 API 密钥并立即保存。密钥只会完整显示一次。
|
||||||
|
|
||||||
|
MCP 使用用户 API 密钥,不是 `KAN_ADMIN_API_KEY`。请求会以 Bearer Token 方式访问 `${KAN_BASE_URL}/api/v1`。
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @kan/mcp build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物为 `packages/mcp/dist/index.js`。MCP 服务使用 stdio 通信,应由 MCP 客户端启动,不需要单独暴露 HTTP 端口。
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| --------------- | ---------------------------------- |
|
||||||
|
| `KAN_BASE_URL` | Kan 实例根地址,不要追加 `/api/v1` |
|
||||||
|
| `KAN_API_TOKEN` | Kan 设置中创建的用户 API 密钥 |
|
||||||
|
|
||||||
|
本地 Kan 使用 Docker Compose 时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
KAN_BASE_URL=http://localhost:3000
|
||||||
|
KAN_API_TOKEN=kan_你的_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Desktop 和 Cursor
|
||||||
|
|
||||||
|
Claude Desktop 使用 `claude_desktop_config.json`,Cursor 使用项目内的 `.cursor/mcp.json`。将 `args` 中的路径替换为本机仓库的绝对路径:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"kan": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/绝对路径/kan/packages/mcp/dist/index.js"],
|
||||||
|
"env": {
|
||||||
|
"KAN_BASE_URL": "http://localhost:3000",
|
||||||
|
"KAN_API_TOKEN": "kan_你的_api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 路径示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"kan": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["C:\\workspace\\kan\\packages\\mcp\\dist\\index.js"],
|
||||||
|
"env": {
|
||||||
|
"KAN_BASE_URL": "http://localhost:3000",
|
||||||
|
"KAN_API_TOKEN": "kan_你的_api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
VS Code 的 `mcp.json` 使用 `servers` 作为顶层键,并增加 `type`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"kan": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/绝对路径/kan/packages/mcp/dist/index.js"],
|
||||||
|
"env": {
|
||||||
|
"KAN_BASE_URL": "http://localhost:3000",
|
||||||
|
"KAN_API_TOKEN": "kan_你的_api_key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
Codex 可通过命令添加 stdio MCP 服务。将脚本路径替换为本机绝对路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex mcp add kan \
|
||||||
|
--env KAN_BASE_URL=http://localhost:3000 \
|
||||||
|
--env KAN_API_TOKEN=kan_你的_api_key \
|
||||||
|
-- node /绝对路径/kan/packages/mcp/dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
codex mcp add kan `
|
||||||
|
--env KAN_BASE_URL=http://localhost:3000 `
|
||||||
|
--env KAN_API_TOKEN=kan_你的_api_key `
|
||||||
|
-- node C:\workspace\kan\packages\mcp\dist\index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `codex mcp get kan` 检查配置。
|
||||||
|
|
||||||
|
### 手动启动和排错
|
||||||
|
|
||||||
|
Linux 或 macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KAN_BASE_URL=http://localhost:3000 \
|
||||||
|
KAN_API_TOKEN=kan_你的_api_key \
|
||||||
|
node packages/mcp/dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:KAN_BASE_URL = "http://localhost:3000"
|
||||||
|
$env:KAN_API_TOKEN = "kan_你的_api_key"
|
||||||
|
node .\packages\mcp\dist\index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
如果出现 `Connection closed`,检查以下内容:
|
||||||
|
|
||||||
|
1. 已执行 `pnpm --filter @kan/mcp build`。
|
||||||
|
2. `args` 指向真实存在的 `packages/mcp/dist/index.js`。
|
||||||
|
3. `KAN_BASE_URL` 只填写实例根地址。
|
||||||
|
4. `KAN_API_TOKEN` 是 Kan 用户 API 密钥。
|
||||||
|
5. `http://localhost:3000/api/v1/health` 返回数据库正常。
|
||||||
|
|
||||||
|
### 工具范围
|
||||||
|
|
||||||
|
当前 MCP 服务提供 46 个工具,覆盖工作区、看板、列表、卡片、评论、标签、检查清单和成员管理。连接后可以直接提出以下请求:
|
||||||
|
|
||||||
|
- 列出我的工作区。
|
||||||
|
- 查看研发工作区中的所有看板。
|
||||||
|
- 创建一个包含待办、进行中、已完成列表的新看板。
|
||||||
|
- 将登录问题卡片移动到进行中列表。
|
||||||
|
- 给版本发布卡片添加检查清单和评论。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
Next.js、React、tRPC、Better Auth、Tailwind CSS、Drizzle ORM、PostgreSQL 和 Model Context Protocol SDK。
|
||||||
|
|
||||||
|
## 参与贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request。提交代码前请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),并运行类型检查、Lint 和格式检查。
|
||||||
|
|
||||||
|
## 许可证与联系
|
||||||
|
|
||||||
|
项目使用 [AGPLv3](LICENSE) 协议。如需支持,请发送邮件至 [henry@kan.bn](mailto:henry@kan.bn),或加入 [Discord 社区](https://discord.gg/e6ejRb6CmT)。
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ https://kan.bn/api/v1
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
|
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request.
|
||||||
|
|
||||||
```
|
```
|
||||||
'x-api-key': kan_123456789
|
'Authorization': 'Bearer kan_123456789'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response codes
|
## Response codes
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ RUN apk update && \
|
|||||||
apk add --no-cache libc6-compat && \
|
apk add --no-cache libc6-compat && \
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||||
corepack enable && \
|
corepack enable && \
|
||||||
|
corepack prepare pnpm@9.14.2 --activate && \
|
||||||
npm install turbo@2.3.1 --global && \
|
npm install turbo@2.3.1 --global && \
|
||||||
pnpm config set store-dir ~/.pnpm-store
|
pnpm config set store-dir ~/.pnpm-store
|
||||||
|
|
||||||
@@ -109,13 +110,15 @@ ENV PORT=3000
|
|||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
# Copy the standalone Next.js server
|
# Copy the standalone Next.js server
|
||||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
|
||||||
# Copy static assets and public files
|
# Copy static assets and public files
|
||||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||||
|
|
||||||
# Copy bootstrap script for runtime env var injection
|
# Copy bootstrap script for runtime env var injection
|
||||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||||
|
|
||||||
|
USER 1000:1000
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["bootstrap.cjs"]
|
CMD ["bootstrap.cjs"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": "1.10",
|
"version": "1.10",
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR", "zh-CN"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"json": {
|
"json": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { formatter } from "@lingui/format-json";
|
|||||||
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR", "zh-CN"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const config = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@kan/api",
|
"@kan/api",
|
||||||
@@ -65,6 +66,8 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
serverExternalPackages: ["pino"],
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
// instrumentationHook: true,
|
// instrumentationHook: true,
|
||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
|
|||||||
@@ -27,12 +27,13 @@
|
|||||||
"@kan/api": "workspace:*",
|
"@kan/api": "workspace:*",
|
||||||
"@kan/auth": "workspace:*",
|
"@kan/auth": "workspace:*",
|
||||||
"@kan/db": "workspace:^",
|
"@kan/db": "workspace:^",
|
||||||
|
"@kan/email": "workspace:^",
|
||||||
|
"@kan/logger": "workspace:^",
|
||||||
"@kan/shared": "workspace:^",
|
"@kan/shared": "workspace:^",
|
||||||
"@lingui/babel-preset-react": "^2.9.2",
|
"@lingui/babel-preset-react": "^2.9.2",
|
||||||
"@lingui/conf": "^5.3.2",
|
"@lingui/conf": "^5.3.2",
|
||||||
"@lingui/macro": "^5.3.2",
|
"@lingui/macro": "^5.3.2",
|
||||||
"@lingui/react": "^5.3.2",
|
"@lingui/react": "^5.3.2",
|
||||||
"@novu/api": "^3.11.0",
|
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
|
|||||||
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
39
apps/web/public/manifest.json
Normal file
39
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"background_color": "#000000",
|
||||||
|
"categories": [
|
||||||
|
"business",
|
||||||
|
"productivity",
|
||||||
|
"utilities"
|
||||||
|
],
|
||||||
|
"description": "The open source Trello alternative.",
|
||||||
|
"display": "standalone",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"sizes": "512x512",
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "/",
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"sizes": "1624x1561",
|
||||||
|
"src": "/screenshot-wide.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sizes": "1290x2796",
|
||||||
|
"src": "/screenshot-narrow.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"name": "Kan.bn | The open source alternative to Trello",
|
||||||
|
"scope": "/",
|
||||||
|
"short_name": "Kan.bn",
|
||||||
|
"start_url": "/",
|
||||||
|
"theme_color": "#000000"
|
||||||
|
}
|
||||||
BIN
apps/web/public/screenshot-narrow.png
Normal file
BIN
apps/web/public/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/web/public/screenshot-wide.png
Normal file
BIN
apps/web/public/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -46,6 +46,7 @@ interface FormValues {
|
|||||||
interface AuthProps {
|
interface AuthProps {
|
||||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||||
isSignUp?: boolean;
|
isSignUp?: boolean;
|
||||||
|
callbackURL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmailSchema = z.object({
|
const EmailSchema = z.object({
|
||||||
@@ -152,7 +153,11 @@ const availableSocialProviders = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
export function Auth({
|
||||||
|
setIsMagicLinkSent,
|
||||||
|
isSignUp,
|
||||||
|
callbackURL: callbackURLProp,
|
||||||
|
}: AuthProps) {
|
||||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||||
useState<null | AuthProvider>(null);
|
useState<null | AuthProvider>(null);
|
||||||
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const redirect = useSearchParams().get("next");
|
const redirect = useSearchParams().get("next");
|
||||||
const callbackURL = redirect ?? "/boards";
|
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
|
||||||
|
|
||||||
// Safely get environment variables on client side to avoid hydration mismatch
|
// Safely get environment variables on client side to avoid hydration mismatch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type SearchResult =
|
|||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
boardName: string;
|
boardName: string;
|
||||||
listName: string;
|
listName: string;
|
||||||
|
cardNumber: number | null;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
type: "card";
|
type: "card";
|
||||||
@@ -145,7 +146,6 @@ export default function CommandPallette({
|
|||||||
value={result}
|
value={result}
|
||||||
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("clicked", url);
|
|
||||||
void router.push(url);
|
void router.push(url);
|
||||||
onClose();
|
onClose();
|
||||||
setQuery("");
|
setQuery("");
|
||||||
@@ -160,8 +160,17 @@ export default function CommandPallette({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
<div className="flex items-center gap-2">
|
||||||
{result.title}
|
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
{result.type === "card" &&
|
||||||
|
result.cardNumber != null &&
|
||||||
|
workspace.cardPrefix && (
|
||||||
|
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
|
||||||
|
{workspace.cardPrefix}-{result.cardNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{result.type === "card" && (
|
{result.type === "card" && (
|
||||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +14,12 @@ import { authClient } from "@kan/auth/client";
|
|||||||
|
|
||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
||||||
|
import Button from "./Button";
|
||||||
|
import Modal from "./modal";
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -41,8 +48,11 @@ export default function Dashboard({
|
|||||||
hasRightPanel = false,
|
hasRightPanel = false,
|
||||||
}: DashboardProps) {
|
}: DashboardProps) {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { openModal } = useModal();
|
const { openModal, closeModal, modalContentType } = useModal();
|
||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||||
@@ -97,10 +107,65 @@ export default function Dashboard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
const partnerActivated = searchParams.get("partner_activated");
|
||||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
const partnerError = searchParams.get("partner_error");
|
||||||
|
|
||||||
|
if (partnerActivated) {
|
||||||
|
showPopup({
|
||||||
|
header: t`License activated`,
|
||||||
|
message: t`Your license has been activated successfully.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete("partner_activated");
|
||||||
|
router.replace(`?${params.toString()}`);
|
||||||
|
} else if (partnerError) {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
invalid_license: t`That license key could not be found. Please contact support.`,
|
||||||
|
license_inactive: t`Your license is not active. Please check your account.`,
|
||||||
|
missing_license: t`No license key was provided. Please try activating again.`,
|
||||||
|
};
|
||||||
|
showPopup({
|
||||||
|
header: t`License activation failed`,
|
||||||
|
message:
|
||||||
|
messages[partnerError] ??
|
||||||
|
t`Something went wrong during license activation.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete("partner_error");
|
||||||
|
router.replace(`?${params.toString()}`);
|
||||||
}
|
}
|
||||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
}, [searchParams, showPopup, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||||
|
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||||
|
router.push(
|
||||||
|
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isCredentialsEnabled =
|
||||||
|
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||||
|
|
||||||
|
if (
|
||||||
|
!userLoading &&
|
||||||
|
user &&
|
||||||
|
isCredentialsEnabled &&
|
||||||
|
user.hasMagicLinkAccount &&
|
||||||
|
!user.hasPassword &&
|
||||||
|
!sessionStorage.getItem("set_password_prompted")
|
||||||
|
) {
|
||||||
|
sessionStorage.setItem("set_password_prompted", "1");
|
||||||
|
openModal("SET_PASSWORD_PROMPT");
|
||||||
|
}
|
||||||
|
}, [user, userLoading, openModal]);
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
@@ -196,6 +261,30 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
modalSize="sm"
|
||||||
|
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
|
||||||
|
>
|
||||||
|
{user?.hasPassword ? (
|
||||||
|
<div className="p-5">
|
||||||
|
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
|
||||||
|
<p className="mb-6 text-sm text-light-900">
|
||||||
|
{t`Your account already has a password. You can change it from your account settings.`}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={closeModal}
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{t`Close`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChangePasswordFormConfirmation hasPassword={false} />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import { twMerge } from "tailwind-merge";
|
|||||||
interface DateSelectorProps {
|
interface DateSelectorProps {
|
||||||
selectedDate?: Date | null;
|
selectedDate?: Date | null;
|
||||||
onDateSelect?: (date: Date | undefined) => void;
|
onDateSelect?: (date: Date | undefined) => void;
|
||||||
|
weekStartsOn?: 0 | 1 | 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
const DateSelector = ({
|
||||||
|
selectedDate,
|
||||||
|
onDateSelect,
|
||||||
|
weekStartsOn = 1,
|
||||||
|
}: DateSelectorProps) => {
|
||||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||||
});
|
});
|
||||||
@@ -28,18 +33,18 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
|||||||
const year = format(currentMonth, "yyyy");
|
const year = format(currentMonth, "yyyy");
|
||||||
|
|
||||||
const dayHeaders = useMemo(() => {
|
const dayHeaders = useMemo(() => {
|
||||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
const weekStart = startOfWeek(new Date(), { weekStartsOn });
|
||||||
return eachDayOfInterval({
|
return eachDayOfInterval({
|
||||||
start: weekStart,
|
start: weekStart,
|
||||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||||
}, []);
|
}, [weekStartsOn]);
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(() => {
|
||||||
const monthStart = startOfMonth(currentMonth);
|
const monthStart = startOfMonth(currentMonth);
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
|
||||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||||
|
|
||||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||||
(date) => {
|
(date) => {
|
||||||
@@ -53,7 +58,7 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [currentMonth, selectedDate]);
|
}, [currentMonth, selectedDate, weekStartsOn]);
|
||||||
|
|
||||||
const handlePreviousMonth = () => {
|
const handlePreviousMonth = () => {
|
||||||
setCurrentMonth(subMonths(currentMonth, 1));
|
setCurrentMonth(subMonths(currentMonth, 1));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { t } from "@lingui/core/macro";
|
|||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import Mention from "@tiptap/extension-mention";
|
import Mention from "@tiptap/extension-mention";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import Typography from "@tiptap/extension-typography";
|
||||||
import {
|
import {
|
||||||
BubbleMenu,
|
BubbleMenu,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
ReactRenderer,
|
ReactRenderer,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import Typography from "@tiptap/extension-typography";
|
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import {
|
import {
|
||||||
@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
|
|||||||
export interface WorkspaceMember {
|
export interface WorkspaceMember {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -180,13 +180,13 @@ const RenderSuggestions = () => {
|
|||||||
|
|
||||||
if (!props.clientRect) return;
|
if (!props.clientRect) return;
|
||||||
|
|
||||||
popup[0]?.setProps({
|
popup?.[0]?.setProps({
|
||||||
getReferenceClientRect: props.clientRect,
|
getReferenceClientRect: props.clientRect,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onKeyDown(props: SuggestionKeyDownProps): boolean {
|
onKeyDown(props: SuggestionKeyDownProps): boolean {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
popup[0]?.hide();
|
popup?.[0]?.hide();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ const RenderSuggestions = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onExit() {
|
onExit() {
|
||||||
popup[0]?.destroy();
|
popup?.[0]?.destroy();
|
||||||
reactRenderer.destroy();
|
reactRenderer.destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -308,11 +308,11 @@ const renderMentionSuggestions = () => {
|
|||||||
onUpdate(props: any) {
|
onUpdate(props: any) {
|
||||||
reactRenderer.updateProps(props);
|
reactRenderer.updateProps(props);
|
||||||
if (!props.clientRect) return;
|
if (!props.clientRect) return;
|
||||||
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
||||||
},
|
},
|
||||||
onKeyDown(props: SuggestionKeyDownProps) {
|
onKeyDown(props: SuggestionKeyDownProps) {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
popup[0]?.hide();
|
popup?.[0]?.hide();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -324,7 +324,7 @@ const renderMentionSuggestions = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onExit() {
|
onExit() {
|
||||||
popup[0]?.destroy();
|
popup?.[0]?.destroy();
|
||||||
reactRenderer.destroy();
|
reactRenderer.destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -440,6 +440,7 @@ export default function Editor({
|
|||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
onSubmit,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
enableYouTubeEmbed = true,
|
enableYouTubeEmbed = true,
|
||||||
@@ -449,6 +450,7 @@ export default function Editor({
|
|||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
workspaceMembers: WorkspaceMember[];
|
||||||
enableYouTubeEmbed?: boolean;
|
enableYouTubeEmbed?: boolean;
|
||||||
@@ -457,6 +459,21 @@ export default function Editor({
|
|||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// useEditor is created once (empty deps below), so keep the latest callbacks
|
||||||
|
// in refs to avoid the editor capturing stale closures on re-render.
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const onBlurRef = useRef(onBlur);
|
||||||
|
const onSubmitRef = useRef(onSubmit);
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
onBlurRef.current = onBlur;
|
||||||
|
}, [onBlur]);
|
||||||
|
useEffect(() => {
|
||||||
|
onSubmitRef.current = onSubmit;
|
||||||
|
}, [onSubmit]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -474,12 +491,12 @@ export default function Editor({
|
|||||||
autolink: true,
|
autolink: true,
|
||||||
linkOnPaste: true,
|
linkOnPaste: true,
|
||||||
}),
|
}),
|
||||||
Markdown,
|
Markdown.configure({ transformPastedText: true }),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: readOnly
|
placeholder: readOnly
|
||||||
? ""
|
? ""
|
||||||
: placeholder ??
|
: (placeholder ??
|
||||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
t`Add description... (type '/' to open commands or '@' to mention)`),
|
||||||
}),
|
}),
|
||||||
SlashCommands.configure({
|
SlashCommands.configure({
|
||||||
commandItems: getCommandItems(disableHeadings),
|
commandItems: getCommandItems(disableHeadings),
|
||||||
@@ -497,24 +514,26 @@ export default function Editor({
|
|||||||
suggestion: {
|
suggestion: {
|
||||||
char: "@",
|
char: "@",
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
const withEmail = workspaceMembers.filter((member) => member.email);
|
const withEmail = workspaceMembers.filter(
|
||||||
|
(member) => member.email,
|
||||||
|
);
|
||||||
|
|
||||||
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||||
id: member.publicId,
|
id: member.publicId,
|
||||||
label: member?.user?.name?.trim() || member.email || "",
|
label: member?.user?.name?.trim() || member.email || "",
|
||||||
image: member?.user?.image ?? null,
|
image: member?.user?.image ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const all: MentionItem[] = mapped.filter(
|
const all: MentionItem[] = mapped.filter(
|
||||||
(item) => item.label && item.label.length > 0,
|
(item) => item.label && item.label.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const q = query.toLowerCase().trim();
|
const q = query.toLowerCase().trim();
|
||||||
|
|
||||||
if (q === "") {
|
if (q === "") {
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = all.filter((u) =>
|
const filtered = all.filter((u) =>
|
||||||
u.label.toLowerCase().includes(q),
|
u.label.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
@@ -540,20 +559,20 @@ export default function Editor({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Typography.configure({
|
Typography.configure({
|
||||||
openDoubleQuote: false,
|
openDoubleQuote: false,
|
||||||
closeDoubleQuote: false,
|
closeDoubleQuote: false,
|
||||||
openSingleQuote: false,
|
openSingleQuote: false,
|
||||||
closeSingleQuote: false,
|
closeSingleQuote: false,
|
||||||
oneHalf: false,
|
oneHalf: false,
|
||||||
oneQuarter: false,
|
oneQuarter: false,
|
||||||
threeQuarters: false,
|
threeQuarters: false,
|
||||||
superscriptTwo: false,
|
superscriptTwo: false,
|
||||||
superscriptThree: false,
|
superscriptThree: false,
|
||||||
}),
|
}),
|
||||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getHTML()),
|
||||||
onBlur: ({ event }) => {
|
onBlur: ({ event }) => {
|
||||||
if (
|
if (
|
||||||
document
|
document
|
||||||
@@ -563,13 +582,20 @@ export default function Editor({
|
|||||||
return;
|
return;
|
||||||
// Only trigger onBlur if the click was outside both the editor and menu
|
// Only trigger onBlur if the click was outside both the editor and menu
|
||||||
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
|
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
|
||||||
onBlur?.();
|
onBlurRef.current?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: "outline-none focus:outline-none focus-visible:ring-0",
|
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||||
},
|
},
|
||||||
|
handleKeyDown: (_view, event) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||||
|
onSubmitRef.current?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
editable: !readOnly,
|
editable: !readOnly,
|
||||||
injectCSS: false,
|
injectCSS: false,
|
||||||
@@ -587,6 +613,16 @@ export default function Editor({
|
|||||||
}
|
}
|
||||||
}, [content, editor]);
|
}, [content, editor]);
|
||||||
|
|
||||||
|
// useEditor captures `readOnly` once at creation time (empty deps above), so
|
||||||
|
// explicitly sync `editable` when the prop changes. Without this the editor
|
||||||
|
// gets stuck read-only when `readOnly` flips from true to false after mount
|
||||||
|
// (e.g. card permissions resolving slower than the card data on first load).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (editor.isEditable === !readOnly) return;
|
||||||
|
editor.setEditable(!readOnly);
|
||||||
|
}, [editor, readOnly]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
|
|||||||
32
apps/web/src/components/FontSizeSelector.tsx
Normal file
32
apps/web/src/components/FontSizeSelector.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { useFontSize, type FontSize } from "~/providers/font-size";
|
||||||
|
|
||||||
|
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
|
||||||
|
{ value: "small", label: () => t`Small` },
|
||||||
|
{ value: "medium", label: () => t`Medium` },
|
||||||
|
{ value: "large", label: () => t`Large` },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FontSizeSelector() {
|
||||||
|
const { fontSize, setFontSize } = useFontSize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<select
|
||||||
|
id="font-size-select"
|
||||||
|
value={fontSize}
|
||||||
|
onChange={(e) => setFontSize(e.target.value as FontSize)}
|
||||||
|
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
|
||||||
|
>
|
||||||
|
{fontSizeOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{iconRight && type !== "password" && (
|
{iconRight && type !== "password" && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
|
||||||
{iconRight}
|
{iconRight}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function LabelForm({
|
|||||||
labelPublicId: entityId,
|
labelPublicId: entityId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isEdit && !!entityId,
|
enabled: !!isEdit && entityId.length >= 12,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export function LabelForm({
|
|||||||
reset(newFormState);
|
reset(newFormState);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -207,7 +207,7 @@ export function LabelForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<Toggle
|
<Toggle
|
||||||
label={t`Create another`}
|
label={t`Create another`}
|
||||||
@@ -231,9 +231,7 @@ export function LabelForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||||
disabled={
|
disabled={!watch("name")}
|
||||||
!watch("name")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isEdit ? t`Update label` : t`Create label`}
|
{isEdit ? t`Update label` : t`Create label`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||||
HiBolt,
|
|
||||||
HiCheck,
|
|
||||||
HiCheckBadge,
|
|
||||||
HiInformationCircle,
|
|
||||||
HiXMark,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import Toggle from "~/components/Toggle";
|
|
||||||
import { useDebounce } from "~/hooks/useDebounce";
|
import { useDebounce } from "~/hooks/useDebounce";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
|
|||||||
watch,
|
watch,
|
||||||
trigger,
|
trigger,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
setValue,
|
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
|
|||||||
|
|
||||||
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
||||||
|
|
||||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
|
||||||
|
|
||||||
const slug = watch("slug");
|
const slug = watch("slug");
|
||||||
const [debouncedSlug] = useDebounce(slug, 500);
|
const [debouncedSlug] = useDebounce(slug, 500);
|
||||||
const isTyping = slug !== debouncedSlug;
|
const isTyping = slug !== debouncedSlug;
|
||||||
|
|
||||||
// Pro toggle state management
|
|
||||||
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
|
|
||||||
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
|
|
||||||
|
|
||||||
// Validate slug only after debounce
|
// Validate slug only after debounce
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTyping) {
|
if (isTyping) {
|
||||||
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
|
|||||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||||
|
|
||||||
const createWorkspace = api.workspace.create.useMutation({
|
const createWorkspace = api.workspace.create.useMutation({
|
||||||
onSuccess: async (values, variables) => {
|
onSuccess: (values) => {
|
||||||
if (values.publicId && values.name) {
|
if (values.publicId && values.name) {
|
||||||
void utils.workspace.all.invalidate();
|
void utils.workspace.all.invalidate();
|
||||||
switchWorkspace({
|
switchWorkspace({
|
||||||
@@ -110,45 +96,11 @@ export function NewWorkspaceForm() {
|
|||||||
description: values.description,
|
description: values.description,
|
||||||
slug: values.slug,
|
slug: values.slug,
|
||||||
plan: values.plan,
|
plan: values.plan,
|
||||||
|
cardPrefix: values.cardPrefix,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
|
weekStartDay: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
|
||||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/stripe/create_checkout_session",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
slug: slug || undefined,
|
|
||||||
workspacePublicId: values.publicId,
|
|
||||||
cancelUrl: "/settings/workspace?upgrade=pro",
|
|
||||||
successUrl: "/boards",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const url = (data as { url: string }).url;
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
window.location.href = url;
|
|
||||||
return; // Don't close modal if redirecting to checkout
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating checkout session:", error);
|
|
||||||
showPopup({
|
|
||||||
header: t`Error upgrading to Pro`,
|
|
||||||
message: t`Workspace created successfully. You can upgrade later in settings.`,
|
|
||||||
icon: "warning",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -167,57 +119,8 @@ export function NewWorkspaceForm() {
|
|||||||
if (nameElement) nameElement.focus();
|
if (nameElement) nameElement.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
|
|
||||||
|
|
||||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
isCloudEnv &&
|
|
||||||
!checkWorkspaceSlugAvailability.isPending &&
|
|
||||||
isValidSlug
|
|
||||||
) {
|
|
||||||
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
|
|
||||||
setShouldShowBenefits(isAvailable);
|
|
||||||
|
|
||||||
// Automatically enable Pro toggle when slug is available
|
|
||||||
if (isAvailable) {
|
|
||||||
setIsProToggleEnabled(true);
|
|
||||||
setLastAvailableSlug(slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isValidSlug,
|
|
||||||
isWorkspaceSlugAvailable?.isAvailable,
|
|
||||||
checkWorkspaceSlugAvailability.isPending,
|
|
||||||
slug,
|
|
||||||
isCloudEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Reset benefits when slug becomes invalid
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isValidSlug) {
|
|
||||||
setShouldShowBenefits(false);
|
|
||||||
}
|
|
||||||
}, [isValidSlug]);
|
|
||||||
|
|
||||||
// Handle Pro toggle changes
|
|
||||||
const handleProToggleChange = () => {
|
|
||||||
if (isProToggleEnabled) {
|
|
||||||
// Turning off: clear the slug and disable Pro
|
|
||||||
setValue("slug", "");
|
|
||||||
setIsProToggleEnabled(false);
|
|
||||||
} else {
|
|
||||||
// Turning on: restore the last available slug if we have one
|
|
||||||
if (lastAvailableSlug) {
|
|
||||||
setValue("slug", lastAvailableSlug);
|
|
||||||
}
|
|
||||||
setIsProToggleEnabled(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showProBenefits = isProToggleEnabled;
|
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
// Don't submit if slug is provided but not available
|
// Don't submit if slug is provided but not available
|
||||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
||||||
@@ -226,7 +129,7 @@ export function NewWorkspaceForm() {
|
|||||||
|
|
||||||
createWorkspace.mutate({
|
createWorkspace.mutate({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
slug: values.slug,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,11 +192,7 @@ export function NewWorkspaceForm() {
|
|||||||
? t`This workspace URL is reserved`
|
? t`This workspace URL is reserved`
|
||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
prefix={
|
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
|
||||||
? "kan.bn/"
|
|
||||||
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
|
||||||
}
|
|
||||||
iconRight={
|
iconRight={
|
||||||
slug && slug.length >= 3 && !errors.slug ? (
|
slug && slug.length >= 3 && !errors.slug ? (
|
||||||
isWorkspaceSlugAvailable?.isAvailable ? (
|
isWorkspaceSlugAvailable?.isAvailable ? (
|
||||||
@@ -310,68 +209,9 @@ export function NewWorkspaceForm() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{slug && slug.length >= 3 && shouldShowBenefits && (
|
|
||||||
<div className="mt-2 flex items-center gap-1">
|
|
||||||
<HiInformationCircle className="h-4 w-4 text-dark-900" />
|
|
||||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
|
||||||
{t`Custom URLs require upgrading to a Pro plan`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showProBenefits && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Unlimited members`}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
|
||||||
{t`Launch offer`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
|
||||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Custom workspace URL`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Board analytics`}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
|
||||||
{t`Coming soon`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
className={twMerge(
|
|
||||||
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
|
|
||||||
!showProBenefits && "mt-12",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Pro Toggle - only show in cloud environment */}
|
|
||||||
{isCloudEnv && (
|
|
||||||
<Toggle
|
|
||||||
isChecked={isProToggleEnabled}
|
|
||||||
onChange={handleProToggleChange}
|
|
||||||
label={t`Upgrade to Pro ($29/month)`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const PageHead = ({ title }: { title: string }) => {
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
</Head>
|
</Head>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
interface PlainTextEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onBlur?: (value: string) => void;
|
||||||
|
onEnter?: (value: string) => void;
|
||||||
|
onEscape?: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlainTextEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
onEnter,
|
||||||
|
onEscape,
|
||||||
|
readOnly = false,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: PlainTextEditorProps) {
|
||||||
|
const onEnterRef = useRef(onEnter);
|
||||||
|
const onEscapeRef = useRef(onEscape);
|
||||||
|
const onBlurRef = useRef(onBlur);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const contentRef = useRef(content);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEnterRef.current = onEnter;
|
||||||
|
}, [onEnter]);
|
||||||
|
useEffect(() => {
|
||||||
|
onEscapeRef.current = onEscape;
|
||||||
|
}, [onEscape]);
|
||||||
|
useEffect(() => {
|
||||||
|
onBlurRef.current = onBlur;
|
||||||
|
}, [onBlur]);
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
contentRef.current = content;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const editor = useEditor(
|
||||||
|
{
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
strike: false,
|
||||||
|
code: false,
|
||||||
|
codeBlock: false,
|
||||||
|
blockquote: false,
|
||||||
|
heading: false,
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false,
|
||||||
|
listItem: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
hardBreak: false,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({ placeholder }),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editable: !readOnly,
|
||||||
|
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
|
||||||
|
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
|
||||||
|
editorProps: {
|
||||||
|
handleKeyDown: (view, event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
onEnterRef.current?.(view.state.doc.textContent.trim());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
// Reset to original content before calling the callback
|
||||||
|
editor?.commands.setContent(contentRef.current, false);
|
||||||
|
editor?.commands.blur();
|
||||||
|
onEscapeRef.current?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
attributes: {
|
||||||
|
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (content !== editor.getText()) {
|
||||||
|
editor.commands.setContent(content, false);
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.setEditable(!readOnly);
|
||||||
|
}, [readOnly, editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style jsx global>{`
|
||||||
|
.plain-text-editor p.is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--placeholder-color, #9ca3af);
|
||||||
|
}
|
||||||
|
.plain-text-editor .tiptap p {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className={twMerge("plain-text-editor", className)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
HiChevronDown,
|
HiChevronDown,
|
||||||
HiOutlineBanknotes,
|
HiOutlineBanknotes,
|
||||||
|
HiOutlineBolt,
|
||||||
HiOutlineCodeBracketSquare,
|
HiOutlineCodeBracketSquare,
|
||||||
HiOutlineRectangleGroup,
|
HiOutlineRectangleGroup,
|
||||||
HiOutlineShieldCheck,
|
HiOutlineShieldCheck,
|
||||||
@@ -64,6 +65,12 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
label: t`API`,
|
label: t`API`,
|
||||||
condition: true,
|
condition: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "webhooks",
|
||||||
|
icon: <HiOutlineBolt />,
|
||||||
|
label: t`Webhooks`,
|
||||||
|
condition: isAdmin,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "integrations",
|
key: "integrations",
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from "@headlessui/react";
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { HiBolt } from "react-icons/hi2";
|
import { HiBolt } from "react-icons/hi2";
|
||||||
import {
|
import {
|
||||||
TbLayoutSidebarLeftCollapse,
|
TbLayoutSidebarLeftCollapse,
|
||||||
@@ -28,7 +28,7 @@ import ButtonComponent from "~/components/Button";
|
|||||||
import ReactiveButton from "~/components/ReactiveButton";
|
import ReactiveButton from "~/components/ReactiveButton";
|
||||||
import UserMenu from "~/components/UserMenu";
|
import UserMenu from "~/components/UserMenu";
|
||||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -51,9 +51,9 @@ export default function SideNavigation({
|
|||||||
}: SideNavigationProps) {
|
}: SideNavigationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
const { locale } = useLinguiContext();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [isInitialised, setIsInitialised] = useState(false);
|
const [isInitialised, setIsInitialised] = useState(false);
|
||||||
const { openModal } = useModal();
|
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{ workspacePublicId: workspace.publicId },
|
||||||
@@ -94,56 +94,59 @@ export default function SideNavigation({
|
|||||||
href: string;
|
href: string;
|
||||||
icon: object;
|
icon: object;
|
||||||
keyboardShortcut: KeyboardShortcut;
|
keyboardShortcut: KeyboardShortcut;
|
||||||
}[] = [
|
}[] = useMemo(
|
||||||
{
|
() => [
|
||||||
name: t`Boards`,
|
{
|
||||||
href: "/boards",
|
name: t`Boards`,
|
||||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
href: "/boards",
|
||||||
keyboardShortcut: {
|
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||||
type: "SEQUENCE",
|
keyboardShortcut: {
|
||||||
strokes: [{ key: "G" }, { key: "B" }],
|
type: "SEQUENCE",
|
||||||
action: () => router.push("/boards"),
|
strokes: [{ key: "G" }, { key: "B" }],
|
||||||
group: "NAVIGATION",
|
action: () => router.push("/boards"),
|
||||||
description: t`Go to boards`,
|
group: "NAVIGATION",
|
||||||
|
description: t`Go to boards`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: t`Templates`,
|
||||||
name: t`Templates`,
|
href: "/templates",
|
||||||
href: "/templates",
|
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
keyboardShortcut: {
|
||||||
keyboardShortcut: {
|
type: "SEQUENCE",
|
||||||
type: "SEQUENCE",
|
strokes: [{ key: "G" }, { key: "T" }],
|
||||||
strokes: [{ key: "G" }, { key: "T" }],
|
action: () => router.push("/templates"),
|
||||||
action: () => router.push("/templates"),
|
group: "NAVIGATION",
|
||||||
group: "NAVIGATION",
|
description: t`Go to templates`,
|
||||||
description: t`Go to templates`,
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: t`Members`,
|
||||||
name: t`Members`,
|
href: "/members",
|
||||||
href: "/members",
|
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
keyboardShortcut: {
|
||||||
keyboardShortcut: {
|
type: "SEQUENCE",
|
||||||
type: "SEQUENCE",
|
strokes: [{ key: "G" }, { key: "M" }],
|
||||||
strokes: [{ key: "G" }, { key: "M" }],
|
action: () => router.push("/members"),
|
||||||
action: () => router.push("/members"),
|
group: "NAVIGATION",
|
||||||
group: "NAVIGATION",
|
description: t`Go to members`,
|
||||||
description: t`Go to members`,
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
name: t`Settings`,
|
||||||
name: t`Settings`,
|
href: "/settings",
|
||||||
href: "/settings",
|
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
keyboardShortcut: {
|
||||||
keyboardShortcut: {
|
type: "SEQUENCE",
|
||||||
type: "SEQUENCE",
|
strokes: [{ key: "G" }, { key: "S" }],
|
||||||
strokes: [{ key: "G" }, { key: "S" }],
|
action: () => router.push("/settings"),
|
||||||
action: () => router.push("/settings"),
|
group: "NAVIGATION",
|
||||||
group: "NAVIGATION",
|
description: t`Go to settings`,
|
||||||
description: t`Go to settings`,
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
];
|
[isDarkMode, locale, router],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
setIsCollapsed(!isCollapsed);
|
setIsCollapsed(!isCollapsed);
|
||||||
@@ -223,21 +226,19 @@ export default function SideNavigation({
|
|||||||
<ButtonComponent
|
<ButtonComponent
|
||||||
iconLeft={<HiBolt />}
|
iconLeft={<HiBolt />}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
href="/settings/workspace?upgrade=pro"
|
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||||
aria-label="Upgrade to Pro"
|
aria-label={t`Start free trial`}
|
||||||
title="Upgrade to Pro"
|
title={t`Start free trial`}
|
||||||
iconOnly
|
iconOnly
|
||||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ButtonComponent
|
<ButtonComponent
|
||||||
iconLeft={<HiBolt />}
|
iconLeft={<HiBolt />}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
href="/settings/workspace?upgrade=pro"
|
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
|
||||||
>
|
>
|
||||||
{t`Upgrade to Pro`}
|
{t`Start free trial`}
|
||||||
</ButtonComponent>
|
</ButtonComponent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,15 +7,17 @@ const Toggle = ({
|
|||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
|
labelPosition = "before",
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
|
labelPosition?: "before" | "after";
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mr-4 flex items-center justify-end">
|
<div className="flex items-center">
|
||||||
{showLabel && (
|
{showLabel && labelPosition === "before" && (
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
@@ -38,6 +40,11 @@ const Toggle = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
{showLabel && labelPosition === "after" && (
|
||||||
|
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Root } from "react-dom/client";
|
import type { Root } from "react-dom/client";
|
||||||
import type { Placement } from "tippy.js";
|
import type { Placement, Instance as TippyInstance } from "tippy.js";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
@@ -20,16 +20,16 @@ export function Tooltip({
|
|||||||
}: TooltipProps) {
|
}: TooltipProps) {
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
const rootRef = useRef<Root | null>(null);
|
const rootRef = useRef<Root | null>(null);
|
||||||
|
const tippyRef = useRef<TippyInstance | null>(null);
|
||||||
|
const contentRef = useRef(content);
|
||||||
|
contentRef.current = content;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!triggerRef.current) return;
|
if (!triggerRef.current) return;
|
||||||
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
rootRef.current = root;
|
rootRef.current = root;
|
||||||
root.render(content);
|
|
||||||
|
|
||||||
const instance = tippy(triggerRef.current, {
|
const instance = tippy(triggerRef.current, {
|
||||||
content: container,
|
content: container,
|
||||||
@@ -39,12 +39,33 @@ export function Tooltip({
|
|||||||
theme: "tooltip",
|
theme: "tooltip",
|
||||||
touch: false,
|
touch: false,
|
||||||
});
|
});
|
||||||
|
tippyRef.current = instance;
|
||||||
|
|
||||||
|
if (contentRef.current) {
|
||||||
|
root.render(contentRef.current);
|
||||||
|
} else {
|
||||||
|
instance.disable();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
instance.destroy();
|
instance.destroy();
|
||||||
rootRef.current?.unmount();
|
tippyRef.current = null;
|
||||||
|
root.unmount();
|
||||||
|
rootRef.current = null;
|
||||||
};
|
};
|
||||||
}, [content, placement, delay]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [placement, delay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!content) {
|
||||||
|
tippyRef.current?.disable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tippyRef.current) {
|
||||||
|
tippyRef.current.enable();
|
||||||
|
rootRef.current?.render(content);
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={triggerRef} className="inline-flex">
|
<div ref={triggerRef} className="inline-flex">
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Button, Menu, Transition } from "@headlessui/react";
|
import { Button, Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Fragment, useState } from "react";
|
import { env } from "next-runtime-env";
|
||||||
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
import CommandPallette from "./CommandPallette";
|
import CommandPallette from "./CommandPallette";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
@@ -17,20 +21,29 @@ export default function WorkspaceMenu({
|
|||||||
}) {
|
}) {
|
||||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||||
useWorkspace();
|
useWorkspace();
|
||||||
|
const { locale } = useLinguiContext();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { data: hasPartnerSlot } =
|
||||||
|
api.workspace.hasAvailablePartnerSlot.useQuery();
|
||||||
|
const router = useRouter();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
const commandPaletteShortcut = useMemo(
|
||||||
useKeyboardShortcut({
|
() => ({
|
||||||
type: "PRESS",
|
type: "PRESS" as const,
|
||||||
stroke: {
|
stroke: {
|
||||||
key: "k",
|
key: "k",
|
||||||
modifiers: ["META"],
|
modifiers: ["META"] as ("META" | "CONTROL" | "ALT" | "SHIFT")[],
|
||||||
},
|
},
|
||||||
action: () => setIsOpen(true),
|
action: () => setIsOpen(true),
|
||||||
description: t`Open command menu`,
|
description: t`Open command menu`,
|
||||||
group: "GENERAL",
|
group: "GENERAL" as const,
|
||||||
});
|
}),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||||
|
useKeyboardShortcut(commandPaletteShortcut);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -147,7 +160,19 @@ export default function WorkspaceMenu({
|
|||||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal("NEW_WORKSPACE")}
|
onClick={() => {
|
||||||
|
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||||
|
openModal("NEW_WORKSPACE");
|
||||||
|
} else if (hasPartnerSlot) {
|
||||||
|
router.push(
|
||||||
|
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(
|
||||||
|
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||||
>
|
>
|
||||||
{t`Create workspace`}
|
{t`Create workspace`}
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ export const env = createEnv({
|
|||||||
VK_CLIENT_SECRET: z.string().optional(),
|
VK_CLIENT_SECRET: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||||
NOVU_API_KEY: z.string().optional(),
|
SUBSCRIBER_API_URL: z.string().url().optional(),
|
||||||
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
SUBSCRIBER_API_KEY: z.string().optional(),
|
||||||
|
SUBSCRIBER_ENVIRONMENT_ID: z.string().optional(),
|
||||||
// Generic OIDC Provider
|
// Generic OIDC Provider
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Locale as DateFnsLocale } from "date-fns";
|
import type { Locale as DateFnsLocale } from "date-fns";
|
||||||
import { useLingui } from "@lingui/react";
|
import { useLingui } from "@lingui/react";
|
||||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
import { de, enGB, es, fr, it, nl, pl, ptBR, ru, zhCN } from "date-fns/locale";
|
||||||
|
|
||||||
import type { Locale } from "~/locales";
|
import type { Locale } from "~/locales";
|
||||||
import { useLinguiContext } from "~/providers/lingui";
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
@@ -19,7 +19,8 @@ export function useLocalisation() {
|
|||||||
nl,
|
nl,
|
||||||
ru,
|
ru,
|
||||||
pl,
|
pl,
|
||||||
"pt-BR": ptBR,
|
ptbr: ptBR,
|
||||||
|
"zh-CN": zhCN,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
|
||||||
interface UseModalFormStateOptions<T> {
|
interface UseModalFormStateOptions<T> {
|
||||||
@@ -12,25 +13,43 @@ export function useModalFormState<T extends Record<string, any>>({
|
|||||||
initialValues,
|
initialValues,
|
||||||
resetOnClose = false,
|
resetOnClose = false,
|
||||||
}: UseModalFormStateOptions<T>) {
|
}: UseModalFormStateOptions<T>) {
|
||||||
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
|
const {
|
||||||
|
modalContentType,
|
||||||
|
isOpen,
|
||||||
|
getModalState,
|
||||||
|
setModalState,
|
||||||
|
clearModalState,
|
||||||
|
} = useModal();
|
||||||
|
|
||||||
const isCurrentModal = modalContentType === modalType;
|
const isCurrentModal = modalContentType === modalType;
|
||||||
const savedState = getModalState(modalType) as T | undefined;
|
const savedState = getModalState(modalType) as T | undefined;
|
||||||
|
|
||||||
// get current form state (using the saved values if available, otherwise the initial values)
|
// get current form state (using the saved values if available, otherwise the initial values)
|
||||||
const formState = savedState || initialValues;
|
const formState = savedState || initialValues;
|
||||||
|
|
||||||
const saveFormState = (state: Partial<T>) => {
|
// Keep refs so the callbacks below stay stable across re-renders.
|
||||||
if (!isCurrentModal) return;
|
const modalTypeRef = useRef(modalType);
|
||||||
|
const initialValuesRef = useRef(initialValues);
|
||||||
const currentState = getModalState(modalType) || initialValues;
|
const getModalStateRef = useRef(getModalState);
|
||||||
const newState = { ...currentState, ...state };
|
modalTypeRef.current = modalType;
|
||||||
setModalState(modalType, newState);
|
initialValuesRef.current = initialValues;
|
||||||
};
|
getModalStateRef.current = getModalState;
|
||||||
|
|
||||||
const clearFormState = () => {
|
const saveFormState = useCallback(
|
||||||
clearModalState(modalType);
|
(state: Partial<T>) => {
|
||||||
};
|
const type = modalTypeRef.current;
|
||||||
|
const currentState =
|
||||||
|
getModalStateRef.current(type) ?? initialValuesRef.current;
|
||||||
|
const newState = { ...currentState, ...state };
|
||||||
|
setModalState(type, newState);
|
||||||
|
},
|
||||||
|
// setModalState is stable (useCallback with [] deps in ModalProvider)
|
||||||
|
[setModalState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFormState = useCallback(() => {
|
||||||
|
clearModalState(modalTypeRef.current);
|
||||||
|
}, [clearModalState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resetOnClose && !isOpen && savedState) {
|
if (resetOnClose && !isOpen && savedState) {
|
||||||
@@ -45,4 +64,4 @@ export function useModalFormState<T extends Record<string, any>>({
|
|||||||
isCurrentModal,
|
isCurrentModal,
|
||||||
hasSavedState: !!savedState,
|
hasSavedState: !!savedState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { NextRouter } from "next/router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const scrollPositions = new Map<string, number>();
|
||||||
|
|
||||||
|
export function useScrollRestore(
|
||||||
|
boardId: string | null | undefined,
|
||||||
|
scrollRef: RefObject<HTMLElement | null>,
|
||||||
|
router: NextRouter,
|
||||||
|
isReady: boolean,
|
||||||
|
) {
|
||||||
|
const restored = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!boardId) return;
|
||||||
|
|
||||||
|
restored.current = false;
|
||||||
|
|
||||||
|
const saveScrollPosition = () => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on("routeChangeStart", saveScrollPosition);
|
||||||
|
return () => router.events.off("routeChangeStart", saveScrollPosition);
|
||||||
|
}, [boardId, router.events, scrollRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (restored.current || !isReady || !boardId) return;
|
||||||
|
restored.current = true;
|
||||||
|
|
||||||
|
const saved = scrollPositions.get(boardId);
|
||||||
|
if (saved === undefined) return;
|
||||||
|
|
||||||
|
// StrictModeDroppable delays rendering by one requestAnimationFrame
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [isReady, scrollRef, boardId]);
|
||||||
|
}
|
||||||
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
@@ -7,12 +7,14 @@ export const locales = [
|
|||||||
"nl",
|
"nl",
|
||||||
"ru",
|
"ru",
|
||||||
"pl",
|
"pl",
|
||||||
"ptbr"
|
"ptbr",
|
||||||
|
"zh-CN",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
export const defaultLocale: Locale = "en";
|
// 自托管实例默认使用简体中文,用户仍可在设置中切换其他语言。
|
||||||
|
export const defaultLocale: Locale = "zh-CN";
|
||||||
|
|
||||||
export const localeNames: Record<Locale, string> = {
|
export const localeNames: Record<Locale, string> = {
|
||||||
en: "English",
|
en: "English",
|
||||||
@@ -24,4 +26,5 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
ptbr: "Português",
|
ptbr: "Português",
|
||||||
|
"zh-CN": "简体中文",
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
13005
apps/web/src/locales/zh-CN/messages.json
Normal file
13005
apps/web/src/locales/zh-CN/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/zh-CN/messages.ts
Normal file
1
apps/web/src/locales/zh-CN/messages.ts
Normal file
File diff suppressed because one or more lines are too long
55
apps/web/src/pages/404.tsx
Normal file
55
apps/web/src/pages/404.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t`404 - Page Not Found | kan.bn`} />
|
||||||
|
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||||
|
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||||
|
<div className="z-10 flex w-full flex-col items-center">
|
||||||
|
<Link href="/">
|
||||||
|
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
kan.bn
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
404
|
||||||
|
</p>
|
||||||
|
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
<Trans>Page not found</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
|
||||||
|
<Trans>
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
|
||||||
|
>
|
||||||
|
<Trans>Go to homepage</Trans>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/boards"
|
||||||
|
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||||
|
>
|
||||||
|
<Trans>Go to boards</Trans>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PatternedBackground />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import posthog from "posthog-js";
|
|||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { FontSizeProvider } from "~/providers/font-size";
|
||||||
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
||||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||||
import { ModalProvider } from "~/providers/modal";
|
import { ModalProvider } from "~/providers/modal";
|
||||||
@@ -81,23 +82,25 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
)}
|
)}
|
||||||
<script src="/__ENV.js" />
|
<script src="/__ENV.js" />
|
||||||
<main className="font-sans">
|
<main className="font-sans">
|
||||||
<KeyboardShortcutProvider>
|
<LinguiProviderWrapper>
|
||||||
<LinguiProviderWrapper>
|
<KeyboardShortcutProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<FontSizeProvider>
|
||||||
<ModalProvider>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<PopupProvider>
|
<ModalProvider>
|
||||||
{posthogKey ? (
|
<PopupProvider>
|
||||||
<PostHogProvider client={posthog}>
|
{posthogKey ? (
|
||||||
{getLayout(<Component {...pageProps} />)}
|
<PostHogProvider client={posthog}>
|
||||||
</PostHogProvider>
|
{getLayout(<Component {...pageProps} />)}
|
||||||
) : (
|
</PostHogProvider>
|
||||||
getLayout(<Component {...pageProps} />)
|
) : (
|
||||||
)}
|
getLayout(<Component {...pageProps} />)
|
||||||
</PopupProvider>
|
)}
|
||||||
</ModalProvider>
|
</PopupProvider>
|
||||||
</ThemeProvider>
|
</ModalProvider>
|
||||||
</LinguiProviderWrapper>
|
</ThemeProvider>
|
||||||
</KeyboardShortcutProvider>
|
</FontSizeProvider>
|
||||||
|
</KeyboardShortcutProvider>
|
||||||
|
</LinguiProviderWrapper>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,49 +1,75 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
|
||||||
|
|
||||||
const { url, filename } = req.query;
|
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: "url parameter is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const downloadFilename = typeof filename === "string"
|
|
||||||
? encodeURIComponent(filename)
|
|
||||||
: "attachment";
|
|
||||||
|
|
||||||
const upstream = await fetch(url);
|
|
||||||
|
|
||||||
if (!upstream.ok) {
|
|
||||||
return res.status(upstream.status).json({
|
|
||||||
message: "Failed to fetch attachment",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType =
|
const { url, filename } = req.query;
|
||||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", contentType);
|
if (!url || typeof url !== "string") {
|
||||||
res.setHeader(
|
return res.status(400).json({ message: "url parameter is required" });
|
||||||
"Content-Disposition",
|
}
|
||||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const buffer = await upstream.arrayBuffer();
|
const s3Endpoint = env.S3_ENDPOINT;
|
||||||
return res.send(Buffer.from(buffer));
|
|
||||||
} catch (error) {
|
if (s3Endpoint) {
|
||||||
console.error("Error downloading attachment:", error);
|
let parsed: URL;
|
||||||
return res.status(500).json({ message: "Failed to download attachment" });
|
try {
|
||||||
}
|
parsed = new URL(url);
|
||||||
},
|
} catch {
|
||||||
|
return res.status(400).json({ message: "Invalid URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
let allowedHost: string;
|
||||||
|
try {
|
||||||
|
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Storage endpoint misconfigured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||||
|
return res.status(403).json({ message: "URL not allowed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadFilename =
|
||||||
|
typeof filename === "string"
|
||||||
|
? encodeURIComponent(filename)
|
||||||
|
: "attachment";
|
||||||
|
|
||||||
|
const upstream = await fetch(url);
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return res
|
||||||
|
.status(upstream.status)
|
||||||
|
.json({ message: "Failed to fetch attachment" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = await upstream.arrayBuffer();
|
||||||
|
return res.send(Buffer.from(buffer));
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: "Failed to download attachment" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch from Formbricks");
|
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
return res.status(200).json(data);
|
try {
|
||||||
} catch (error) {
|
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||||
console.error("Error fetching OSS friends:", error);
|
if (!response.ok) {
|
||||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
throw new Error("Failed to fetch from Formbricks");
|
||||||
}
|
}
|
||||||
},
|
const data = await response.json();
|
||||||
|
|
||||||
|
return res.status(200).json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
20
apps/web/src/pages/api/partner/_utils.ts
Normal file
20
apps/web/src/pages/api/partner/_utils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
|
||||||
|
|
||||||
|
export interface TierConfig {
|
||||||
|
plan: WorkspacePlan;
|
||||||
|
seats: number | null;
|
||||||
|
unlimitedSeats: boolean;
|
||||||
|
workspaceSlots: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_MAP: Record<number, TierConfig> = {
|
||||||
|
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
|
||||||
|
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
|
||||||
|
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tierConfig(tier: number): TierConfig {
|
||||||
|
const config = TIER_MAP[tier];
|
||||||
|
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
144
apps/web/src/pages/api/partner/callback.ts
Normal file
144
apps/web/src/pages/api/partner/callback.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
|
import { createLogger } from "@kan/logger";
|
||||||
|
|
||||||
|
import { tierConfig } from "./_utils";
|
||||||
|
|
||||||
|
const log = createLogger("api");
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthLicenseResponse {
|
||||||
|
license_key: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LicenseDetailResponse {
|
||||||
|
license_key: string;
|
||||||
|
status: string;
|
||||||
|
tier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
|
||||||
|
const tokenUrl = process.env.PARTNER_TOKEN_URL;
|
||||||
|
const clientId = process.env.PARTNER_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
|
||||||
|
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
|
||||||
|
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
|
||||||
|
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
|
||||||
|
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
|
||||||
|
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
|
||||||
|
|
||||||
|
const res = await fetch(tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<TokenResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOAuthLicense(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<OAuthLicenseResponse> {
|
||||||
|
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
|
||||||
|
if (!oauthLicenseUrl)
|
||||||
|
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
|
||||||
|
|
||||||
|
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
|
||||||
|
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<OAuthLicenseResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLicenseDetail(
|
||||||
|
licenseKey: string,
|
||||||
|
): Promise<LicenseDetailResponse> {
|
||||||
|
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
|
||||||
|
const apiKey = process.env.PARTNER_API_KEY;
|
||||||
|
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
|
||||||
|
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
|
||||||
|
|
||||||
|
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
|
||||||
|
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
|
||||||
|
|
||||||
|
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
|
||||||
|
headers: { [apiKeyHeader]: apiKey },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<LicenseDetailResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 20, duration: 60 },
|
||||||
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = req.query;
|
||||||
|
|
||||||
|
if (!code || typeof code !== "string") {
|
||||||
|
return res.status(200).json({ message: "OK" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let license: LicenseDetailResponse;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenData = await exchangeCodeForToken(code);
|
||||||
|
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
|
||||||
|
license = await fetchLicenseDetail(oauthLicense.license_key);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "Partner OAuth flow failed");
|
||||||
|
return res.redirect(`/partner/activate?error=oauth_failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db, user } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
const cfg = tierConfig(license.tier);
|
||||||
|
const status = license.status === "active" ? "active" : "inactive";
|
||||||
|
|
||||||
|
// Ensure subscription slots exist — webhook may have already created them
|
||||||
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license.license_key,
|
||||||
|
);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
|
db,
|
||||||
|
license.license_key,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: license.tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
cfg.workspaceSlots,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.redirect(
|
||||||
|
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect(
|
||||||
|
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
98
apps/web/src/pages/api/partner/link.ts
Normal file
98
apps/web/src/pages/api/partner/link.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 20, duration: 60 },
|
||||||
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { license_key } = req.query;
|
||||||
|
|
||||||
|
if (!license_key || typeof license_key !== "string") {
|
||||||
|
return res.redirect("/boards?partner_error=missing_license");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db, user } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.redirect(
|
||||||
|
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allSlots.length) {
|
||||||
|
return res.redirect("/boards?partner_error=invalid_license");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSlots = allSlots.filter((s) =>
|
||||||
|
["active", "trialing"].includes(s.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeSlots.length) {
|
||||||
|
return res.redirect("/boards?partner_error=license_inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
|
||||||
|
|
||||||
|
if (!unlinkedSlot) {
|
||||||
|
return res.redirect("/boards?partner_activated=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedIds = new Set(
|
||||||
|
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||||
|
const availableWorkspace = memberships
|
||||||
|
.map((m) => m.workspace)
|
||||||
|
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
|
||||||
|
|
||||||
|
if (!availableWorkspace) {
|
||||||
|
return res.redirect(
|
||||||
|
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
|
||||||
|
referenceId: availableWorkspace.publicId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await workspaceRepo.update(db, availableWorkspace.publicId, {
|
||||||
|
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
|
||||||
|
});
|
||||||
|
|
||||||
|
const remainingUnlinked = activeSlots.filter(
|
||||||
|
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remainingUnlinked.length > 0) {
|
||||||
|
const updatedLinkedIds = new Set([
|
||||||
|
...linkedIds,
|
||||||
|
availableWorkspace.publicId,
|
||||||
|
]);
|
||||||
|
const hasMoreAvailableWorkspace = memberships
|
||||||
|
.map((m) => m.workspace)
|
||||||
|
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
|
||||||
|
|
||||||
|
if (hasMoreAvailableWorkspace) {
|
||||||
|
return res.redirect(
|
||||||
|
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect("/boards?partner_activated=1");
|
||||||
|
}),
|
||||||
|
);
|
||||||
418
apps/web/src/pages/api/partner/webhook.ts
Normal file
418
apps/web/src/pages/api/partner/webhook.ts
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "crypto";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
import { cancelWorkspaceAccess } from "@kan/api/utils/workspace";
|
||||||
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { createLogger } from "@kan/logger";
|
||||||
|
import { getActiveSubscriptions } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
import type { TierConfig } from "./_utils";
|
||||||
|
import { tierConfig } from "./_utils";
|
||||||
|
|
||||||
|
const log = createLogger("api");
|
||||||
|
|
||||||
|
async function createAndLinkSlots(
|
||||||
|
db: dbClient,
|
||||||
|
licenseKey: string,
|
||||||
|
cfg: TierConfig,
|
||||||
|
tier: number,
|
||||||
|
alreadyLinkedReferenceIds: Set<string>,
|
||||||
|
count: number,
|
||||||
|
) {
|
||||||
|
const linkedId = [...alreadyLinkedReferenceIds][0];
|
||||||
|
let autoLinked = 0;
|
||||||
|
|
||||||
|
if (linkedId) {
|
||||||
|
const linkedWorkspace = await workspaceRepo.getByPublicId(db, linkedId);
|
||||||
|
|
||||||
|
if (linkedWorkspace?.createdBy) {
|
||||||
|
const owned = await workspaceRepo.getAllOwnedByUserId(
|
||||||
|
db,
|
||||||
|
linkedWorkspace.createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = owned.filter(
|
||||||
|
(w) => !alreadyLinkedReferenceIds.has(w.publicId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
const existingSubs =
|
||||||
|
await subscriptionRepo.getAllActivePartnerSubsByWorkspaceIds(
|
||||||
|
db,
|
||||||
|
candidates.map((w) => w.publicId),
|
||||||
|
);
|
||||||
|
const alreadySubscribed = new Set(
|
||||||
|
existingSubs
|
||||||
|
.map((s) => s.referenceId)
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const available = candidates
|
||||||
|
.filter((w) => !alreadySubscribed.has(w.publicId))
|
||||||
|
.slice(0, count);
|
||||||
|
|
||||||
|
if (available.length > 0) {
|
||||||
|
const newSlots = await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
|
db,
|
||||||
|
licenseKey,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: "active",
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
available.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
available.map((workspace, i) => {
|
||||||
|
const slot = newSlots[i];
|
||||||
|
if (!slot) return;
|
||||||
|
return Promise.all([
|
||||||
|
subscriptionRepo.updateById(db, slot.id, {
|
||||||
|
referenceId: workspace.publicId,
|
||||||
|
}),
|
||||||
|
workspaceRepo.update(db, workspace.publicId, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
autoLinked = available.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = count - autoLinked;
|
||||||
|
if (remaining > 0) {
|
||||||
|
await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
|
db,
|
||||||
|
licenseKey,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: "active",
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
remaining,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buffer(readable: Readable) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of readable) {
|
||||||
|
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySignature(
|
||||||
|
rawBody: string,
|
||||||
|
signature: string,
|
||||||
|
timestamp: string,
|
||||||
|
): boolean {
|
||||||
|
const secret = process.env.PARTNER_API_KEY;
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const ts = Number(timestamp);
|
||||||
|
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
|
||||||
|
|
||||||
|
const payload = `${timestamp}${rawBody}`;
|
||||||
|
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
|
||||||
|
s: T,
|
||||||
|
): s is T & { referenceId: string } {
|
||||||
|
return !!s.referenceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookPayload {
|
||||||
|
event:
|
||||||
|
| "purchase"
|
||||||
|
| "activate"
|
||||||
|
| "deactivate"
|
||||||
|
| "upgrade"
|
||||||
|
| "downgrade"
|
||||||
|
| "migrate";
|
||||||
|
license_key: string;
|
||||||
|
license_status: string;
|
||||||
|
tier: number;
|
||||||
|
prev_license_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withApiLogging(
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
|
||||||
|
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
|
||||||
|
|
||||||
|
if (!signatureHeader || !timestampHeader) {
|
||||||
|
log.error("Webhook signature headers not configured");
|
||||||
|
return res.status(500).json({ message: "Server misconfiguration" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = req.headers[signatureHeader] as string | undefined;
|
||||||
|
const timestamp = req.headers[timestampHeader] as string | undefined;
|
||||||
|
|
||||||
|
if (!signature || !timestamp) {
|
||||||
|
return res.status(400).json({ message: "Missing signature headers" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await buffer(req as unknown as Readable);
|
||||||
|
const rawBody = buf.toString("utf8");
|
||||||
|
|
||||||
|
if (!verifySignature(rawBody, signature, timestamp)) {
|
||||||
|
return res.status(401).json({ message: "Invalid signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(rawBody) as WebhookPayload;
|
||||||
|
const { event, license_key, license_status, tier, prev_license_key } =
|
||||||
|
payload;
|
||||||
|
|
||||||
|
log.info({ headers: req.headers, payload }, "partner webhook received");
|
||||||
|
|
||||||
|
const { db } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case "purchase":
|
||||||
|
case "activate": {
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
);
|
||||||
|
const status = event === "activate" ? "active" : license_status;
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await subscriptionRepo.createPartnerLicenseSlots(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
cfg.workspaceSlots,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "deactivate": {
|
||||||
|
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
allSlots.filter(hasReferenceId).map(async (slot) => {
|
||||||
|
const siblingSubs = await subscriptionRepo.getByReferenceId(
|
||||||
|
db,
|
||||||
|
slot.referenceId,
|
||||||
|
);
|
||||||
|
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
|
||||||
|
(s) => s.id !== slot.id,
|
||||||
|
);
|
||||||
|
if (!hasOtherActiveSub) {
|
||||||
|
await cancelWorkspaceAccess(db, slot.referenceId);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: "free",
|
||||||
|
status: "canceled",
|
||||||
|
unlimitedSeats: false,
|
||||||
|
seats: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "upgrade":
|
||||||
|
case "downgrade": {
|
||||||
|
const lookupKey = prev_license_key ?? license_key;
|
||||||
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
lookupKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length === 0) break;
|
||||||
|
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
const newCount = cfg.workspaceSlots;
|
||||||
|
|
||||||
|
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
|
||||||
|
const preferKeep = [
|
||||||
|
...existing.filter((s) => s.referenceId),
|
||||||
|
...existing.filter((s) => !s.referenceId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||||
|
const slotsToRemove = preferKeep.slice(newCount);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...slotsToKeep.map((slot) =>
|
||||||
|
subscriptionRepo.updateById(db, slot.id, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
partnerTier: tier,
|
||||||
|
status: "active",
|
||||||
|
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...slotsToKeep
|
||||||
|
.filter(hasReferenceId)
|
||||||
|
.map((s) =>
|
||||||
|
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (newCount > existing.length) {
|
||||||
|
const linkedIds = new Set(
|
||||||
|
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||||
|
);
|
||||||
|
await createAndLinkSlots(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
cfg,
|
||||||
|
tier,
|
||||||
|
linkedIds,
|
||||||
|
newCount - existing.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slotsToRemove.length > 0) {
|
||||||
|
await Promise.all([
|
||||||
|
...slotsToRemove
|
||||||
|
.filter(hasReferenceId)
|
||||||
|
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||||
|
...slotsToRemove.map((s) =>
|
||||||
|
subscriptionRepo.updateById(db, s.id, {
|
||||||
|
plan: "free",
|
||||||
|
status: "inactive",
|
||||||
|
unlimitedSeats: false,
|
||||||
|
seats: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "migrate": {
|
||||||
|
if (!prev_license_key) break;
|
||||||
|
|
||||||
|
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
prev_license_key,
|
||||||
|
);
|
||||||
|
if (existing.length === 0) break;
|
||||||
|
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
const newCount = cfg.workspaceSlots;
|
||||||
|
|
||||||
|
const preferKeep = [
|
||||||
|
...existing.filter((s) => s.referenceId),
|
||||||
|
...existing.filter((s) => !s.referenceId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||||
|
const slotsToRemove = preferKeep.slice(newCount);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...slotsToKeep.map((slot) =>
|
||||||
|
subscriptionRepo.updateById(db, slot.id, {
|
||||||
|
partnerLicenseKey: license_key,
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: "active",
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...slotsToKeep
|
||||||
|
.filter(hasReferenceId)
|
||||||
|
.map((s) =>
|
||||||
|
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (newCount > existing.length) {
|
||||||
|
const linkedIds = new Set(
|
||||||
|
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||||
|
);
|
||||||
|
await createAndLinkSlots(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
cfg,
|
||||||
|
tier,
|
||||||
|
linkedIds,
|
||||||
|
newCount - existing.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slotsToRemove.length > 0) {
|
||||||
|
await Promise.all([
|
||||||
|
...slotsToRemove
|
||||||
|
.filter(hasReferenceId)
|
||||||
|
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||||
|
...slotsToRemove.map((s) =>
|
||||||
|
subscriptionRepo.updateById(db, s.id, {
|
||||||
|
plan: "free",
|
||||||
|
status: "inactive",
|
||||||
|
unlimitedSeats: false,
|
||||||
|
seats: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.warn({ event }, "Unhandled partner webhook event");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, event });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { user } = await createNextApiContext(req);
|
|
||||||
|
|
||||||
if (!user?.stripeCustomerId) {
|
|
||||||
return res.status(404).json({ error: "No billing account found" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await stripe.billingPortal.sessions.create({
|
try {
|
||||||
customer: user.stripeCustomerId,
|
const { user } = await createNextApiContext(req);
|
||||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({ url: session.url });
|
if (!user?.stripeCustomerId) {
|
||||||
} catch (error) {
|
return res.status(404).json({ error: "No billing account found" });
|
||||||
console.error("Error:", error);
|
}
|
||||||
return res.status(500).json({ error: "Error creating portal session" });
|
|
||||||
}
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
},
|
customer: user.stripeCustomerId,
|
||||||
|
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ url: session.url });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: "Error creating portal session" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { env } from "next-runtime-env";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
import { assertPermission } from "@kan/api/utils/permissions";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { generateUID } from "@kan/shared/utils";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const workspaceSlugSchema = z
|
const workspaceSlugSchema = z
|
||||||
.string()
|
.string()
|
||||||
@@ -17,21 +20,24 @@ const workspaceSlugSchema = z
|
|||||||
interface CheckoutSessionRequest {
|
interface CheckoutSessionRequest {
|
||||||
successUrl: string;
|
successUrl: string;
|
||||||
cancelUrl: string;
|
cancelUrl: string;
|
||||||
slug: string;
|
plan: "team" | "pro";
|
||||||
workspacePublicId: string;
|
billing?: string;
|
||||||
stripeCustomerId: string;
|
workspacePublicId?: string;
|
||||||
|
slug?: string;
|
||||||
|
workspaceName?: string;
|
||||||
|
workspaceDescription?: string;
|
||||||
|
workspaceSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const { user, db } = await createNextApiContext(req);
|
const { user, db } = await createNextApiContext(req);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -39,81 +45,107 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = req.body as CheckoutSessionRequest;
|
const body = req.body as CheckoutSessionRequest;
|
||||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
const {
|
||||||
|
successUrl,
|
||||||
|
cancelUrl,
|
||||||
|
plan,
|
||||||
|
billing,
|
||||||
|
workspacePublicId,
|
||||||
|
slug,
|
||||||
|
workspaceName,
|
||||||
|
workspaceDescription,
|
||||||
|
workspaceSlug,
|
||||||
|
} = body;
|
||||||
|
|
||||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
if (!successUrl || !cancelUrl || !plan) {
|
||||||
return res.status(400).json({ error: "Missing required fields" });
|
return res.status(400).json({ error: "Missing required fields" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug) {
|
if (!workspacePublicId && !workspaceName) {
|
||||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Must provide workspacePublicId or workspaceName" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSlug = slug ?? workspaceSlug;
|
||||||
|
|
||||||
|
if (resolvedSlug) {
|
||||||
|
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
|
||||||
if (!slugResult.success) {
|
if (!slugResult.success) {
|
||||||
return new Response(
|
return res.status(400).json({ error: "Invalid workspace slug" });
|
||||||
JSON.stringify({ error: "Invalid workspace slug" }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
let resolvedWorkspacePublicId = workspacePublicId;
|
||||||
|
let subscriptionId: number | undefined;
|
||||||
|
|
||||||
const isMemberOfWorkspace = workspace.some(
|
if (workspacePublicId) {
|
||||||
({ workspace }) => workspace.publicId === body.workspacePublicId,
|
// Existing workspace upgrade
|
||||||
);
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
db,
|
||||||
|
workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isMemberOfWorkspace) {
|
if (!workspace) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return res.status(404).json({ error: "Workspace not found" });
|
||||||
status: 403,
|
}
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
|
try {
|
||||||
|
await assertPermission(db, user.id, workspace.id, "workspace:manage");
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await subscriptionRepo.create(db, {
|
||||||
|
plan: plan,
|
||||||
|
referenceId: workspacePublicId,
|
||||||
|
userId: user.id,
|
||||||
|
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||||
|
status: "incomplete",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
subscriptionId = subscription?.id;
|
||||||
|
|
||||||
|
if (!subscriptionId) {
|
||||||
|
return res.status(500).json({ error: "Error creating subscription" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedWorkspacePublicId = generateUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await subscriptionRepo.create(db, {
|
const isTeam = body.plan === "team";
|
||||||
plan: "pro",
|
const annualPriceId = isTeam
|
||||||
referenceId: workspacePublicId,
|
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
|
||||||
userId: user.id,
|
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
|
||||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
|
||||||
status: "incomplete",
|
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
|
||||||
});
|
const monthlyPriceId = isTeam
|
||||||
|
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
|
||||||
const subscriptionId = subscription?.id;
|
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
|
||||||
|
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
|
||||||
if (!subscriptionId) {
|
|
||||||
return res.status(500).json({ error: "Error creating subscription" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
payment_method_collection: "always",
|
payment_method_collection: "always",
|
||||||
line_items: [
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
{
|
subscription_data: { trial_period_days: 14 },
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subscription_data: {
|
|
||||||
trial_period_days: 14,
|
|
||||||
},
|
|
||||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
|
||||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||||
client_reference_id: workspacePublicId,
|
client_reference_id: resolvedWorkspacePublicId,
|
||||||
customer: user.stripeCustomerId ?? undefined,
|
customer: user.stripeCustomerId ?? undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
...(slug && { workspaceSlug: slug }),
|
workspacePublicId: resolvedWorkspacePublicId!,
|
||||||
workspacePublicId,
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
subscriptionId,
|
userEmail: user.email ?? "",
|
||||||
|
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
|
||||||
|
...(workspaceName && { workspaceName }),
|
||||||
|
...(workspaceDescription && { workspaceDescription }),
|
||||||
|
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
|
||||||
|
plan: plan,
|
||||||
|
isNewWorkspace: workspaceName ? "true" : "false",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json({ url: session.url });
|
return res.status(200).json({ url: session.url });
|
||||||
} catch (error) {
|
}),
|
||||||
console.error("Error:", error);
|
|
||||||
return res.status(500).json({ error: "Error creating checkout session" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { createLogger } from "@kan/logger";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
|
const log = createLogger("api");
|
||||||
|
|
||||||
async function buffer(readable: Readable) {
|
async function buffer(readable: Readable) {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for await (const chunk of readable) {
|
for await (const chunk of readable) {
|
||||||
@@ -18,6 +23,9 @@ export default async function handler(
|
|||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) {
|
) {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
const start = Date.now();
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const procedure = req.url?.split("?")[0] ?? "/api/stripe/webhook";
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
@@ -44,24 +52,85 @@ export default async function handler(
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "checkout.session.completed": {
|
case "checkout.session.completed": {
|
||||||
const checkoutSession = event.data.object;
|
const checkoutSession = event.data.object;
|
||||||
|
const meta = checkoutSession.metadata;
|
||||||
|
|
||||||
const metaData = checkoutSession.metadata;
|
if (!meta?.workspacePublicId) break;
|
||||||
|
|
||||||
if (metaData?.workspacePublicId) {
|
const plan = meta.plan === "team" ? "team" : ("pro" as const);
|
||||||
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
|
||||||
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
|
if (
|
||||||
plan: "pro",
|
meta.isNewWorkspace === "true" &&
|
||||||
|
meta.workspaceName &&
|
||||||
|
meta.userId &&
|
||||||
|
meta.userEmail
|
||||||
|
) {
|
||||||
|
const existing = await workspaceRepo.getByPublicId(
|
||||||
|
db,
|
||||||
|
meta.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
|
||||||
|
|
||||||
|
await workspaceRepo.create(db, {
|
||||||
|
publicId: meta.workspacePublicId,
|
||||||
|
name: meta.workspaceName,
|
||||||
|
slug,
|
||||||
|
plan,
|
||||||
|
createdBy: meta.userId,
|
||||||
|
createdByEmail: meta.userEmail,
|
||||||
|
...(meta.workspaceDescription && {
|
||||||
|
description: meta.workspaceDescription,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await subscriptionRepo.create(db, {
|
||||||
|
plan,
|
||||||
|
referenceId: meta.workspacePublicId,
|
||||||
|
userId: meta.userId,
|
||||||
|
stripeCustomerId: checkoutSession.customer as string,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await workspaceRepo.update(db, meta.workspacePublicId, {
|
||||||
|
plan,
|
||||||
|
...(plan === "pro" &&
|
||||||
|
meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.log(`Unhandled event type: ${event.type}`);
|
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
procedure,
|
||||||
|
transport: "rest",
|
||||||
|
duration: Date.now() - start,
|
||||||
|
status: 200,
|
||||||
|
input: { eventType: event.type, eventId: event.id },
|
||||||
|
},
|
||||||
|
"API OK",
|
||||||
|
);
|
||||||
|
|
||||||
return res.status(200).json({ received: true });
|
return res.status(200).json({ received: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Webhook error:", err);
|
log.error(
|
||||||
|
{
|
||||||
|
requestId,
|
||||||
|
procedure,
|
||||||
|
transport: "rest",
|
||||||
|
duration: Date.now() - start,
|
||||||
|
status: 400,
|
||||||
|
err,
|
||||||
|
},
|
||||||
|
"API error",
|
||||||
|
);
|
||||||
return res.status(400).json({ message: "Webhook handler failed" });
|
return res.status(400).json({ message: "Webhook handler failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,58 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { addYears } from "date-fns";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { integrations } from "@kan/db/schema";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { addYears } from "date-fns";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import { integrations } from "@kan/db/schema";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = await createNextApiContext(req);
|
const { user } = await createNextApiContext(req);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
return res.status(401).json({ message: "User not authenticated" });
|
return res.status(401).json({ message: "User not authenticated" });
|
||||||
|
|
||||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||||
|
|
||||||
if (!apiKey)
|
if (!apiKey)
|
||||||
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Trello API key not set in Environment Variables" });
|
||||||
|
|
||||||
const token = req.body.token;
|
const token = req.body.token;
|
||||||
|
|
||||||
if (!token)
|
if (!token) return res.status(400).json({ message: "No token found" });
|
||||||
return res.status(400).json({ message: "No token found" });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { db } = await createNextApiContext(req);
|
const { db } = await createNextApiContext(req);
|
||||||
|
|
||||||
await db.insert(integrations).values({
|
await db
|
||||||
provider: "trello",
|
.insert(integrations)
|
||||||
userId: user.id,
|
.values({
|
||||||
accessToken: token,
|
provider: "trello",
|
||||||
expiresAt: addYears(new Date(), 1),
|
userId: user.id,
|
||||||
}).onConflictDoUpdate({
|
accessToken: token,
|
||||||
set: {
|
expiresAt: addYears(new Date(), 1),
|
||||||
accessToken: token,
|
})
|
||||||
expiresAt: addYears(new Date(), 1),
|
.onConflictDoUpdate({
|
||||||
},
|
set: {
|
||||||
target: [integrations.userId, integrations.provider],
|
accessToken: token,
|
||||||
});
|
expiresAt: addYears(new Date(), 1),
|
||||||
|
},
|
||||||
|
target: [integrations.userId, integrations.provider],
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(200).json({ message: "Trello authentication successful" });
|
return res
|
||||||
} catch (err) {
|
.status(200)
|
||||||
console.error("Trello authentication error:", err);
|
.json({ message: "Trello authentication successful" });
|
||||||
return res.status(400).json({ message: "Trello authentication failed" });
|
} catch (err) {
|
||||||
}
|
return res.status(400).json({ message: "Trello authentication failed" });
|
||||||
},
|
}
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { Novu } from "@novu/api";
|
|
||||||
import { jwtVerify } from "jose";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
|
||||||
token: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenPayloadSchema = z.object({
|
|
||||||
subscriberId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ResponseData =
|
|
||||||
| { success: true }
|
|
||||||
| { success: false; error: string; code?: string };
|
|
||||||
|
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: "Unsubscribe endpoint is not available.",
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
return res.status(405).json({
|
|
||||||
success: false,
|
|
||||||
error: "Method not allowed.",
|
|
||||||
code: "METHOD_NOT_ALLOWED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = requestSchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Invalid request payload.",
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: "Unsubscribe service is not configured.",
|
|
||||||
code: "NOT_CONFIGURED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verified = await jwtVerify(
|
|
||||||
parsedBody.data.token,
|
|
||||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
|
||||||
{
|
|
||||||
// We intentionally do not use exp/iat claims –
|
|
||||||
// tokens are long-lived and validated only by signature + payload.
|
|
||||||
clockTolerance: "0s",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
payload = tokenPayloadSchema.parse(verified.payload);
|
|
||||||
} catch {
|
|
||||||
return res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
error: "Your unsubscribe link is invalid or has expired.",
|
|
||||||
code: "INVALID_TOKEN",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await novu.subscribers.preferences.update(
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
email: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
payload.subscriberId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update Novu preferences", error);
|
|
||||||
return res.status(502).json({
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
"We could not update your email preferences right now. Please try again later.",
|
|
||||||
code: "NOVU_ERROR",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({ success: true });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { Upload } from "@aws-sdk/lib-storage";
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
import { assertPermission } from "@kan/api/utils/permissions";
|
import { assertPermission } from "@kan/api/utils/permissions";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
@@ -22,7 +23,7 @@ export const config = {
|
|||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -36,7 +37,9 @@ export default withRateLimit(
|
|||||||
|
|
||||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Attachments bucket not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardPublicId = req.query.cardPublicId;
|
const cardPublicId = req.query.cardPublicId;
|
||||||
@@ -55,15 +58,24 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing or invalid content length" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentLength > MAX_SIZE_BYTES) {
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
return res.status(400).json({ error: "File too large" });
|
return res.status(400).json({ error: "File too large" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalFilenameHeader =
|
const rawFilenameHeader =
|
||||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
const originalFilenameHeader = (() => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(rawFilenameHeader);
|
||||||
|
} catch {
|
||||||
|
return rawFilenameHeader;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const sanitizedFilename = originalFilenameHeader
|
const sanitizedFilename = originalFilenameHeader
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
@@ -129,8 +141,7 @@ export default withRateLimit(
|
|||||||
|
|
||||||
return res.status(200).json({ attachment });
|
return res.status(200).json({ attachment });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Attachment upload failed", error);
|
|
||||||
return res.status(500).json({ error: "Internal server error" });
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import { createS3Client } from "@kan/shared/utils";
|
import { createS3Client } from "@kan/shared/utils";
|
||||||
|
|
||||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
const MAX_SIZE_BYTES = parseInt(
|
||||||
|
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
|
||||||
|
10,
|
||||||
|
); // Default 2MB
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
@@ -19,7 +23,7 @@ export const config = {
|
|||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -51,15 +55,24 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing or invalid content length" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentLength > MAX_SIZE_BYTES) {
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
return res.status(400).json({ error: "File too large" });
|
return res.status(400).json({ error: "File too large" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalFilenameHeader =
|
const rawFilenameHeader =
|
||||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
const originalFilenameHeader = (() => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(rawFilenameHeader);
|
||||||
|
} catch {
|
||||||
|
return rawFilenameHeader;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const sanitizedFilename = originalFilenameHeader
|
const sanitizedFilename = originalFilenameHeader
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
@@ -93,9 +106,7 @@ export default withRateLimit(
|
|||||||
user: updatedUser,
|
user: updatedUser,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Avatar upload failed", error);
|
|
||||||
return res.status(500).json({ error: "Internal server error" });
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||||
|
|
||||||
|
export default function SelectPlanPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && !session?.user) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||||
|
router.push("/boards");
|
||||||
|
}
|
||||||
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
|
if (isPending || !session?.user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title="Select plan | kan.bn" />
|
||||||
|
<SelectPlanView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
|
||||||
|
|
||||||
|
export default function WorkspaceDetailsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && !session?.user) router.push("/login");
|
||||||
|
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
|
||||||
|
router.push("/boards");
|
||||||
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
|
if (isPending || !session?.user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title="Create workspace | kan.bn" />
|
||||||
|
<WorkspaceDetailsView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/web/src/pages/partner/activate.tsx
Normal file
117
apps/web/src/pages/partner/activate.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
|
import { Auth } from "~/components/AuthForm";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
|
||||||
|
export default function PartnerActivatePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const licenseKey = searchParams.get("license_key");
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
|
||||||
|
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
|
||||||
|
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (licenseKey) {
|
||||||
|
localStorage.setItem("partnerLicenseKey", licenseKey);
|
||||||
|
}
|
||||||
|
}, [licenseKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && session?.user && licenseKey) {
|
||||||
|
router.push(
|
||||||
|
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
|
||||||
|
);
|
||||||
|
} else if (!isPending && session?.user && !licenseKey) {
|
||||||
|
router.push("/boards");
|
||||||
|
}
|
||||||
|
}, [session, isPending, licenseKey, router]);
|
||||||
|
|
||||||
|
if (isPending || (session?.user && licenseKey)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t`Activate | 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-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
|
||||||
|
</p>
|
||||||
|
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
|
||||||
|
{isMagicLinkSent ? (
|
||||||
|
<Trans>We sent a link to {magicLinkRecipient}</Trans>
|
||||||
|
) : partnerName ? (
|
||||||
|
<Trans>
|
||||||
|
Sign in or create an account to activate your {partnerName}{" "}
|
||||||
|
license
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
t`Sign in or create an account to activate your license`
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
|
||||||
|
{t`Something went wrong during activation. Please try again.`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMagicLinkSent && (
|
||||||
|
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<Auth
|
||||||
|
setIsMagicLinkSent={(val, recipient) => {
|
||||||
|
setIsMagicLinkSent(val);
|
||||||
|
setMagicLinkRecipient(recipient);
|
||||||
|
}}
|
||||||
|
callbackURL={
|
||||||
|
licenseKey
|
||||||
|
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
|
||||||
|
: "/boards"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||||
|
<Trans>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<span className="underline">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
licenseKey
|
||||||
|
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
|
||||||
|
: "/login"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PatternedBackground />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function TrelloAuthorize() {
|
export default function TrelloAuthorize() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
const token = hash.split("=")[1];
|
const token = hash.split("=")[1];
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log("Posting token to /api/trello/authenticate", token);
|
fetch("/api/trello/authenticate", {
|
||||||
fetch("/api/trello/authenticate", {
|
method: "POST",
|
||||||
method: "POST",
|
headers: {
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
},
|
body: JSON.stringify({ token }),
|
||||||
body: JSON.stringify({ token }),
|
}).then(() => {
|
||||||
}).then(() => {
|
window.close();
|
||||||
window.close();
|
});
|
||||||
});
|
}
|
||||||
}
|
}, []);
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <div className="flex h-[200px] items-center justify-center">
|
return (
|
||||||
<p className="text-center">Connecting to Trello...</p>
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
</div>;
|
<p className="text-center">Connecting to Trello...</p>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
|
import WebhookSettings from "~/views/settings/WebhookSettings";
|
||||||
|
|
||||||
|
const WebhookSettingsPage: NextPageWithLayout = () => {
|
||||||
|
return (
|
||||||
|
<SettingsLayout currentTab="webhooks">
|
||||||
|
<WebhookSettings />
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||||
|
|
||||||
|
export default WebhookSettingsPage;
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import { PageHead } from "~/components/PageHead";
|
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
|
||||||
|
|
||||||
type UnsubscribeStatus = "idle" | "processing" | "success" | "error";
|
|
||||||
|
|
||||||
export default function UnsubscribePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [status, setStatus] = useState<UnsubscribeStatus>("idle");
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!router.isReady) return;
|
|
||||||
const value = router.query.token;
|
|
||||||
if (typeof value === "string") {
|
|
||||||
setToken(value);
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
setToken(value[0] ?? "");
|
|
||||||
} else {
|
|
||||||
setToken("");
|
|
||||||
}
|
|
||||||
}, [router.isReady, router.query.token]);
|
|
||||||
|
|
||||||
const handleUnsubscribe = async () => {
|
|
||||||
if (!token) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
t`Your unsubscribe link is missing a token. Please open the latest email and try again.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("processing");
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/unsubscribe", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const payload = (await response.json().catch(() => null)) as {
|
|
||||||
error?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
payload?.error ??
|
|
||||||
"We couldn't update your preferences. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("success");
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
t`We couldn't update your preferences. Please try again.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = t`Unsubscribe`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHead title={`${title} | kan.bn`} />
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
|
||||||
<PatternedBackground />
|
|
||||||
<div className="z-10 w-full max-w-md space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
|
||||||
{t`Do you want to unsubscribe?`}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
|
|
||||||
{t`Confirm your email preferences:`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Button
|
|
||||||
onClick={handleUnsubscribe}
|
|
||||||
disabled={status === "success"}
|
|
||||||
isLoading={status === "processing"}
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{t`Unsubscribe`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{status === "success" && (
|
|
||||||
<p className="text-center text-sm text-light-900 dark:text-dark-800">
|
|
||||||
{t`You have been unsubscribed!`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status === "error" && (
|
|
||||||
<p className="mx-auto max-w-[300px] text-center text-sm font-medium text-red-600 dark:text-red-400">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal file
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||||
|
|
||||||
|
export default function UpgradeSelectPlanPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && !session?.user) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||||
|
router.push("/boards");
|
||||||
|
}
|
||||||
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
|
if (isPending || !session?.user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title="Upgrade | kan.bn" />
|
||||||
|
<SelectPlanView />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/web/src/providers/font-size.tsx
Normal file
57
apps/web/src/providers/font-size.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type FontSize = "small" | "medium" | "large";
|
||||||
|
|
||||||
|
const fontSizeMap: Record<FontSize, string> = {
|
||||||
|
small: "14px",
|
||||||
|
medium: "16px",
|
||||||
|
large: "18px",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FontSizeContextProps {
|
||||||
|
fontSize: FontSize;
|
||||||
|
setFontSize: (size: FontSize) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FontSizeContext = createContext<FontSizeContextProps | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FontSizeProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [fontSize, setFontSizeState] = useState<FontSize>("medium");
|
||||||
|
|
||||||
|
const setFontSize = (size: FontSize) => {
|
||||||
|
document.documentElement.style.fontSize = fontSizeMap[size];
|
||||||
|
localStorage.setItem("fontSize", size);
|
||||||
|
setFontSizeState(size);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("fontSize") as FontSize | null;
|
||||||
|
const size = stored && fontSizeMap[stored] ? stored : "medium";
|
||||||
|
document.documentElement.style.fontSize = fontSizeMap[size];
|
||||||
|
setFontSizeState(size);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FontSizeContext.Provider value={{ fontSize, setFontSize }}>
|
||||||
|
{children}
|
||||||
|
</FontSizeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFontSize = (): FontSizeContextProps => {
|
||||||
|
const context = useContext(FontSizeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useFontSize must be used within a FontSizeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -20,6 +20,7 @@ import { HiXMark } from "react-icons/hi2";
|
|||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { useEventListener } from "~/hooks/useEventListener";
|
import { useEventListener } from "~/hooks/useEventListener";
|
||||||
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
|
|
||||||
const ModifierKey = {
|
const ModifierKey = {
|
||||||
CONTROL: "CONTROL",
|
CONTROL: "CONTROL",
|
||||||
@@ -160,6 +161,7 @@ export function KeyboardShortcutProvider({
|
|||||||
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
||||||
|
const { locale } = useLinguiContext();
|
||||||
|
|
||||||
const openLegendShortcut: KeyboardShortcut = useMemo(
|
const openLegendShortcut: KeyboardShortcut = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -172,7 +174,7 @@ export function KeyboardShortcutProvider({
|
|||||||
description: t`Open keyboard shortcuts`,
|
description: t`Open keyboard shortcuts`,
|
||||||
group: ShortcutGroup.GENERAL,
|
group: ShortcutGroup.GENERAL,
|
||||||
}),
|
}),
|
||||||
[setIsLegendOpen],
|
[locale],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -35,10 +35,31 @@ function detectBrowserLocale(availableLocales: readonly string[]): Locale {
|
|||||||
const browserLanguages = navigator.languages || [navigator.language];
|
const browserLanguages = navigator.languages || [navigator.language];
|
||||||
|
|
||||||
for (const browserLang of browserLanguages) {
|
for (const browserLang of browserLanguages) {
|
||||||
const langCode = browserLang.split("-")[0];
|
const normalizedLocale = browserLang.toLowerCase();
|
||||||
|
const exactLocale = availableLocales.find(
|
||||||
|
(availableLocale) => availableLocale.toLowerCase() === normalizedLocale,
|
||||||
|
);
|
||||||
|
|
||||||
if (langCode && availableLocales.includes(langCode.toLowerCase())) {
|
if (exactLocale) {
|
||||||
return langCode.toLowerCase() as Locale;
|
return exactLocale as Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageCode = normalizedLocale.split("-")[0];
|
||||||
|
|
||||||
|
if (languageCode === "zh" && availableLocales.includes("zh-CN")) {
|
||||||
|
return "zh-CN";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (languageCode === "pt" && availableLocales.includes("ptbr")) {
|
||||||
|
return "ptbr";
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseLocale = availableLocales.find(
|
||||||
|
(availableLocale) => availableLocale.toLowerCase() === languageCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (baseLocale) {
|
||||||
|
return baseLocale as Locale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +70,7 @@ export function LinguiProviderWrapper({
|
|||||||
children,
|
children,
|
||||||
initialLocale = defaultLocale,
|
initialLocale = defaultLocale,
|
||||||
}: LinguiProviderProps) {
|
}: LinguiProviderProps) {
|
||||||
const [locale, setLocale] = useState<Locale>(defaultLocale);
|
const [locale, setLocale] = useState<Locale>(initialLocale);
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
initializeI18n();
|
initializeI18n();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState } from "react";
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
|
|
||||||
interface PopupContextType {
|
interface PopupContextType {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -25,24 +25,27 @@ export const PopupProvider: React.FC<Props> = ({ children }) => {
|
|||||||
const [popupMessage, setPopupMessage] = useState("");
|
const [popupMessage, setPopupMessage] = useState("");
|
||||||
const [popupIcon, setPopupIcon] = useState("");
|
const [popupIcon, setPopupIcon] = useState("");
|
||||||
|
|
||||||
const showPopup = ({
|
const showPopup = useCallback(
|
||||||
header,
|
({
|
||||||
message,
|
header,
|
||||||
icon,
|
message,
|
||||||
}: {
|
icon,
|
||||||
header: string;
|
}: {
|
||||||
message: string;
|
header: string;
|
||||||
icon: string;
|
message: string;
|
||||||
}) => {
|
icon: string;
|
||||||
setIsOpen(true);
|
}) => {
|
||||||
setPopupHeader(header);
|
setIsOpen(true);
|
||||||
setPopupMessage(message);
|
setPopupHeader(header);
|
||||||
setPopupIcon(icon);
|
setPopupMessage(message);
|
||||||
};
|
setPopupIcon(icon);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const hidePopup = () => {
|
const hidePopup = useCallback(() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopupContext.Provider
|
<PopupContext.Provider
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ interface Workspace {
|
|||||||
description: string | null | undefined;
|
description: string | null | undefined;
|
||||||
publicId: string;
|
publicId: string;
|
||||||
slug: string | undefined;
|
slug: string | undefined;
|
||||||
plan: "free" | "pro" | "enterprise" | undefined;
|
plan: "free" | "team" | "pro" | "enterprise" | undefined;
|
||||||
role: "admin" | "member" | "guest";
|
role: "admin" | "member" | "guest";
|
||||||
|
weekStartDay: 0 | 1 | 6;
|
||||||
|
cardPrefix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialWorkspace: Workspace = {
|
const initialWorkspace: Workspace = {
|
||||||
@@ -26,15 +28,17 @@ const initialWorkspace: Workspace = {
|
|||||||
description: null,
|
description: null,
|
||||||
publicId: "",
|
publicId: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
plan: "free",
|
plan: "free" as const,
|
||||||
role: "member",
|
role: "member",
|
||||||
|
weekStartDay: 1,
|
||||||
|
cardPrefix: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
|
|
||||||
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
export const WorkspaceContext = createContext<
|
||||||
undefined,
|
WorkspaceContextProps | undefined
|
||||||
);
|
>(undefined);
|
||||||
|
|
||||||
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
@@ -48,7 +52,18 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const workspacePublicId = useSearchParams().get("workspacePublicId");
|
const workspacePublicId = useSearchParams().get("workspacePublicId");
|
||||||
|
|
||||||
const { data, isLoading } = api.workspace.all.useQuery();
|
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string | null>(
|
||||||
|
workspacePublicId,
|
||||||
|
);
|
||||||
|
const pollAttemptsRef = React.useRef(0);
|
||||||
|
const MAX_POLL_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||||
|
},
|
||||||
|
);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const switchWorkspace = (_workspace: Workspace) => {
|
const switchWorkspace = (_workspace: Workspace) => {
|
||||||
@@ -64,7 +79,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data?.length) {
|
if (!data?.length) {
|
||||||
if (!isLoading) setHasLoaded(true);
|
if (!isLoading && !isFetching) setHasLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +94,8 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
slug: workspace.slug,
|
slug: workspace.slug,
|
||||||
description: workspace.description,
|
description: workspace.description,
|
||||||
plan: workspace.plan,
|
plan: workspace.plan,
|
||||||
|
weekStartDay: workspace.weekStartDay,
|
||||||
|
cardPrefix: workspace.cardPrefix,
|
||||||
hasLoaded: true,
|
hasLoaded: true,
|
||||||
})) as Workspace[];
|
})) as Workspace[];
|
||||||
|
|
||||||
@@ -86,42 +103,72 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (storedWorkspaceId !== null) {
|
if (storedWorkspaceId !== null) {
|
||||||
const newData = data;
|
const selectedWorkspace = data.find(
|
||||||
const selectedWorkspace = newData.find(
|
|
||||||
({ workspace }) => workspace.publicId === storedWorkspaceId,
|
({ workspace }) => workspace.publicId === storedWorkspaceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!selectedWorkspace?.workspace) return;
|
if (!selectedWorkspace?.workspace) {
|
||||||
|
if (pendingWorkspaceId) {
|
||||||
|
pollAttemptsRef.current += 1;
|
||||||
|
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||||
|
setPendingWorkspaceId(null);
|
||||||
|
localStorage.removeItem("workspacePublicId");
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear stale workspacePublicId from localStorage
|
||||||
|
localStorage.removeItem("workspacePublicId");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pollAttemptsRef.current = 0;
|
||||||
|
setPendingWorkspaceId(null);
|
||||||
|
|
||||||
setWorkspace({
|
setWorkspace({
|
||||||
publicId: selectedWorkspace.workspace.publicId,
|
publicId: selectedWorkspace.workspace.publicId,
|
||||||
name: selectedWorkspace.workspace.name,
|
name: selectedWorkspace.workspace.name,
|
||||||
slug: selectedWorkspace.workspace.slug,
|
slug: selectedWorkspace.workspace.slug,
|
||||||
plan: selectedWorkspace.workspace.plan,
|
plan: selectedWorkspace.workspace.plan,
|
||||||
description: selectedWorkspace.workspace.description,
|
description: selectedWorkspace.workspace.description,
|
||||||
role: selectedWorkspace.role,
|
role: selectedWorkspace.role as "admin" | "member" | "guest",
|
||||||
});
|
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||||
|
cardPrefix: selectedWorkspace.workspace.cardPrefix,
|
||||||
|
});
|
||||||
|
|
||||||
if (workspacePublicId) {
|
if (workspacePublicId) {
|
||||||
router.push(`/boards`);
|
router.push(`/boards`);
|
||||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasLoaded(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const primaryWorkspace = data[0]?.workspace;
|
|
||||||
const primaryWorkspaceRole = data[0]?.role;
|
|
||||||
|
|
||||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
|
||||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
|
||||||
setWorkspace({
|
|
||||||
publicId: primaryWorkspace.publicId,
|
|
||||||
name: primaryWorkspace.name,
|
|
||||||
slug: primaryWorkspace.slug,
|
|
||||||
plan: primaryWorkspace.plan,
|
|
||||||
description: primaryWorkspace.description,
|
|
||||||
role: primaryWorkspaceRole,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [data, isLoading, workspacePublicId, router]);
|
|
||||||
|
const primaryWorkspace = data[0]?.workspace;
|
||||||
|
const primaryWorkspaceRole = data[0]?.role;
|
||||||
|
|
||||||
|
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||||
|
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||||
|
setWorkspace({
|
||||||
|
publicId: primaryWorkspace.publicId,
|
||||||
|
name: primaryWorkspace.name,
|
||||||
|
slug: primaryWorkspace.slug,
|
||||||
|
plan: primaryWorkspace.plan,
|
||||||
|
description: primaryWorkspace.description,
|
||||||
|
role: primaryWorkspaceRole as "admin" | "member" | "guest",
|
||||||
|
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||||
|
cardPrefix: primaryWorkspace.cardPrefix,
|
||||||
|
});
|
||||||
|
setHasLoaded(true);
|
||||||
|
}, [
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
workspacePublicId,
|
||||||
|
pendingWorkspaceId,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceContext.Provider
|
<WorkspaceContext.Provider
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
[contenteditable="true"]:empty:before {
|
[contenteditable="true"]:empty:before {
|
||||||
content: attr(placeholder);
|
content: attr(placeholder);
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
export const formatToArray = (
|
export const formatToArray = (
|
||||||
value: string | string[] | undefined,
|
value: string | string[] | undefined,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
@@ -50,5 +52,21 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
|||||||
return imageOrKey;
|
return imageOrKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Construct URL from S3 key
|
||||||
|
const useVirtualHosted = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS") === "true";
|
||||||
|
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||||
|
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||||
|
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||||
|
|
||||||
|
if (useVirtualHosted && storageDomain && bucket) {
|
||||||
|
// Virtual-hosted style: https://{bucket}.{domain}/{key}
|
||||||
|
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageUrl && bucket) {
|
||||||
|
// Path-style: {storageUrl}/{bucket}/{key}
|
||||||
|
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { i18n } from "@lingui/core";
|
|||||||
import type { Locale } from "~/locales";
|
import type { Locale } from "~/locales";
|
||||||
import { defaultLocale } from "~/locales";
|
import { defaultLocale } from "~/locales";
|
||||||
import { messages as enMessages } from "~/locales/en/messages";
|
import { messages as enMessages } from "~/locales/en/messages";
|
||||||
|
import { messages as zhCNMessages } from "~/locales/zh-CN/messages";
|
||||||
|
|
||||||
const loadMessages = async (locale: Locale) => {
|
const loadMessages = async (locale: Locale) => {
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
@@ -22,8 +23,10 @@ const loadMessages = async (locale: Locale) => {
|
|||||||
return (await import("~/locales/ru/messages")).messages;
|
return (await import("~/locales/ru/messages")).messages;
|
||||||
case "pl":
|
case "pl":
|
||||||
return (await import("~/locales/pl/messages")).messages;
|
return (await import("~/locales/pl/messages")).messages;
|
||||||
case "pt-BR":
|
case "ptbr":
|
||||||
return (await import("~/locales/pt-BR/messages")).messages;
|
return (await import("~/locales/ptbr/messages")).messages;
|
||||||
|
case "zh-CN":
|
||||||
|
return (await import("~/locales/zh-CN/messages")).messages;
|
||||||
default:
|
default:
|
||||||
return enMessages;
|
return enMessages;
|
||||||
}
|
}
|
||||||
@@ -32,11 +35,15 @@ const loadMessages = async (locale: Locale) => {
|
|||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
const loadedLocales = new Set<string>();
|
const loadedLocales = new Set<string>();
|
||||||
|
|
||||||
export function initializeI18n(locale: Locale = defaultLocale) {
|
export function initializeI18n() {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
i18n.load(defaultLocale, enMessages);
|
// 首屏同步加载中英文,避免默认语言激活前出现中英混排。
|
||||||
|
i18n.load("en", enMessages);
|
||||||
|
i18n.load("zh-CN", zhCNMessages);
|
||||||
i18n.activate(defaultLocale);
|
i18n.activate(defaultLocale);
|
||||||
loadedLocales.add(defaultLocale);
|
loadedLocales.add(defaultLocale);
|
||||||
|
loadedLocales.add("en");
|
||||||
|
loadedLocales.add("zh-CN");
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const { data } = authClient.useSession();
|
const { data } = authClient.useSession();
|
||||||
|
|
||||||
if (data?.user.id) router.push("/boards");
|
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function SignUpPage() {
|
|||||||
|
|
||||||
const { data } = authClient.useSession();
|
const { data } = authClient.useSession();
|
||||||
|
|
||||||
if (data?.user.id) router.push("/boards");
|
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||||
|
|
||||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||||
setIsMagicLinkSent(value);
|
setIsMagicLinkSent(value);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import {
|
import {
|
||||||
|
HiArrowRightOnRectangle,
|
||||||
HiEllipsisHorizontal,
|
HiEllipsisHorizontal,
|
||||||
HiLink,
|
HiLink,
|
||||||
HiOutlineDocumentDuplicate,
|
HiOutlineDocumentDuplicate,
|
||||||
@@ -29,6 +31,7 @@ export default function BoardDropdown({
|
|||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
boardName?: string;
|
boardName?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||||
@@ -47,6 +50,7 @@ export default function BoardDropdown({
|
|||||||
: t`The board has been unarchived.`,
|
: t`The board has been unarchived.`,
|
||||||
icon: "success",
|
icon: "success",
|
||||||
});
|
});
|
||||||
|
void router.push(`/boards`);
|
||||||
} else if (variables.favorite !== undefined) {
|
} else if (variables.favorite !== undefined) {
|
||||||
showPopup({
|
showPopup({
|
||||||
header: variables.favorite
|
header: variables.favorite
|
||||||
@@ -85,7 +89,7 @@ export default function BoardDropdown({
|
|||||||
const isArchiveActionPending = updateBoard.isPending;
|
const isArchiveActionPending = updateBoard.isPending;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
...(isTemplate && canCreateBoard
|
...(!isTemplate && canCreateBoard
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Make template`,
|
label: t`Make template`,
|
||||||
@@ -116,6 +120,17 @@ export default function BoardDropdown({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(!isTemplate && canEditBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Move to workspace`,
|
||||||
|
action: () => openModal("MOVE_BOARD"),
|
||||||
|
icon: (
|
||||||
|
<HiArrowRightOnRectangle className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
label: isFavorite
|
label: isFavorite
|
||||||
? t`Remove from favorites`
|
? t`Remove from favorites`
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
|
|||||||
|
|
||||||
const Card = ({
|
const Card = ({
|
||||||
title,
|
title,
|
||||||
|
ticketNumber,
|
||||||
labels,
|
labels,
|
||||||
members,
|
members,
|
||||||
checklists,
|
checklists,
|
||||||
@@ -25,6 +26,7 @@ const Card = ({
|
|||||||
dueDate,
|
dueDate,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
|
ticketNumber?: string | null;
|
||||||
labels: { name: string; colourCode: string | null }[];
|
labels: { name: string; colourCode: string | null }[];
|
||||||
members: {
|
members: {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
@@ -67,6 +69,11 @@ const Card = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||||
|
{ticketNumber && (
|
||||||
|
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
|
||||||
|
{ticketNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="break-words">{title}</span>
|
<span className="break-words">{title}</span>
|
||||||
{labels.length ||
|
{labels.length ||
|
||||||
members.length ||
|
members.length ||
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
import { DueDateSelector } from "~/views/card/components/DueDateSelector";
|
||||||
|
|
||||||
|
export function CardContextDueDateModal() {
|
||||||
|
const { entityId: cardPublicId, closeModal } = useModal();
|
||||||
|
|
||||||
|
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||||
|
{ cardPublicId: cardPublicId ?? "" },
|
||||||
|
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cardPublicId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||||
|
{t`Set due date`}
|
||||||
|
</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||||
|
) : (
|
||||||
|
<DueDateSelector
|
||||||
|
cardPublicId={cardPublicId}
|
||||||
|
dueDate={card?.dueDate ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||||
|
>
|
||||||
|
{t`Done`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import {
|
||||||
|
Listbox,
|
||||||
|
ListboxButton,
|
||||||
|
ListboxOption,
|
||||||
|
ListboxOptions,
|
||||||
|
Transition,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { HiChevronDown } from "react-icons/hi2";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Input from "~/components/Input";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
interface CardContextDuplicateModalProps {
|
||||||
|
boardPublicId?: string;
|
||||||
|
isTemplate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContextDuplicateModal({
|
||||||
|
boardPublicId: boardPublicIdProp,
|
||||||
|
isTemplate: isTemplateProp,
|
||||||
|
}: CardContextDuplicateModalProps = {}) {
|
||||||
|
const { entityId: cardPublicId, closeModal, getModalState } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const modalState = getModalState("CARD_CONTEXT_DUPLICATE") as
|
||||||
|
| { boardPublicId: string; isTemplate?: boolean }
|
||||||
|
| undefined;
|
||||||
|
const boardPublicId =
|
||||||
|
boardPublicIdProp ?? modalState?.boardPublicId ?? "";
|
||||||
|
const isTemplate = isTemplateProp ?? modalState?.isTemplate ?? false;
|
||||||
|
|
||||||
|
const [listPublicId, setListPublicId] = useState("");
|
||||||
|
const [copyLabels, setCopyLabels] = useState(true);
|
||||||
|
const [copyMembers, setCopyMembers] = useState(true);
|
||||||
|
const [copyChecklists, setCopyChecklists] = useState(true);
|
||||||
|
const [position, setPosition] = useState<string>("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
|
||||||
|
const { data: card, isLoading: isCardLoading } = api.card.byId.useQuery(
|
||||||
|
{ cardPublicId: cardPublicId ?? "" },
|
||||||
|
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const boardType = isTemplate ? "template" : "regular";
|
||||||
|
const { data: board } = api.board.byId.useQuery(
|
||||||
|
{ boardPublicId, type: boardType },
|
||||||
|
{ enabled: !!boardPublicId },
|
||||||
|
);
|
||||||
|
const lists = board?.lists ?? [];
|
||||||
|
const listOptions = lists.map((l) => ({ publicId: l.publicId, name: l.name }));
|
||||||
|
const currentListPublicId = card?.list?.publicId;
|
||||||
|
const hasLabels = (card?.labels?.length ?? 0) > 0;
|
||||||
|
const hasMembers = (card?.members?.length ?? 0) > 0;
|
||||||
|
const hasChecklists = (card?.checklists?.length ?? 0) > 0;
|
||||||
|
const hasAnyCopyOption = hasLabels || hasMembers || hasChecklists;
|
||||||
|
|
||||||
|
const duplicateCard = api.card.duplicate.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Card duplicated`,
|
||||||
|
icon: "success",
|
||||||
|
message: t`The card has been duplicated.`,
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to duplicate card`,
|
||||||
|
message: t`Please try again.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await utils.board.byId.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!cardPublicId || !listPublicId) return;
|
||||||
|
const indexNum =
|
||||||
|
position === "" ? undefined : parseInt(position, 10);
|
||||||
|
if (
|
||||||
|
position !== "" &&
|
||||||
|
(indexNum === undefined || isNaN(indexNum) || indexNum < 0)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
duplicateCard.mutate({
|
||||||
|
cardPublicId,
|
||||||
|
listPublicId,
|
||||||
|
copyLabels,
|
||||||
|
copyMembers,
|
||||||
|
copyChecklists,
|
||||||
|
...(typeof indexNum === "number" && { index: indexNum }),
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cardPublicId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="p-4">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||||
|
{t`Duplicate card`}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||||
|
{t`List`}
|
||||||
|
</label>
|
||||||
|
<Listbox value={listPublicId} onChange={setListPublicId}>
|
||||||
|
<div className="relative">
|
||||||
|
<ListboxButton
|
||||||
|
className={twMerge(
|
||||||
|
"relative w-full cursor-pointer rounded-md border border-light-300 bg-white py-2 pl-3 pr-10 text-left text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-light-400 focus:ring-offset-0 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:focus:ring-dark-500",
|
||||||
|
!listPublicId && "text-light-600 dark:text-dark-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block truncate">
|
||||||
|
{listPublicId
|
||||||
|
? listOptions.find((o) => o.publicId === listPublicId)
|
||||||
|
?.name
|
||||||
|
: t`Select a list`}
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
|
||||||
|
<HiChevronDown
|
||||||
|
className="h-4 w-4 text-light-600 dark:text-dark-600"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
<Transition
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<ListboxOptions className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-light-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-200">
|
||||||
|
<div className="max-h-60 overflow-y-auto py-1 pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||||
|
{listOptions.map((option) => {
|
||||||
|
const isCurrentList =
|
||||||
|
option.publicId === currentListPublicId;
|
||||||
|
return (
|
||||||
|
<ListboxOption
|
||||||
|
key={option.publicId}
|
||||||
|
value={option.publicId}
|
||||||
|
disabled={isCurrentList}
|
||||||
|
className={({ focus }) =>
|
||||||
|
twMerge(
|
||||||
|
"relative select-none py-2 pl-3 pr-9 text-sm",
|
||||||
|
isCurrentList
|
||||||
|
? "cursor-default opacity-50"
|
||||||
|
: "cursor-pointer",
|
||||||
|
!isCurrentList && focus
|
||||||
|
? "bg-light-200 text-light-1000 dark:bg-dark-400 dark:text-dark-1000"
|
||||||
|
: "text-light-900 dark:text-dark-900",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</ListboxOption>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ListboxOptions>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||||
|
{t`Title (optional)`}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={card?.title ?? ""}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||||
|
{t`Position in list (0 = first, leave empty for end)`}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => setPosition(e.target.value)}
|
||||||
|
placeholder={t`End of list`}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasAnyCopyOption && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{hasLabels && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={copyLabels}
|
||||||
|
onChange={(e) => setCopyLabels(e.target.checked)}
|
||||||
|
className={twMerge(
|
||||||
|
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||||
|
"cursor-pointer",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{t`Copy labels`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasMembers && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={copyMembers}
|
||||||
|
onChange={(e) => setCopyMembers(e.target.checked)}
|
||||||
|
className={twMerge(
|
||||||
|
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||||
|
"cursor-pointer",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{t`Copy members`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasChecklists && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={copyChecklists}
|
||||||
|
onChange={(e) => setCopyChecklists(e.target.checked)}
|
||||||
|
className={twMerge(
|
||||||
|
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||||
|
"cursor-pointer",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{t`Copy checklists`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={closeModal}>
|
||||||
|
{t`Cancel`}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={
|
||||||
|
!listPublicId ||
|
||||||
|
duplicateCard.isPending ||
|
||||||
|
isCardLoading ||
|
||||||
|
lists.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{duplicateCard.isPending ? t`Duplicating…` : t`Duplicate`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
|
import LabelSelector from "~/views/card/components/LabelSelector";
|
||||||
|
|
||||||
|
export function CardContextLabelsModal() {
|
||||||
|
const { entityId: cardPublicId, closeModal } = useModal();
|
||||||
|
|
||||||
|
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||||
|
{ cardPublicId: cardPublicId ?? "" },
|
||||||
|
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const boardLabels = card?.list?.board?.labels ?? [];
|
||||||
|
const selectedLabels = card?.labels ?? [];
|
||||||
|
|
||||||
|
const formattedLabels = boardLabels.map((label) => ({
|
||||||
|
key: label.publicId,
|
||||||
|
value: label.name,
|
||||||
|
selected: selectedLabels.some((l) => l.publicId === label.publicId),
|
||||||
|
leftIcon: <LabelIcon colourCode={label.colourCode} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!cardPublicId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||||
|
{t`Labels`}
|
||||||
|
</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||||
|
) : (
|
||||||
|
<LabelSelector
|
||||||
|
cardPublicId={cardPublicId}
|
||||||
|
labels={formattedLabels}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||||
|
>
|
||||||
|
{t`Done`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||||
|
import Avatar from "~/components/Avatar";
|
||||||
|
import MemberSelector from "~/views/card/components/MemberSelector";
|
||||||
|
|
||||||
|
export function CardContextMembersModal() {
|
||||||
|
const { entityId: cardPublicId, closeModal } = useModal();
|
||||||
|
|
||||||
|
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||||
|
{ cardPublicId: cardPublicId ?? "" },
|
||||||
|
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const board = card?.list?.board;
|
||||||
|
const workspaceMembers = board?.workspace?.members ?? [];
|
||||||
|
const selectedMembers = card?.members ?? [];
|
||||||
|
|
||||||
|
const formattedMembers = workspaceMembers.map((member) => {
|
||||||
|
const isSelected = selectedMembers.some(
|
||||||
|
(m) => m.publicId === member.publicId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key: member.publicId,
|
||||||
|
value: formatMemberDisplayName(
|
||||||
|
member.user?.name ?? null,
|
||||||
|
member.user?.email ?? member.email,
|
||||||
|
),
|
||||||
|
imageUrl: member.user?.image ? getAvatarUrl(member.user.image) : undefined,
|
||||||
|
selected: isSelected,
|
||||||
|
leftIcon: (
|
||||||
|
<Avatar
|
||||||
|
size="xs"
|
||||||
|
name={member.user?.name ?? ""}
|
||||||
|
imageUrl={
|
||||||
|
member.user?.image ? getAvatarUrl(member.user.image) : undefined
|
||||||
|
}
|
||||||
|
email={member.user?.email ?? member.email}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cardPublicId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||||
|
{t`Manage members`}
|
||||||
|
</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||||
|
) : (
|
||||||
|
<MemberSelector
|
||||||
|
cardPublicId={cardPublicId}
|
||||||
|
members={formattedMembers}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||||
|
>
|
||||||
|
{t`Done`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
apps/web/src/views/board/components/CardContextMenu.tsx
Normal file
179
apps/web/src/views/board/components/CardContextMenu.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
HiLink,
|
||||||
|
HiOutlineArrowRightCircle,
|
||||||
|
HiOutlineCalendar,
|
||||||
|
HiOutlineDocumentDuplicate,
|
||||||
|
HiOutlineTag,
|
||||||
|
HiOutlineTrash,
|
||||||
|
HiOutlineUserGroup,
|
||||||
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
|
||||||
|
|
||||||
|
export type CardContextMenuAction =
|
||||||
|
| "members"
|
||||||
|
| "move"
|
||||||
|
| "labels"
|
||||||
|
| "dueDate"
|
||||||
|
| "copyLink"
|
||||||
|
| "duplicate"
|
||||||
|
| "delete";
|
||||||
|
|
||||||
|
interface CardContextMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onAction: (action: CardContextMenuAction) => void;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_ITEMS: {
|
||||||
|
action: CardContextMenuAction;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
requiresEdit: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
action: "members",
|
||||||
|
label: t`Manage members`,
|
||||||
|
icon: <HiOutlineUserGroup className="h-4 w-4 shrink-0" />,
|
||||||
|
requiresEdit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "move",
|
||||||
|
label: t`Move to another list`,
|
||||||
|
icon: <HiOutlineArrowRightCircle className="h-4 w-4 shrink-0" />,
|
||||||
|
requiresEdit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "labels",
|
||||||
|
label: t`Add / edit label`,
|
||||||
|
icon: <HiOutlineTag className="h-4 w-4 shrink-0" />,
|
||||||
|
requiresEdit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "dueDate",
|
||||||
|
label: t`Set due date`,
|
||||||
|
icon: <HiOutlineCalendar className="h-4 w-4 shrink-0" />,
|
||||||
|
requiresEdit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "copyLink",
|
||||||
|
label: t`Copy link to card`,
|
||||||
|
icon: <HiLink className="h-4 w-4 shrink-0" />,
|
||||||
|
requiresEdit: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "duplicate",
|
||||||
|
label: t`Duplicate card`,
|
||||||
|
icon: <HiOutlineDocumentDuplicate className="h-4 w-4 shrink-0" />,
|
||||||
|
requiresEdit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "delete",
|
||||||
|
label: t`Delete card`,
|
||||||
|
icon: <HiOutlineTrash className="h-4 w-4 shrink-0" />,
|
||||||
|
requiresEdit: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CardContextMenu({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
onAction,
|
||||||
|
canEdit,
|
||||||
|
}: CardContextMenuProps) {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [position, setPosition] = useState({ x, y });
|
||||||
|
|
||||||
|
const items = MENU_ITEMS.filter((item) => !item.requiresEdit || canEdit);
|
||||||
|
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
const menu = menuRef.current;
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
const gutter = 8;
|
||||||
|
const updatePosition = () => {
|
||||||
|
const { width, height } = menu.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||||
|
const viewportHeight =
|
||||||
|
window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
const maxX = Math.max(gutter, viewportWidth - width - gutter);
|
||||||
|
const maxY = Math.max(gutter, viewportHeight - height - gutter);
|
||||||
|
const nextPosition = {
|
||||||
|
x: Math.min(Math.max(x, gutter), maxX),
|
||||||
|
y: Math.min(Math.max(y, gutter), maxY),
|
||||||
|
};
|
||||||
|
|
||||||
|
setPosition((currentPosition) =>
|
||||||
|
currentPosition.x === nextPosition.x &&
|
||||||
|
currentPosition.y === nextPosition.y
|
||||||
|
? currentPosition
|
||||||
|
: nextPosition,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver !== "undefined"
|
||||||
|
? new ResizeObserver(updatePosition)
|
||||||
|
: null;
|
||||||
|
resizeObserver?.observe(menu);
|
||||||
|
window.addEventListener("resize", updatePosition);
|
||||||
|
window.addEventListener("scroll", updatePosition, true);
|
||||||
|
window.visualViewport?.addEventListener("resize", updatePosition);
|
||||||
|
window.visualViewport?.addEventListener("scroll", updatePosition);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
window.removeEventListener("resize", updatePosition);
|
||||||
|
window.removeEventListener("scroll", updatePosition, true);
|
||||||
|
window.visualViewport?.removeEventListener("resize", updatePosition);
|
||||||
|
window.visualViewport?.removeEventListener("scroll", updatePosition);
|
||||||
|
};
|
||||||
|
}, [x, y, canEdit, items.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed z-[200] max-h-[calc(100dvh-1rem)] min-w-[min(200px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto overflow-x-hidden rounded-md border border-light-200 bg-white py-1 shadow-lg dark:border-dark-400 dark:bg-dark-200"
|
||||||
|
style={{ left: position.x, top: position.y }}
|
||||||
|
>
|
||||||
|
{items.map(({ action, label, icon }) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onAction(action);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 break-words px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user