Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfd1f3d03 | ||
|
|
392be38f10 | ||
|
|
ec1b5d5acb | ||
|
|
316f148663 | ||
|
|
67050be27f | ||
|
|
ff4448ebf4 | ||
|
|
dd061c6d00 | ||
|
|
014fdbb7d8 | ||
|
|
500fe8af95 | ||
|
|
e7383ec670 | ||
|
|
5586c26c12 | ||
|
|
2d7097eb83 | ||
|
|
2aa898da00 | ||
|
|
a01a826bac | ||
|
|
a29ea76024 | ||
|
|
e1c6bfd6cd | ||
|
|
c272c38f6b | ||
|
|
02d3d300fb | ||
|
|
0bd28f9f95 | ||
|
|
d0c2314ebf | ||
|
|
268871c348 | ||
|
|
2e11199764 | ||
|
|
fc6bb22400 | ||
|
|
1c75a948f9 | ||
|
|
3a48c797b7 | ||
|
|
b838e8040a | ||
|
|
f0e3c4c3e2 | ||
|
|
d6b0b5cd80 | ||
|
|
1707557bea | ||
|
|
f6114a56af | ||
|
|
c1d0bb3c93 | ||
|
|
09ae4292ac | ||
|
|
75e89f118f | ||
|
|
dd6cdfc208 | ||
|
|
7758bd77ea | ||
|
|
294ed2ffc2 | ||
|
|
e2786f31b0 | ||
|
|
bfc69996c6 | ||
|
|
96f2835bab | ||
|
|
53426bf155 | ||
|
|
17c2a1b8ec | ||
|
|
d203f834cc | ||
|
|
75ad0d8f98 | ||
|
|
4066e6bba7 | ||
|
|
8aa4aa2a7c | ||
|
|
43164a0d8a | ||
|
|
8474601b2e | ||
|
|
d768b1e6ba | ||
|
|
6a7f2f148d | ||
|
|
cbc90c3002 | ||
|
|
efe83ec8a9 | ||
|
|
d1d37840fc | ||
|
|
4135e316f4 | ||
|
|
805ef8cf43 | ||
|
|
e590de80c0 | ||
|
|
f64549f743 | ||
|
|
ce2ef1b1d4 | ||
|
|
56b0161c6f | ||
|
|
314053fa95 | ||
|
|
f41736c1aa | ||
|
|
e53efe80c5 | ||
|
|
2043a8f98c | ||
|
|
116fefcaf3 | ||
|
|
f88bd82044 | ||
|
|
6eb17529f1 | ||
|
|
4b7f58aeeb | ||
|
|
86a01074b9 |
73
.env.example
73
.env.example
@@ -1,23 +1,76 @@
|
||||
POSTGRES_URL=
|
||||
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
|
||||
|
||||
EMAIL_FROM=
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||
|
||||
# Only required if deploying from compose
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# SMTP (optional)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||
SMTP_SECURE= # set to "false" to use port 587
|
||||
|
||||
NEXT_PUBLIC_BASE_URL=
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=
|
||||
# Auth config (optional)
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
|
||||
# Integration providers (optional)
|
||||
TRELLO_APP_API_KEY=
|
||||
TRELLO_APP_SECRET=
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
GITLAB_ISSUER=
|
||||
MICROSOFT_CLIENT_ID=
|
||||
MICROSOFT_CLIENT_SECRET=
|
||||
TWITTER_CLIENT_ID=
|
||||
TWITTER_CLIENT_SECRET=
|
||||
KICK_CLIENT_ID=
|
||||
KICK_CLIENT_SECRET=
|
||||
ZOOM_CLIENT_ID=
|
||||
ZOOM_CLIENT_SECRET=
|
||||
DROPBOX_CLIENT_ID=
|
||||
DROPBOX_CLIENT_SECRET=
|
||||
VK_CLIENT_ID=
|
||||
VK_CLIENT_SECRET=
|
||||
LINKEDIN_CLIENT_ID=
|
||||
LINKEDIN_CLIENT_SECRET=
|
||||
REDDIT_CLIENT_ID=
|
||||
REDDIT_CLIENT_SECRET=
|
||||
ROBLOX_CLIENT_ID=
|
||||
ROBLOX_CLIENT_SECRET=
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
TIKTOK_CLIENT_ID=
|
||||
TIKTOK_CLIENT_SECRET=
|
||||
TIKTOK_CLIENT_KEY=
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_CLIENT_SECRET=
|
||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||
|
||||
|
||||
8
.github/workflows/docker-publish.yml
vendored
8
.github/workflows/docker-publish.yml
vendored
@@ -66,6 +66,13 @@ jobs:
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
@@ -76,6 +83,7 @@ jobs:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,3 +46,7 @@ dist/
|
||||
|
||||
# docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# runtime env
|
||||
__ENV.js
|
||||
i18n.cache
|
||||
|
||||
85
CHANGELOG.md
Normal file
85
CHANGELOG.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.2...HEAD)
|
||||
|
||||
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28
|
||||
|
||||
### Added
|
||||
|
||||
- Localization and multi-language support
|
||||
- URLs are now rendered as clickable links in card descriptions
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved email configuration with optional SMTP authentication
|
||||
|
||||
## [0.2.1](https://github.com/kanbn/kan/compare/v0.2.0...v0.2.1) - 2025-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- PostHog analytics integration
|
||||
- Debug logging for email functionality
|
||||
- Standalone mode support for Next.js
|
||||
- Environment variable for configuring web port
|
||||
- Docker Compose setup with PostgreSQL
|
||||
|
||||
### Changed
|
||||
|
||||
- SMTP authentication is now optional
|
||||
- Added `SMTP_SECURE` configuration option
|
||||
- Improved Docker build process with better context handling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Default output mode for Next.js builds
|
||||
- Container environment variable configuration
|
||||
- Docker Compose volume naming
|
||||
- Build context and static file copying in Docker
|
||||
- Comment rendering inside contenteditable elements
|
||||
|
||||
## [0.2.0](https://github.com/kanbn/kan/compare/v0.1.0...v0.2.0) - 2025-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- Trello integration with OAuth authentication and user field mapping
|
||||
- Board visibility controls with dropdown menu and success notifications
|
||||
- Markdown editor for card descriptions
|
||||
- Email/password authentication for self-hosters
|
||||
- Account deletion functionality with cascade delete for workspaces
|
||||
- Enhanced scrollbar styles for dark mode
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified auth configuration to use `NEXT_PUBLIC_BASE_URL` instead of `BETTER_AUTH_URL`
|
||||
- Updated self-hosting documentation
|
||||
- Improved database schema with relation names
|
||||
|
||||
### Fixed
|
||||
|
||||
- List buttons stacking issue
|
||||
- Editor onChange prop is now optional
|
||||
- Renamed markdown prop to content in Editor component
|
||||
- Router navigation for disabled signup redirects
|
||||
|
||||
## [0.1.0](https://github.com/kanbn/kan/releases/tag/v0.1.0) - 2025-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of Kan
|
||||
- Core project management features
|
||||
- Board, list, and card management
|
||||
- User authentication system
|
||||
- Workspace collaboration
|
||||
- Comment system
|
||||
- Activity logging
|
||||
- Docker deployment support
|
||||
- Self-hosting capabilities
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated license link in footer
|
||||
121
README.md
121
README.md
@@ -45,6 +45,70 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
||||
- [React Email](https://react.email/?ref=kan.bn)
|
||||
|
||||
## Self Hosting 🐳
|
||||
|
||||
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: kan-web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
environment:
|
||||
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
||||
BETTER_AUTH_SECRET: your_auth_secret
|
||||
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: kan-db
|
||||
environment:
|
||||
POSTGRES_DB: kan_db
|
||||
POSTGRES_USER: kan
|
||||
POSTGRES_PASSWORD: your_postgres_password
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
|
||||
networks:
|
||||
kan-network:
|
||||
|
||||
volumes:
|
||||
kan_postgres_data:
|
||||
```
|
||||
|
||||
2. Start the containers in detached mode:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Access Kan at http://localhost:3000
|
||||
|
||||
The application will be running in the background. You can manage the containers using these commands:
|
||||
|
||||
- To stop the containers: `docker compose down`
|
||||
- To view logs: `docker compose logs -f`
|
||||
- To restart the containers: `docker compose restart`
|
||||
|
||||
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
|
||||
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
1. Clone the repository (or fork)
|
||||
@@ -74,27 +138,36 @@ pnpm dev
|
||||
|
||||
## Environment Variables 🔐
|
||||
|
||||
| Variable | Description | Required | Example |
|
||||
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | Yes | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | Yes | `re_xxxx` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `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` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| Variable | Description | Required | Example |
|
||||
| -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
@@ -102,6 +175,12 @@ See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||
|
||||
## Contributors 👥
|
||||
|
||||
<a href="https://github.com/kanbn/kan/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||
</a>
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
@@ -3,31 +3,25 @@ title: Trello
|
||||
description: "Import your Trello boards to Kan."
|
||||
---
|
||||
|
||||
## Getting your Trello API key and token
|
||||
## Authorizing Kan.bn for Trello
|
||||
|
||||
Before you can import your Trello boards, you need to get your Trello API key and token. Follow these steps (only takes a couple of minutes):
|
||||
Before you can import your Trello boards, you need to authorize Kan.bn for Trello. Follow these steps (only takes a couple of minutes):
|
||||
|
||||
1. **Sign in to your Trello account**
|
||||
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
|
||||
1. **Authorize Kan.bn for Trello**
|
||||
Go to [https://kan.bn/settings](https://kan.bn/settings) and click **Connect Trello**.
|
||||
|
||||
2. **Create a new integration**
|
||||
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
|
||||
2. **Log in to your Trello account**
|
||||
You will be redirected to Trello to log in to your Trello account.
|
||||
|
||||
- **Name**: Choose a name for your integration (e.g. "Kan Import")
|
||||
- **Workspace**: Select the workspace containing the boards you want to import
|
||||
- **Email**: Enter your email address
|
||||
- **Support email**: Enter your email address
|
||||
- **Author**: Add your name
|
||||
3. **Authorize Kan.bn for Trello**
|
||||
Approve the permissions and you will be redirected back to Kan.bn.
|
||||
|
||||
3. **Get a Trello API Key**
|
||||
Now click **Generate a new API key** to create an API key.
|
||||
|
||||
4. **Generate a Trello Token**
|
||||
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
|
||||
4. **Verify the connection**
|
||||
You should see a **Disconnect Trello** button.
|
||||
|
||||
## Importing your Trello boards
|
||||
|
||||
Once you have your API key and token, importing your Trello boards is easy. Follow these steps:
|
||||
Once you have authorized Kan.bn for Trello, importing your Trello boards is easy. Follow these steps:
|
||||
|
||||
1. **Create a new import in Kan**
|
||||
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
|
||||
@@ -35,8 +29,5 @@ Once you have your API key and token, importing your Trello boards is easy. Foll
|
||||
2. **Select Trello as the Import Source**
|
||||
Choose **Trello** from the list of import options and click **Select source**.
|
||||
|
||||
3. **Enter Your Trello API Key and Token**
|
||||
Paste your API key and token into the provided fields and click **Fetch boards** (don't worry, we don't store your key or token).
|
||||
|
||||
4. **Start the Import**
|
||||
3. **Start the Import**
|
||||
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.
|
||||
|
||||
@@ -25,7 +25,11 @@ Get started with Kan by choosing your preferred deployment option below.
|
||||
Get started instantly with our hosted solution - we'll handle all the
|
||||
hosting, scaling, and maintenance for you.
|
||||
</Card>
|
||||
<Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
|
||||
<Card
|
||||
title="Self-host"
|
||||
icon="code"
|
||||
href="https://github.com/kanbn/kan?tab=readme-ov-file#self-hosting-"
|
||||
>
|
||||
For full data ownership and control, you can self-host Kan on your
|
||||
infrastructure.
|
||||
</Card>
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
RUN turbo prune --scope=${PROJECT} --docker
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
@@ -53,8 +53,14 @@ RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfi
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
|
||||
RUN pnpm build --filter=${PROJECT}
|
||||
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||
|
||||
# 4. Final image - runner stage to run the application
|
||||
FROM base AS runner
|
||||
ARG APP_DIRNAME
|
||||
@@ -75,5 +81,5 @@ ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]
|
||||
|
||||
|
||||
23
apps/web/entrypoint.sh
Normal file
23
apps/web/entrypoint.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Run migrations from the Turborepo root context
|
||||
echo "Running database migrations..."
|
||||
# Navigate to the Turborepo root to run the db:migrate command
|
||||
pnpm --prefix /app db:migrate
|
||||
|
||||
# Check if migration was successful
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "\nDatabase migration failed! Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "\nStarting Next.js application..."
|
||||
|
||||
# Check if we should use standalone mode
|
||||
if [ "$NEXT_PUBLIC_USE_STANDALONE_OUTPUT" = "true" ]; then
|
||||
echo "Starting in standalone mode..."
|
||||
exec node .next/standalone/apps/web/server.js
|
||||
else
|
||||
echo "Starting in standard mode..."
|
||||
exec pnpm start
|
||||
fi
|
||||
13
apps/web/i18n.json
Normal file
13
apps/web/i18n.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
}
|
||||
398
apps/web/i18n.lock
Normal file
398
apps/web/i18n.lock
Normal file
@@ -0,0 +1,398 @@
|
||||
version: 1
|
||||
checksums:
|
||||
8033ed03a24d7c4898c90a5365297c20:
|
||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
"%231%20Hacker%20News/singular": de271b412c6c022d5c427d29fc70e0ba
|
||||
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
|
||||
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
||||
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
||||
Add%20description...%20(type%20'%2F'%20to%20open%20commands)/singular: df99ea599521682e9ad0a90716955dc6
|
||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
||||
added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2
|
||||
added%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1caba6b6bca4c6fa1ab173d241059dc2
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
|
||||
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
|
||||
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
|
||||
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
||||
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
|
||||
assigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20to%20the%20card/singular: 4fb1c419a78a0f010a3e5790801551fc
|
||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
|
||||
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
|
||||
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
|
||||
Board/singular: 101ff39aab674c033c15ab978d0d8bac
|
||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
||||
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
|
||||
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
||||
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
||||
Board%20URL%20must%20be%20at%20least%203%20characters%20long/singular: 105f5b4f3b606baf7b5302117022bd68
|
||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
|
||||
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
||||
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
||||
Comments/singular: 502237cd1044491c48494aa60967a985
|
||||
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
|
||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
|
||||
Contact/singular: 9afa39bc47019ee6dec6c74b6273967c
|
||||
Contact%20us/singular: 694a45bc5c96f6f3a27873c1cf5a7707
|
||||
Content%20Creation/singular: 4e6be22ade0188f408471325fe363575
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
|
||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20URLs%20are%20a%20premium%20feature.%20You'll%20be%20directed%20to%20upgrade%20your%20account./singular: b52e4f6122cf739c9551d29beb89b25e
|
||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
|
||||
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
|
||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
Design/singular: 991b75727b6784c1a063a7462b76186d
|
||||
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
|
||||
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
|
||||
Display%20name/singular: 725f331065e9d594d720d07b1ae51a90
|
||||
Display%20name%20cannot%20exceed%20280%20characters/singular: 426ddbcc87590e9d028bdca4e585c549
|
||||
Display%20name%20must%20be%20at%20least%203%20characters%20long/singular: 84178b79845a70937af5e26020403052
|
||||
Display%20name%20updated/singular: 4e27503ca24ef5f009713951ca9e5b97
|
||||
Do%20you%20offer%20a%20free%20plan%3F/singular: 3ef16b0994f0d9ae17067782ca9bed1b
|
||||
docs/singular: 307bff5678ada918da1f1ec3edc76cac
|
||||
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
|
||||
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
||||
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
||||
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
|
||||
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
|
||||
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
|
||||
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
|
||||
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
|
||||
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
|
||||
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
|
||||
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
|
||||
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
|
||||
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
|
||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
||||
Features/singular: 341ff316a339b106a178f0b8d362951b
|
||||
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
||||
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
|
||||
Filter/singular: 626325a05e4c8800f7ede7012b0cadaf
|
||||
Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
|
||||
Free/singular: 0326365539c004f6088656f692602078
|
||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
|
||||
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
||||
Get%20started%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
|
||||
Get%20started%20on%20Cloud/singular: ed926f526266ad2063283c58e6f4284a
|
||||
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
|
||||
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
How%20do%20I%20get%20a%20custom%20URL%3F/singular: 5b28fb95969c0dd19e1b9c9ab8a5fb29
|
||||
How%20do%20I%20import%20my%20Trello%20boards%3F/singular: 44b30a727150cb93364af0a10346012a
|
||||
How%20do%20I%20invite%20team%20members%3F/singular: e620622060a06288e86ccda35395db87
|
||||
How%20do%20I%20self-host%3F/singular: ec0b6afc8ad2f581b512c0fd82926b75
|
||||
How%20is%20the%20project%20funded%3F/singular: ba7e936f7b1b5faa47df59a0ef990092
|
||||
I%20acknowledge%20that%20all%20of%20my%20account%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: 2ffc824d0b1f9e883c2e87ed4b8b40b6
|
||||
I%20acknowledge%20that%20all%20of%20the%20workspace%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: fe1183a7439299210d8ab7379b8f4d01
|
||||
Ideas/singular: 27c24b56895defecd10b0171b39bc93f
|
||||
Ideas%20to%20improve%20this%20page.../singular: 285decc2cc28403fae043d9b4de97a00
|
||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
||||
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
|
||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
||||
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
||||
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
||||
Light/singular: bb78ea2c6edd8662676c81a8a13ecb93
|
||||
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
|
||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||
moved%20the%20card%20from%20%3C0%3E%7BfromList%7D%3C%2F0%3E%20to%3C1%3E%7BtoList%7D%3C%2F1%3E/singular: c2d0d72ad97510344b51154ef15060e0
|
||||
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
|
||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||
New%20label/singular: 890040da52a88c16c249a4ac78980f81
|
||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
||||
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
|
||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||
Private/singular: d740d0660e68c61db1853489356030ae
|
||||
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
|
||||
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
|
||||
Public/singular: c834f11dab0881632aded3f01d4f8fa9
|
||||
Publishing/singular: d9c8862c0180d9b7bd1272d5dc0d3ac3
|
||||
Question/singular: 0576462ce60d4263d7c482463fcc9547
|
||||
Questions%3F/singular: c7c3a47f8b230ce7a43f3c090f21d683
|
||||
Quick%20Win/singular: a03ff4c0896f14b87455510bab203511
|
||||
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
|
||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||
removed%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1e7ebc1866136352bdc3cd90afc083f0
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Revoke/singular: be57685a85b6dfeaeb6eab4e9560b520
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
|
||||
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
|
||||
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
|
||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||
Send/singular: 76a8497d7b546628b03bb81d5c1ce995
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
|
||||
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
|
||||
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
|
||||
Sign%20up%20with%20/singular: 103e5d75506e6835b471695d799ff580
|
||||
Simple%20pricing/singular: 5aa2bece93a71947b268498a58062937
|
||||
Simple%2C%20visual%20task%20management%20that%20just%20works.%20Drag%20and%20drop%20cards%2C%20collaborate%20with%20your%20team%2C%20and%20get%20more%20done./singular: f4f138f83017d43dab2882cc08e07bdb
|
||||
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
|
||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||
Success/singular: c43827becada6750f7a25890905f38b9
|
||||
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
||||
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
|
||||
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
|
||||
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
|
||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
||||
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
|
||||
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
|
||||
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
|
||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
||||
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||
unassigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20from%20the%20card/singular: 105d4eeb32044126b8dde243bad11d56
|
||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
|
||||
Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20No%20credit%20card%20required./singular: 8d9733137deb26664d76b55ad561c42c
|
||||
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
|
||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||
updated%20the%20title%20to%20%3C0%3E%7BtoTitle%7D%3C%2F0%3E/singular: 70cdf681ec2b6ef8e14397bc1f93f945
|
||||
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
|
||||
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
|
||||
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
|
||||
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
|
||||
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||
User/singular: 61073457a5c3901084b557d065f876be
|
||||
Video/singular: 8050c90e4289b105a0780f0fdda6ff66
|
||||
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
|
||||
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
|
||||
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
|
||||
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
|
||||
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
|
||||
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
|
||||
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
|
||||
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
|
||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
|
||||
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20time%20(and%20atlassification)%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 60a55e9bd16c3d1beff1a6c7633c5911
|
||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
Workspace%20deleted/singular: bc5c267ce3f04de94ff1379cf2ca48a3
|
||||
Workspace%20description/singular: 7159d620b7ef5c5afef734989a5be08b
|
||||
Workspace%20description%20cannot%20exceed%20280%20characters/singular: d0e83b9a0304d7c36cf8c85d98783ce2
|
||||
Workspace%20description%20must%20be%20at%20least%203%20characters%20long/singular: 5ebac407715e6569adc381ebbb4f32d4
|
||||
Workspace%20description%20updated/singular: 19ddb6ee9ead26982a90dee426f3ee1f
|
||||
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
||||
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
||||
Workspace%20name%20cannot%20exceed%2024%20characters/singular: c8b8f79fb83abf8329cc34c1f51fcfc9
|
||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
|
||||
Yearly/singular: 87f43e016c19cb25860f456549a2f431
|
||||
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
|
||||
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
|
||||
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
|
||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
||||
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
||||
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
|
||||
Your%20workspace%20description%20has%20been%20updated./singular: 98de6f65c567c6654cc0adce3c9d5656
|
||||
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
||||
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
||||
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
||||
15
apps/web/lingui.config.ts
Normal file
15
apps/web/lingui.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
path: "src/locales/{locale}/messages",
|
||||
include: ["src"],
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -10,6 +10,10 @@ configureRuntimeEnv();
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
output:
|
||||
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
|
||||
? "standalone"
|
||||
: undefined,
|
||||
reactStrictMode: true,
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
@@ -26,10 +30,27 @@ const config = {
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
images: {
|
||||
domains: [env("NEXT_PUBLIC_STORAGE_DOMAIN") ?? ""],
|
||||
remotePatterns: [
|
||||
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: process.env.S3_FORCE_PATH_STYLE === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
// instrumentationHook: true,
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm next build",
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev | pino-pretty",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
"with-env": "dotenv -e ../../.env --",
|
||||
"lingui:extract": "lingui extract",
|
||||
"lingui:compile": "lingui compile --typescript",
|
||||
"lingui:extract:watch": "lingui extract --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
@@ -22,8 +25,19 @@
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@lingui/babel-preset-react": "^2.9.2",
|
||||
"@lingui/conf": "^5.3.2",
|
||||
"@lingui/macro": "^5.3.2",
|
||||
"@lingui/react": "^5.3.2",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
"@tiptap/starter-kit": "^2.14.0",
|
||||
"@tiptap/suggestion": "^2.14.0",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "^11.0.0-rc.660",
|
||||
"@trpc/react-query": "catalog:",
|
||||
@@ -31,22 +45,23 @@
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "^14.2.15",
|
||||
"next": "^15.3.4",
|
||||
"next-logger": "^5.0.1",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-js": "^1.254.0",
|
||||
"react": "catalog:react18",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-dom": "catalog:react18",
|
||||
"react-hook-form": "^7.51.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-lottie-player": "^1.5.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,7 +70,9 @@
|
||||
"@kan/stripe": "workspace:*",
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@types/node": "^20.17.7",
|
||||
"@types/react": "catalog:react18",
|
||||
"@types/react-beautiful-dnd": "^13.1.7",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
window.__ENV = {"NEXT_PUBLIC_UMAMI_ID":"aaed1f55-25e2-4223-b918-e429c390be35","NEXT_PUBLIC_KAN_ENV":"cloud","NEXT_PUBLIC_BASE_URL":"http://localhost:3000","NEXT_PUBLIC_AVATAR_BUCKET_NAME":"avatars","NEXT_PUBLIC_STORAGE_DOMAIN":"storage.kanbn.com","NEXT_PUBLIC_STORAGE_URL":"https://storage.kanbn.com"};
|
||||
@@ -1,115 +1,340 @@
|
||||
import type { SocialProvider } from "better-auth/social-providers";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { FaGoogle } from "react-icons/fa";
|
||||
import {
|
||||
FaApple,
|
||||
FaDiscord,
|
||||
FaDropbox,
|
||||
FaFacebook,
|
||||
FaGithub,
|
||||
FaGitlab,
|
||||
FaGoogle,
|
||||
FaLinkedin,
|
||||
FaMicrosoft,
|
||||
FaReddit,
|
||||
FaSpotify,
|
||||
FaTiktok,
|
||||
FaTwitch,
|
||||
FaTwitter,
|
||||
FaVk,
|
||||
} from "react-icons/fa";
|
||||
import { SiRoblox, SiZoom } from "react-icons/si";
|
||||
import { TbBrandKick } from "react-icons/tb";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
interface FormValues {
|
||||
name?: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({ email: z.string().email() });
|
||||
const EmailSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
||||
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
|
||||
useState(false);
|
||||
const availableSocialProviders = {
|
||||
google: {
|
||||
id: "google",
|
||||
name: "Google",
|
||||
icon: FaGoogle,
|
||||
},
|
||||
github: {
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: FaGithub,
|
||||
},
|
||||
discord: {
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
icon: FaDiscord,
|
||||
},
|
||||
apple: {
|
||||
id: "apple",
|
||||
name: "Apple",
|
||||
icon: FaApple,
|
||||
},
|
||||
microsoft: {
|
||||
id: "microsoft",
|
||||
name: "Microsoft",
|
||||
icon: FaMicrosoft,
|
||||
},
|
||||
facebook: {
|
||||
id: "facebook",
|
||||
name: "Facebook",
|
||||
icon: FaFacebook,
|
||||
},
|
||||
spotify: {
|
||||
id: "spotify",
|
||||
name: "Spotify",
|
||||
icon: FaSpotify,
|
||||
},
|
||||
twitch: {
|
||||
id: "twitch",
|
||||
name: "Twitch",
|
||||
icon: FaTwitch,
|
||||
},
|
||||
twitter: {
|
||||
id: "twitter",
|
||||
name: "Twitter",
|
||||
icon: FaTwitter,
|
||||
},
|
||||
dropbox: {
|
||||
id: "dropbox",
|
||||
name: "Dropbox",
|
||||
icon: FaDropbox,
|
||||
},
|
||||
linkedin: {
|
||||
id: "linkedin",
|
||||
name: "LinkedIn",
|
||||
icon: FaLinkedin,
|
||||
},
|
||||
gitlab: {
|
||||
id: "gitlab",
|
||||
name: "GitLab",
|
||||
icon: FaGitlab,
|
||||
},
|
||||
tiktok: {
|
||||
id: "tiktok",
|
||||
name: "TikTok",
|
||||
icon: FaTiktok,
|
||||
},
|
||||
reddit: {
|
||||
id: "reddit",
|
||||
name: "Reddit",
|
||||
icon: FaReddit,
|
||||
},
|
||||
roblox: {
|
||||
id: "roblox",
|
||||
name: "Roblox",
|
||||
icon: SiRoblox,
|
||||
},
|
||||
vk: {
|
||||
id: "vk",
|
||||
name: "VK",
|
||||
icon: FaVk,
|
||||
},
|
||||
kick: {
|
||||
id: "kick",
|
||||
name: "Kick",
|
||||
icon: TbBrandKick,
|
||||
},
|
||||
zoom: {
|
||||
id: "zoom",
|
||||
name: "Zoom",
|
||||
icon: SiZoom,
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | SocialProvider>(null);
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(EmailSchema),
|
||||
});
|
||||
|
||||
const handleLoginWithEmail = async (email: string) => {
|
||||
const { data: socialProviders } = useQuery({
|
||||
queryKey: ["social_providers"],
|
||||
queryFn: () => authClient.getSocialProviders(),
|
||||
});
|
||||
|
||||
const handleLoginWithEmail = async (
|
||||
email: string,
|
||||
password?: string,
|
||||
name?: string,
|
||||
) => {
|
||||
setIsLoginWithEmailPending(true);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
email,
|
||||
if (password) {
|
||||
if (isSignUp && name) {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: t`Success`,
|
||||
message: t`You have been signed up successfully.`,
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: t`Success`,
|
||||
message: t`You have been logged in successfully.`,
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
};
|
||||
|
||||
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||
setIsLoginWithProviderPending(provider);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
setIsLoginWithProviderPending(null);
|
||||
|
||||
if (error) {
|
||||
setLoginError(
|
||||
"Something went wrong, please try again later or contact customer support.",
|
||||
t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
||||
);
|
||||
} else {
|
||||
setIsMagicLinkSent(true, email);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithGoogle = async () => {
|
||||
setIsLoginWithGooglePending(true);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithGooglePending(false);
|
||||
|
||||
if (error) {
|
||||
setLoginError("Failed to login with Google. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await handleLoginWithEmail(values.email);
|
||||
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||
};
|
||||
|
||||
const password = watch("password");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleLoginWithGoogle}
|
||||
isLoading={isLoginWithGooglePending}
|
||||
iconLeft={<FaGoogle />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">or</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(availableSocialProviders).map(([key, provider]) => {
|
||||
if (!socialProviders?.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
<Trans>Continue with {provider.name}</Trans>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
<div className="mt-[1.5rem]">
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
fullWidth
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
Continue with email
|
||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||
? t`email`
|
||||
: t`magic link`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,74 @@
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
TbLayoutSidebarRightCollapse,
|
||||
TbLayoutSidebarRightExpand,
|
||||
} from "react-icons/tb";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import FeedbackButton from "./FeedbackButton";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = api.user.getUser.useQuery();
|
||||
interface DashboardProps {
|
||||
children: React.ReactNode;
|
||||
rightPanel?: React.ReactNode;
|
||||
hasRightPanel?: boolean;
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
children,
|
||||
rightPanel,
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
|
||||
const sideNavRef = useRef<HTMLDivElement>(null);
|
||||
const rightPanelRef = useRef<HTMLDivElement>(null);
|
||||
const sideNavButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const rightPanelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const toggleSideNav = () => {
|
||||
setIsSideNavOpen(!isSideNavOpen);
|
||||
if (!isSideNavOpen) {
|
||||
setIsRightPanelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRightPanel = () => {
|
||||
setIsRightPanelOpen(!isRightPanelOpen);
|
||||
if (!isRightPanelOpen) {
|
||||
setIsSideNavOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useClickOutside(sideNavRef, (event) => {
|
||||
if (sideNavButtonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (isSideNavOpen) {
|
||||
setIsSideNavOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
useClickOutside(rightPanelRef, (event) => {
|
||||
if (rightPanelButtonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (isRightPanelOpen) {
|
||||
setIsRightPanelOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -13,26 +76,95 @@ export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
html {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 320px;
|
||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#161616"};
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
||||
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
|
||||
<div className="flex h-screen flex-col items-center bg-light-100 dark:bg-dark-50 md:min-w-[800px]">
|
||||
<div className="flex h-12 min-h-12 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400 md:h-16 md:min-h-16">
|
||||
<div className="my-auto flex w-full items-center justify-between">
|
||||
<Link href="/">
|
||||
<button
|
||||
ref={sideNavButtonRef}
|
||||
onClick={toggleSideNav}
|
||||
className="text-neutral-900 dark:text-dark-1000 md:hidden"
|
||||
>
|
||||
{isSideNavOpen ? (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Link href="/" className="hidden md:block">
|
||||
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<FeedbackButton />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasRightPanel && (
|
||||
<button
|
||||
ref={rightPanelButtonRef}
|
||||
onClick={toggleRightPanel}
|
||||
className="text-neutral-900 dark:text-dark-1000 md:hidden"
|
||||
>
|
||||
{isRightPanelOpen ? (
|
||||
<TbLayoutSidebarRightCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarRightExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<FeedbackButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full">
|
||||
<SideNavigation
|
||||
user={{ email: data?.email, image: data?.image }}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="w-full overflow-hidden">{props.children}</div>
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
<div
|
||||
ref={sideNavRef}
|
||||
className={`fixed left-0 top-12 z-50 h-[calc(100dvh-3rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 w-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-y-auto">{children}</div>
|
||||
|
||||
{/* Mobile Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className={`fixed right-0 top-12 z-50 h-[calc(100dvh-3rem)] w-80 transform bg-light-200 transition-transform duration-300 ease-in-out dark:bg-dark-100 md:hidden ${
|
||||
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full overflow-y-auto">{rightPanel}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div className="hidden md:block">{rightPanel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
431
apps/web/src/components/Editor.tsx
Normal file
431
apps/web/src/components/Editor.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionOptions,
|
||||
} from "@tiptap/suggestion";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import {
|
||||
BubbleMenu,
|
||||
EditorContent,
|
||||
Extension,
|
||||
ReactRenderer,
|
||||
useEditor,
|
||||
} from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import {
|
||||
HiH1,
|
||||
HiH2,
|
||||
HiH3,
|
||||
HiOutlineBold,
|
||||
HiOutlineChatBubbleLeftEllipsis,
|
||||
HiOutlineCodeBracket,
|
||||
HiOutlineCodeBracketSquare,
|
||||
HiOutlineItalic,
|
||||
HiOutlineListBullet,
|
||||
HiOutlineNumberedList,
|
||||
HiOutlineStrikethrough,
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
slashSuggestion: {
|
||||
setSlashSuggestion: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlashCommandItem {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SlashCommandsOptions {
|
||||
suggestion?: Partial<SuggestionOptions>;
|
||||
commandItems?: SlashCommandItem[];
|
||||
options?: any;
|
||||
}
|
||||
|
||||
function filterSlashCommandItems(items: SlashCommandItem[], query: string) {
|
||||
return items.filter((item) =>
|
||||
item.title.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RenderSuggestionsProps {
|
||||
editor: TiptapEditor;
|
||||
clientRect: () => DOMRect;
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}
|
||||
|
||||
const CommandsList = forwardRef<
|
||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||
{
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}
|
||||
>(({ items, command }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const item = items[selectedIndex];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||
<div className="max-h-[350px] overflow-y-auto p-1">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
onClick={() => command(item)}
|
||||
className={twMerge(
|
||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||
)}
|
||||
>
|
||||
<span className="text-dark-700 dark:text-dark-800">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CommandsList.displayName = "CommandsList";
|
||||
|
||||
const RenderSuggestions = () => {
|
||||
let reactRenderer: ReactRenderer;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props: RenderSuggestionsProps) => {
|
||||
reactRenderer = new ReactRenderer(CommandsList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate(props: RenderSuggestionsProps) {
|
||||
reactRenderer.updateProps(props);
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown(props: SuggestionKeyDownProps): boolean {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(
|
||||
reactRenderer.ref as {
|
||||
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
|
||||
}
|
||||
).onKeyDown?.(props) ?? false
|
||||
);
|
||||
},
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
reactRenderer.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
||||
name: "slash-commands",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
props.command({ editor, range });
|
||||
},
|
||||
items: ({ query }: { query: string }) => {
|
||||
return filterSlashCommandItems(
|
||||
this.parent().commandItems ?? [],
|
||||
query,
|
||||
);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReturnType<typeof RenderSuggestions>;
|
||||
return {
|
||||
onStart: (props: any) => {
|
||||
component = RenderSuggestions();
|
||||
component.onStart(props);
|
||||
},
|
||||
onUpdate(props: any) {
|
||||
component.onUpdate(props);
|
||||
},
|
||||
onKeyDown(props: any) {
|
||||
if (props.event.key === "Escape") {
|
||||
return true;
|
||||
}
|
||||
return component.onKeyDown(props) ?? false;
|
||||
},
|
||||
onExit: () => {
|
||||
component.onExit();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
commandItems: [] as SlashCommandItem[],
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
render: RenderSuggestions,
|
||||
} as SuggestionOptions),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export interface SlashNodeAttrs {
|
||||
id: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const CommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Editor({
|
||||
content,
|
||||
onChange,
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: t`Add description... (type '/' to open commands)`,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
HTMLAttributes: {
|
||||
class: "text-blue-600 hover:text-blue-800 underline cursor-pointer",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
},
|
||||
validate: (href) => /^https?:\/\//.test(href),
|
||||
autolink: true,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: CommandItems,
|
||||
suggestion: {
|
||||
items: ({ query }: { query: string }) =>
|
||||
filterSlashCommandItems(CommandItems, query),
|
||||
startOfLine: true,
|
||||
char: "/",
|
||||
},
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||
onBlur: ({ event }) => {
|
||||
if (
|
||||
document
|
||||
.querySelector(".tippy-box")
|
||||
?.contains(event.relatedTarget as Node)
|
||||
)
|
||||
return;
|
||||
// Only trigger onBlur if the click was outside both the editor and menu
|
||||
if (!containerRef.current?.contains(event.relatedTarget as Node)) {
|
||||
onBlur?.();
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||
},
|
||||
},
|
||||
editable: !readOnly,
|
||||
injectCSS: false,
|
||||
},
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<style jsx global>{`
|
||||
.tiptap p.is-empty::before {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tiptap p {
|
||||
margin: 0 0 1rem 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorBubbleMenu({ editor }: { editor: TiptapEditor | null }) {
|
||||
const isMac = navigator.platform.includes("Mac");
|
||||
|
||||
const bubbleMenuItems = [
|
||||
{
|
||||
title: "Bold",
|
||||
icon: <HiOutlineBold />,
|
||||
keys: ["meta", "b"],
|
||||
onClick: () => editor?.chain().focus().toggleBold().run(),
|
||||
active: editor?.isActive("bold"),
|
||||
},
|
||||
{
|
||||
title: "Italic",
|
||||
icon: <HiOutlineItalic />,
|
||||
keys: ["meta", "i"],
|
||||
onClick: () => editor?.chain().focus().toggleItalic().run(),
|
||||
active: editor?.isActive("italic"),
|
||||
},
|
||||
{
|
||||
title: "Strikethrough",
|
||||
icon: <HiOutlineStrikethrough />,
|
||||
keys: ["meta", "shift", "s"],
|
||||
onClick: () => editor?.chain().focus().toggleStrike().run(),
|
||||
active: editor?.isActive("strike"),
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
icon: <HiOutlineCodeBracket />,
|
||||
keys: ["meta", "e"],
|
||||
onClick: () => editor?.chain().focus().toggleCode().run(),
|
||||
active: editor?.isActive("code"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<BubbleMenu editor={editor}>
|
||||
<div className="flex items-center gap-2 rounded-md border border-light-600 bg-light-50 p-1 dark:border-dark-600 dark:bg-dark-50">
|
||||
{bubbleMenuItems.map((item) => (
|
||||
<Button
|
||||
key={item.title}
|
||||
className={twMerge(
|
||||
"rounded p-1 text-light-900 focus:ring-2 focus:ring-light-600 dark:text-dark-900 dark:focus:ring-dark-600",
|
||||
item.active && "bg-light-100 dark:bg-dark-400",
|
||||
)}
|
||||
title={`${item.title} [${item.keys.join(" + ").replace("meta", isMac ? "⌘" : "ctrl")}]`}
|
||||
onClick={item.onClick}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -34,15 +35,15 @@ const FeedbackButton: React.FC = () => {
|
||||
onSuccess: async () => {
|
||||
reset();
|
||||
showPopup({
|
||||
header: "Feedback sent",
|
||||
message: "Thank you for your feedback!",
|
||||
header: t`Feedback sent`,
|
||||
message: t`Thank you for your feedback!`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: async () => {
|
||||
showPopup({
|
||||
header: "Unable to send feedback",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to send feedback`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -74,7 +75,7 @@ const FeedbackButton: React.FC = () => {
|
||||
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
|
||||
isPlaying={isHovered}
|
||||
/>
|
||||
<span className="ml-1">Feedback</span>
|
||||
<span className="ml-1">{t`Feedback`}</span>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +95,7 @@ const FeedbackButton: React.FC = () => {
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
|
||||
<Input
|
||||
placeholder="Ideas to improve this page..."
|
||||
placeholder={t`Ideas to improve this page...`}
|
||||
onChange={(e) => {
|
||||
setValue("feedback", e.target.value);
|
||||
}}
|
||||
@@ -112,28 +113,28 @@ const FeedbackButton: React.FC = () => {
|
||||
<div className="flex flex-row items-center justify-between pt-2">
|
||||
<div>
|
||||
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
|
||||
Need help?{" "}
|
||||
{t`Need help?`}{" "}
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
Contact us
|
||||
{t`Contact us`}
|
||||
</Link>
|
||||
, or see our{" "}
|
||||
{t`, or see our`}{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
docs
|
||||
{t`docs`}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="justify-end">
|
||||
<Button size="sm" isLoading={createFeedback.isPending}>
|
||||
Send
|
||||
{t`Send`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="relative flex">
|
||||
{prefix && (
|
||||
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
|
||||
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-sm dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
@@ -67,6 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
className={twMerge(
|
||||
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
||||
prefix && "rounded-l-none",
|
||||
type === "password" && "pr-8",
|
||||
className && className,
|
||||
)}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -75,7 +76,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
{type === "password" && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-light-900 dark:text-dark-900"
|
||||
tabIndex={-1}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
|
||||
@@ -114,7 +115,7 @@ export function LabelForm({
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-medium">
|
||||
{isEdit ? "Edit label" : "New label"}
|
||||
{isEdit ? t`Edit label` : t`New label`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -130,7 +131,7 @@ export function LabelForm({
|
||||
|
||||
<Input
|
||||
id="label-name"
|
||||
placeholder="Name"
|
||||
placeholder={t`Name`}
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -208,7 +209,7 @@ export function LabelForm({
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isEdit && (
|
||||
<Toggle
|
||||
label="Create another"
|
||||
label={t`Create another`}
|
||||
isChecked={!!isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||
@@ -222,14 +223,14 @@ export function LabelForm({
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_LABEL", entityId)}
|
||||
>
|
||||
Delete
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||
>
|
||||
{isEdit ? "Update label" : "Create label"}
|
||||
{isEdit ? t`Update label` : t`Create label`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
26
apps/web/src/components/LanguageSelector.tsx
Normal file
26
apps/web/src/components/LanguageSelector.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { HiLanguage } from "react-icons/hi2";
|
||||
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { localeNames } from "~/locales";
|
||||
|
||||
export function LanguageSelector() {
|
||||
const { locale, setLocale, availableLocales } = useLocalisation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<HiLanguage className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<select
|
||||
id="language-select"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as any)}
|
||||
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500 sm:text-sm"
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{localeNames[loc]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
@@ -18,10 +19,12 @@ export function NewWorkspaceForm() {
|
||||
const { showPopup } = usePopup();
|
||||
const { switchWorkspace } = useWorkspace();
|
||||
const { register, handleSubmit } = useForm<FormValues>();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: (values) => {
|
||||
if (values.publicId && values.name) {
|
||||
utils.workspace.all.invalidate();
|
||||
switchWorkspace({
|
||||
publicId: values.publicId,
|
||||
name: values.name,
|
||||
@@ -34,8 +37,8 @@ export function NewWorkspaceForm() {
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to create workspace",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to create workspace`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -58,7 +61,7 @@ export function NewWorkspaceForm() {
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
New workspace
|
||||
{t`New workspace`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -74,7 +77,7 @@ export function NewWorkspaceForm() {
|
||||
|
||||
<Input
|
||||
id="workspace-name"
|
||||
placeholder="Workspace name"
|
||||
placeholder={t`Workspace name`}
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -87,7 +90,7 @@ export function NewWorkspaceForm() {
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit" isLoading={createWorkspace.isPending}>
|
||||
Create workspace
|
||||
{t`Create workspace`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,10 @@ export const PageHead = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import boardsIconDark from "~/assets/boards-dark.json";
|
||||
import boardsIconLight from "~/assets/boards-light.json";
|
||||
@@ -35,17 +36,17 @@ export default function SideNavigation({
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Boards",
|
||||
name: t`Boards`,
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
},
|
||||
{
|
||||
name: "Members",
|
||||
name: t`Members`,
|
||||
href: "/members",
|
||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
name: t`Settings`,
|
||||
href: "/settings",
|
||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||
},
|
||||
@@ -53,7 +54,7 @@ export default function SideNavigation({
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
|
||||
<nav className="flex h-full w-64 flex-col justify-between border-r border-light-600 bg-light-50 px-3 pb-3 pt-5 dark:border-dark-400 dark:bg-dark-50">
|
||||
<div>
|
||||
<WorkspaceMenu />
|
||||
<ul role="list" className="space-y-1">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
@@ -81,7 +82,7 @@ export default function UserMenu({
|
||||
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
||||
<div className="p-1">
|
||||
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
||||
<span>Theme</span>
|
||||
<span>{t`Theme`}</span>
|
||||
</div>
|
||||
<Menu.Item>
|
||||
<button
|
||||
@@ -94,7 +95,7 @@ export default function UserMenu({
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
System
|
||||
{t`System`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -108,7 +109,7 @@ export default function UserMenu({
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
Dark
|
||||
{t`Dark`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
@@ -122,7 +123,7 @@ export default function UserMenu({
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
Light
|
||||
{t`Light`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
@@ -132,7 +133,7 @@ export default function UserMenu({
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
Logout
|
||||
{t`Logout`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment } from "react";
|
||||
import { HiCheck } from "react-icons/hi2";
|
||||
|
||||
@@ -81,7 +82,7 @@ export default function WorkspaceMenu() {
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
Create workspace
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
|
||||
@@ -15,19 +15,62 @@ export const env = createEnv({
|
||||
*/
|
||||
server: {
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
BETTER_AUTH_URL: z.string(),
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) =>
|
||||
!s ||
|
||||
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||
)
|
||||
.optional(),
|
||||
POSTGRES_URL: z.string().url(),
|
||||
TRELLO_APP_API_KEY: z.string().optional(),
|
||||
TRELLO_APP_SECRET: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
DISCORD_CLIENT_ID: z.string().optional(),
|
||||
DISCORD_CLIENT_SECRET: z.string().optional(),
|
||||
GITHUB_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
GITLAB_CLIENT_ID: z.string().optional(),
|
||||
GITLAB_CLIENT_SECRET: z.string().optional(),
|
||||
GITLAB_ISSUER: z.string().optional(),
|
||||
MICROSOFT_CLIENT_ID: z.string().optional(),
|
||||
MICROSOFT_CLIENT_SECRET: z.string().optional(),
|
||||
TWITTER_CLIENT_ID: z.string().optional(),
|
||||
TWITTER_CLIENT_SECRET: z.string().optional(),
|
||||
KICK_CLIENT_ID: z.string().optional(),
|
||||
KICK_CLIENT_SECRET: z.string().optional(),
|
||||
ZOOM_CLIENT_ID: z.string().optional(),
|
||||
ZOOM_CLIENT_SECRET: z.string().optional(),
|
||||
DROPBOX_CLIENT_ID: z.string().optional(),
|
||||
DROPBOX_CLIENT_SECRET: z.string().optional(),
|
||||
VK_CLIENT_ID: z.string().optional(),
|
||||
VK_CLIENT_SECRET: z.string().optional(),
|
||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||
REDDIT_CLIENT_ID: z.string().optional(),
|
||||
REDDIT_CLIENT_SECRET: z.string().optional(),
|
||||
ROBLOX_CLIENT_ID: z.string().optional(),
|
||||
ROBLOX_CLIENT_SECRET: z.string().optional(),
|
||||
SPOTIFY_CLIENT_ID: z.string().optional(),
|
||||
SPOTIFY_CLIENT_SECRET: z.string().optional(),
|
||||
TIKTOK_CLIENT_ID: z.string().optional(),
|
||||
TIKTOK_CLIENT_SECRET: z.string().optional(),
|
||||
TIKTOK_CLIENT_KEY: z.string().optional(),
|
||||
TWITCH_CLIENT_ID: z.string().optional(),
|
||||
TWITCH_CLIENT_SECRET: z.string().optional(),
|
||||
APPLE_CLIENT_ID: z.string().optional(),
|
||||
APPLE_CLIENT_SECRET: z.string().optional(),
|
||||
APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
|
||||
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
EMAIL_FROM: z.string(),
|
||||
EMAIL_URL: z.string(),
|
||||
EMAIL_TOKEN: z.string(),
|
||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -37,10 +80,27 @@ export const env = createEnv({
|
||||
client: {
|
||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
@@ -49,10 +109,16 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
|
||||
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||
|
||||
59
apps/web/src/hooks/useClickOutside.ts
Normal file
59
apps/web/src/hooks/useClickOutside.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import { useEventListener } from "./useEventListener";
|
||||
|
||||
/** Supported event types. */
|
||||
type EventType =
|
||||
| "mousedown"
|
||||
| "mouseup"
|
||||
| "touchstart"
|
||||
| "touchend"
|
||||
| "focusin"
|
||||
| "focusout";
|
||||
|
||||
/**
|
||||
* Custom hook that handles clicks outside a specified element.
|
||||
* @template T - The type of the element's reference.
|
||||
* @param {RefObject<T> | RefObject<T>[]} ref - The React ref object(s) representing the element(s) to watch for outside clicks.
|
||||
* @param {(event: MouseEvent | TouchEvent | FocusEvent) => void} handler - The callback function to be executed when a click outside the element occurs.
|
||||
* @param {EventType} [eventType] - The mouse event type to listen for (optional, default is 'mousedown').
|
||||
* @param {?AddEventListenerOptions} [eventListenerOptions] - The options object to be passed to the `addEventListener` method (optional).
|
||||
* @returns {void}
|
||||
* @example
|
||||
* ```tsx
|
||||
* const containerRef = useRef(null);
|
||||
* useClickOutside([containerRef], () => {
|
||||
* // Handle clicks outside the container.
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T> | RefObject<T>[],
|
||||
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
|
||||
eventType: EventType = "mousedown",
|
||||
eventListenerOptions: AddEventListenerOptions = {},
|
||||
): void {
|
||||
useEventListener(
|
||||
eventType,
|
||||
(event) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
// Do nothing if the target is not connected element with document
|
||||
if (!target.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOutside = Array.isArray(ref)
|
||||
? ref
|
||||
.filter((r) => Boolean(r.current))
|
||||
.every((r) => r.current && !r.current.contains(target))
|
||||
: ref.current && !ref.current.contains(target);
|
||||
|
||||
if (isOutside) {
|
||||
handler(event);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
eventListenerOptions,
|
||||
);
|
||||
}
|
||||
120
apps/web/src/hooks/useEventListener.ts
Normal file
120
apps/web/src/hooks/useEventListener.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
|
||||
|
||||
// MediaQueryList Event based useEventListener interface
|
||||
function useEventListener<K extends keyof MediaQueryListEventMap>(
|
||||
eventName: K,
|
||||
handler: (event: MediaQueryListEventMap[K]) => void,
|
||||
element: RefObject<MediaQueryList>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
// Window Event based useEventListener interface
|
||||
function useEventListener<K extends keyof WindowEventMap>(
|
||||
eventName: K,
|
||||
handler: (event: WindowEventMap[K]) => void,
|
||||
element?: undefined,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
// Element Event based useEventListener interface
|
||||
function useEventListener<
|
||||
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
|
||||
T extends Element = K extends keyof HTMLElementEventMap
|
||||
? HTMLDivElement
|
||||
: SVGElement,
|
||||
>(
|
||||
eventName: K,
|
||||
handler:
|
||||
| ((event: HTMLElementEventMap[K]) => void)
|
||||
| ((event: SVGElementEventMap[K]) => void),
|
||||
element: RefObject<T>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
// Document Event based useEventListener interface
|
||||
function useEventListener<K extends keyof DocumentEventMap>(
|
||||
eventName: K,
|
||||
handler: (event: DocumentEventMap[K]) => void,
|
||||
element: RefObject<Document>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
|
||||
* @template KW - The type of event for window events.
|
||||
* @template KH - The type of event for HTML or SVG element events.
|
||||
* @template KM - The type of event for media query list events.
|
||||
* @template T - The type of the DOM element (default is `HTMLElement`).
|
||||
* @param {KW | KH | KM} eventName - The name of the event to listen for.
|
||||
* @param {(event: WindowEventMap[KW] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | MediaQueryListEventMap[KM] | Event) => void} handler - The event handler function.
|
||||
* @param {RefObject<T>} [element] - The DOM element or media query list to attach the event listener to (optional).
|
||||
* @param {boolean | AddEventListenerOptions} [options] - An options object that specifies characteristics about the event listener (optional).
|
||||
* @public
|
||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-event-listener)
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Example 1: Attach a window event listener
|
||||
* useEventListener('resize', handleResize);
|
||||
* ```
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Example 2: Attach a document event listener with options
|
||||
* const elementRef = useRef(document);
|
||||
* useEventListener('click', handleClick, elementRef, { capture: true });
|
||||
* ```
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Example 3: Attach an element event listener
|
||||
* const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
* useEventListener('click', handleButtonClick, buttonRef);
|
||||
* ```
|
||||
*/
|
||||
function useEventListener<
|
||||
KW extends keyof WindowEventMap,
|
||||
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
|
||||
KM extends keyof MediaQueryListEventMap,
|
||||
T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
|
||||
>(
|
||||
eventName: KW | KH | KM,
|
||||
handler: (
|
||||
event:
|
||||
| WindowEventMap[KW]
|
||||
| HTMLElementEventMap[KH]
|
||||
| SVGElementEventMap[KH]
|
||||
| MediaQueryListEventMap[KM]
|
||||
| Event,
|
||||
) => void,
|
||||
element?: RefObject<T>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) {
|
||||
// Create a ref that stores handler
|
||||
const savedHandler = useRef(handler);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
savedHandler.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
// Define the listening target
|
||||
const targetElement: T | Window = element?.current ?? window;
|
||||
|
||||
if (!(targetElement && targetElement.addEventListener)) return;
|
||||
|
||||
// Create event listener that calls handler function stored in ref
|
||||
const listener: typeof handler = (event) => {
|
||||
savedHandler.current(event);
|
||||
};
|
||||
|
||||
targetElement.addEventListener(eventName, listener, options);
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
targetElement.removeEventListener(eventName, listener, options);
|
||||
};
|
||||
}, [eventName, element, options]);
|
||||
}
|
||||
|
||||
export { useEventListener };
|
||||
15
apps/web/src/hooks/useIsomorphicLayoutEffect.ts
Normal file
15
apps/web/src/hooks/useIsomorphicLayoutEffect.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook that uses either `useLayoutEffect` or `useEffect` based on the environment (client-side or server-side).
|
||||
* @param {Function} effect - The effect function to be executed.
|
||||
* @param {Array<any>} [dependencies] - An array of dependencies for the effect (optional).
|
||||
* @example
|
||||
* ```tsx
|
||||
* useIsomorphicLayoutEffect(() => {
|
||||
* // Code to be executed during the layout phase on the client side
|
||||
* }, [dependency1, dependency2]);
|
||||
* ```
|
||||
*/
|
||||
export const useIsomorphicLayoutEffect =
|
||||
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
23
apps/web/src/hooks/useLocalisation.ts
Normal file
23
apps/web/src/hooks/useLocalisation.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { useLinguiContext } from "~/providers/lingui";
|
||||
import { activateLocale } from "~/utils/i18n";
|
||||
|
||||
export function useLocalisation() {
|
||||
const { i18n } = useLingui();
|
||||
const { locale, setLocale, availableLocales } = useLinguiContext();
|
||||
|
||||
const handleSetLocale = async (newLocale: Locale) => {
|
||||
await activateLocale(newLocale);
|
||||
setLocale(newLocale);
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
setLocale: handleSetLocale,
|
||||
availableLocales,
|
||||
formatDate: i18n.date,
|
||||
formatNumber: i18n.number,
|
||||
};
|
||||
}
|
||||
1699
apps/web/src/locales/de/messages.po
Normal file
1699
apps/web/src/locales/de/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/de/messages.ts
Normal file
1
apps/web/src/locales/de/messages.ts
Normal file
File diff suppressed because one or more lines are too long
1748
apps/web/src/locales/en/messages.po
Normal file
1748
apps/web/src/locales/en/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/en/messages.ts
Normal file
1
apps/web/src/locales/en/messages.ts
Normal file
File diff suppressed because one or more lines are too long
1699
apps/web/src/locales/es/messages.po
Normal file
1699
apps/web/src/locales/es/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/es/messages.ts
Normal file
1
apps/web/src/locales/es/messages.ts
Normal file
File diff suppressed because one or more lines are too long
1699
apps/web/src/locales/fr/messages.po
Normal file
1699
apps/web/src/locales/fr/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/fr/messages.ts
Normal file
1
apps/web/src/locales/fr/messages.ts
Normal file
File diff suppressed because one or more lines are too long
14
apps/web/src/locales/index.ts
Normal file
14
apps/web/src/locales/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const locales = ["en", "fr", "de", "es", "it", "nl"] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "en";
|
||||
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
en: "English",
|
||||
fr: "Français",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
nl: "Nederlands",
|
||||
};
|
||||
1699
apps/web/src/locales/it/messages.po
Normal file
1699
apps/web/src/locales/it/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/it/messages.ts
Normal file
1
apps/web/src/locales/it/messages.ts
Normal file
File diff suppressed because one or more lines are too long
1699
apps/web/src/locales/nl/messages.po
Normal file
1699
apps/web/src/locales/nl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/nl/messages.ts
Normal file
1
apps/web/src/locales/nl/messages.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -1,9 +1,16 @@
|
||||
import "~/styles/globals.css";
|
||||
import "~/utils/i18n";
|
||||
|
||||
import type { AppType } from "next/app";
|
||||
import { Viewport } from "next";
|
||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { env } from "next-runtime-env";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||
import { ModalProvider } from "~/providers/modal";
|
||||
import { PopupProvider } from "~/providers/popup";
|
||||
import { ThemeProvider } from "~/providers/theme";
|
||||
@@ -20,7 +27,29 @@ export const metadata = {
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
|
||||
|
||||
useEffect(() => {
|
||||
if (posthogKey) {
|
||||
posthog.init(posthogKey, {
|
||||
api_host: env("NEXT_PUBLIC_POSTHOG_HOST"),
|
||||
person_profiles: "identified_only",
|
||||
defaults: "2025-05-24",
|
||||
loaded: (posthog) => {
|
||||
if (process.env.NODE_ENV === "development") posthog.debug();
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [posthogKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
@@ -32,7 +61,7 @@ const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
}
|
||||
`}</style>
|
||||
{env("NEXT_PUBLIC_UMAMI_ID") && (
|
||||
<script
|
||||
<Script
|
||||
defer
|
||||
src="https://cloud.umami.is/script.js"
|
||||
data-website-id={env("NEXT_PUBLIC_UMAMI_ID")}
|
||||
@@ -40,13 +69,21 @@ const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
)}
|
||||
<script src="/__ENV.js" />
|
||||
<main className="font-sans">
|
||||
<ThemeProvider>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
<Component {...pageProps} />
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
<LinguiProviderWrapper>
|
||||
<ThemeProvider>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
<Component {...pageProps} />
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
<Component {...pageProps} />
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</LinguiProviderWrapper>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,6 @@ import { createDrizzleClient } from "@kan/db/client";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
const auth = initAuth(createDrizzleClient());
|
||||
export const auth = initAuth(createDrizzleClient());
|
||||
|
||||
export default toNodeHandler(auth.handler);
|
||||
|
||||
51
apps/web/src/pages/api/trello/authenticate.ts
Normal file
51
apps/web/src/pages/api/trello/authenticate.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
|
||||
if (!apiKey)
|
||||
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
|
||||
|
||||
const token = req.body.token;
|
||||
|
||||
if (!token)
|
||||
return res.status(400).json({ message: "No token found" });
|
||||
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
await db.insert(integrations).values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
}).onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "next-runtime-env";
|
||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
@@ -44,6 +46,7 @@ export default async function handler(
|
||||
const client = new S3Client({
|
||||
region: env.S3_REGION ?? "",
|
||||
endpoint: env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
@@ -54,8 +57,9 @@ export default async function handler(
|
||||
// @ts-ignore
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import Dashboard from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import CardView from "~/views/card";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import CardView, { CardRightPanel } from "~/views/card";
|
||||
|
||||
export default function CardPage() {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard>
|
||||
<Dashboard hasRightPanel rightPanel={<CardRightPanel />}>
|
||||
<CardView />
|
||||
</Dashboard>
|
||||
<Popup />
|
||||
|
||||
24
apps/web/src/pages/settings/trello/authorize.tsx
Normal file
24
apps/web/src/pages/settings/trello/authorize.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function TrelloAuthorize() {
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const token = hash.split("=")[1];
|
||||
if (token) {
|
||||
console.log("Posting token to /api/trello/authenticate", token);
|
||||
fetch("/api/trello/authenticate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <div className="flex h-[200px] items-center justify-center">
|
||||
<p className="text-center">Connecting to Trello...</p>
|
||||
</div>;
|
||||
}
|
||||
88
apps/web/src/providers/lingui.tsx
Normal file
88
apps/web/src/providers/lingui.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { defaultLocale, locales } from "~/locales";
|
||||
import { activateLocale, i18n, initializeI18n } from "~/utils/i18n";
|
||||
|
||||
interface LinguiContextType {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
availableLocales: Locale[];
|
||||
}
|
||||
|
||||
const LinguiContext = createContext<LinguiContextType | undefined>(undefined);
|
||||
|
||||
export function useLinguiContext() {
|
||||
const context = useContext(LinguiContext);
|
||||
if (!context) {
|
||||
throw new Error("useLinguiContext must be used within a LinguiProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface LinguiProviderProps {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}
|
||||
|
||||
function detectBrowserLocale(availableLocales: readonly string[]): Locale {
|
||||
if (typeof navigator === "undefined") {
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
const browserLanguages = navigator.languages || [navigator.language];
|
||||
|
||||
for (const browserLang of browserLanguages) {
|
||||
const langCode = browserLang.split("-")[0];
|
||||
|
||||
if (langCode && availableLocales.includes(langCode.toLowerCase())) {
|
||||
return langCode.toLowerCase() as Locale;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export function LinguiProviderWrapper({
|
||||
children,
|
||||
initialLocale = defaultLocale,
|
||||
}: LinguiProviderProps) {
|
||||
const [locale, setLocale] = useState<Locale>(defaultLocale);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
initializeI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const savedLocale = localStorage.getItem("locale") as Locale;
|
||||
|
||||
if (savedLocale && locales.includes(savedLocale)) {
|
||||
setLocale(savedLocale);
|
||||
} else {
|
||||
const detectedLocale = detectBrowserLocale(locales);
|
||||
setLocale(detectedLocale);
|
||||
}
|
||||
setIsHydrated(true);
|
||||
}, [initialLocale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHydrated && locale !== defaultLocale) {
|
||||
activateLocale(locale).then(() => {
|
||||
localStorage.setItem("locale", locale);
|
||||
});
|
||||
} else if (isHydrated) {
|
||||
localStorage.setItem("locale", locale);
|
||||
}
|
||||
}, [locale, isHydrated]);
|
||||
|
||||
return (
|
||||
<LinguiContext.Provider
|
||||
value={{ locale, setLocale, availableLocales: [...locales] }}
|
||||
>
|
||||
<I18nProvider i18n={i18n} key={locale}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
</LinguiContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
[contenteditable=true]:empty:before {
|
||||
[contenteditable="true"]:empty:before {
|
||||
content: attr(placeholder);
|
||||
display: block;
|
||||
color: #707070;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.gradient-border::before {
|
||||
@@ -18,7 +19,7 @@
|
||||
transparent 110deg,
|
||||
transparent 360deg
|
||||
);
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
width: 200%;
|
||||
|
||||
@@ -45,6 +45,10 @@ export const formatMemberDisplayName = (
|
||||
return localPart.replace(/[_-]/g, ".");
|
||||
};
|
||||
|
||||
export const getAvatarUrl = (key: string) => {
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${key}`;
|
||||
export const getAvatarUrl = (imageOrKey: string) => {
|
||||
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
};
|
||||
|
||||
53
apps/web/src/utils/i18n.ts
Normal file
53
apps/web/src/utils/i18n.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { defaultLocale } from "~/locales";
|
||||
import { messages as enMessages } from "~/locales/en/messages";
|
||||
|
||||
const loadMessages = async (locale: Locale) => {
|
||||
switch (locale) {
|
||||
case "en":
|
||||
return enMessages;
|
||||
case "fr":
|
||||
return (await import("~/locales/fr/messages")).messages;
|
||||
case "de":
|
||||
return (await import("~/locales/de/messages")).messages;
|
||||
case "es":
|
||||
return (await import("~/locales/es/messages")).messages;
|
||||
case "it":
|
||||
return (await import("~/locales/it/messages")).messages;
|
||||
case "nl":
|
||||
return (await import("~/locales/nl/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
};
|
||||
|
||||
let isInitialized = false;
|
||||
const loadedLocales = new Set<string>();
|
||||
|
||||
export function initializeI18n(locale: Locale = defaultLocale) {
|
||||
if (!isInitialized) {
|
||||
i18n.load(defaultLocale, enMessages);
|
||||
i18n.activate(defaultLocale);
|
||||
loadedLocales.add(defaultLocale);
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
return i18n;
|
||||
}
|
||||
|
||||
export async function activateLocale(locale: Locale) {
|
||||
if (!loadedLocales.has(locale)) {
|
||||
const messages = await loadMessages(locale);
|
||||
i18n.load(locale, messages);
|
||||
loadedLocales.add(locale);
|
||||
}
|
||||
|
||||
i18n.activate(locale);
|
||||
return i18n;
|
||||
}
|
||||
|
||||
initializeI18n();
|
||||
|
||||
export { i18n };
|
||||
@@ -1,15 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
// import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
// const router = useRouter();
|
||||
const router = useRouter();
|
||||
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
@@ -18,19 +22,13 @@ export default function LoginPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
// const authCookieExists = document.cookie
|
||||
// .split("; ")
|
||||
// .some((cookie) => cookie.includes("auth-token"));
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||
// enabled: authCookieExists ? true : false,
|
||||
// });
|
||||
|
||||
// if (data?.id) router.push("/boards");
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Login | kan.bn" />
|
||||
<PageHead title={t`Login | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
@@ -40,12 +38,15 @@ export default function LoginPage() {
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? "Check your inbox" : "Welcome back"}
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Welcome back`}
|
||||
</p>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
|
||||
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||
<Trans>
|
||||
Click on the link we've sent to {magicLinkRecipient} to sign
|
||||
in.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -55,12 +56,16 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
</span>
|
||||
</p>
|
||||
{!isSignUpDisabled && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
// import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
|
||||
export default function SignupPage() {
|
||||
// const router = useRouter();
|
||||
export default function SignUpPage() {
|
||||
const router = useRouter();
|
||||
const isSignUpDisabled = env("NEXT_PUBLIC_DISABLE_SIGN_UP") === "true";
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
// const authCookieExists = document.cookie
|
||||
// .split("; ")
|
||||
// .some((cookie) => cookie.includes("auth-token"));
|
||||
|
||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||
// enabled: authCookieExists ? true : false,
|
||||
// });
|
||||
|
||||
// if (data?.id) router.push("/boards");
|
||||
if (isSignUpDisabled) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{t`Sign up disabled`}
|
||||
</p>
|
||||
<p className="text-md text-center text-light-1000 dark:text-dark-1000">
|
||||
{t`Sign up is currently disabled. Please try again later.`}
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Sign up | kan.bn" />
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
@@ -40,26 +64,31 @@ export default function SignupPage() {
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? "Check your inbox" : "Create your account"}
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Get started`}
|
||||
</p>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
|
||||
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||
<Trans>
|
||||
Click on the link we've sent to {magicLinkRecipient} to sign
|
||||
in.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<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={handleMagicLinkSent} />
|
||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} isSignUp />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/login">Sign in</Link>
|
||||
</span>
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/login">Sign in</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
@@ -11,12 +12,12 @@ export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Edit board URL",
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: "Delete board",
|
||||
label: t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiMiniXMark,
|
||||
HiOutlineTag,
|
||||
@@ -89,7 +90,7 @@ const Filters = ({
|
||||
? [
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
label: t`Members`,
|
||||
icon: <HiOutlineUserCircle size={16} />,
|
||||
items: formattedMembers,
|
||||
},
|
||||
@@ -97,7 +98,7 @@ const Filters = ({
|
||||
: []),
|
||||
{
|
||||
key: "labels",
|
||||
label: "Labels",
|
||||
label: t`Labels`,
|
||||
icon: <HiOutlineTag size={16} />,
|
||||
items: formattedLabels,
|
||||
},
|
||||
@@ -145,13 +146,13 @@ const Filters = ({
|
||||
disabled={isLoading}
|
||||
iconLeft={<IoFilterOutline />}
|
||||
>
|
||||
Filter
|
||||
{t`Filter`}
|
||||
</Button>
|
||||
{numOfFilters > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
aria-label="Clear filters"
|
||||
aria-label={t`Clear filters`}
|
||||
className="group absolute -right-[8px] -top-[8px] flex h-5 w-5 items-center justify-center rounded-full border-2 border-light-100 bg-light-1000 text-[8px] font-[700] text-light-600 dark:border-dark-50 dark:bg-dark-1000 dark:text-dark-600"
|
||||
>
|
||||
<span className="group-hover:hidden">{numOfFilters}</span>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
HiOutlinePlusSmall,
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlineTrash,
|
||||
HiOutlineSquaresPlus,
|
||||
} from "react-icons/hi2";
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlinePlusSmall,
|
||||
HiOutlineSquaresPlus,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface ListProps {
|
||||
children: ReactNode;
|
||||
@@ -80,20 +80,20 @@ export default function List({
|
||||
{...provided.dragHandleProps}
|
||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
className="w-full focus-visible:outline-none"
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="mb-4 block border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
/>
|
||||
</form>
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
@@ -107,14 +107,14 @@ export default function List({
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Add a card",
|
||||
label: t`Add a card`,
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Delete list",
|
||||
label: t`Delete list`,
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
@@ -12,6 +14,7 @@ import { generateUID } from "@kan/shared/utils";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import Editor from "~/components/Editor";
|
||||
import Input from "~/components/Input";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import Toggle from "~/components/Toggle";
|
||||
@@ -63,6 +66,7 @@ export function NewCardForm({
|
||||
const memberPublicIds = watch("memberPublicIds") || [];
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
const position = watch("position");
|
||||
const title = watch("title");
|
||||
|
||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardPublicId,
|
||||
@@ -88,9 +92,14 @@ export function NewCardForm({
|
||||
args.labelPublicIds.includes(label.publicId),
|
||||
),
|
||||
members:
|
||||
oldBoard.workspace.members.filter((member) =>
|
||||
args.memberPublicIds.includes(member.publicId),
|
||||
) ?? [],
|
||||
oldBoard.workspace.members
|
||||
.filter((member) =>
|
||||
args.memberPublicIds.includes(member.publicId),
|
||||
)
|
||||
.map((member) => ({
|
||||
...member,
|
||||
deletedAt: null,
|
||||
})) ?? [],
|
||||
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
|
||||
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
|
||||
index: position === "start" ? 0 : list.cards.length,
|
||||
@@ -110,16 +119,29 @@ export function NewCardForm({
|
||||
|
||||
return { previousState: currentState };
|
||||
},
|
||||
onError: (_error, _newList, context) => {
|
||||
onError: (error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to create card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to create card`,
|
||||
message: error.data?.zodError?.fieldErrors.title?.[0]
|
||||
? `${error.data.zodError.fieldErrors.title[0].replace("String", "Title")}`
|
||||
: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
onSuccess: async () => {
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
reset({
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId: watch("listPublicId"),
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,6 +173,7 @@ export function NewCardForm({
|
||||
member.user?.name ?? null,
|
||||
member.user?.email ?? member.email,
|
||||
),
|
||||
selected: memberPublicIds.includes(member.publicId),
|
||||
leftIcon: (
|
||||
<Avatar
|
||||
size="xs"
|
||||
@@ -164,18 +187,6 @@ export function NewCardForm({
|
||||
})) ?? [];
|
||||
|
||||
const onSubmit = (data: NewCardInput) => {
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
reset({
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId: watch("listPublicId"),
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
});
|
||||
|
||||
createCard.mutate({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
@@ -223,7 +234,7 @@ export function NewCardForm({
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-5">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
New card
|
||||
{t`New card`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -240,7 +251,7 @@ export function NewCardForm({
|
||||
<div>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="Card title"
|
||||
placeholder={t`Card title`}
|
||||
{...register("title")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -251,18 +262,12 @@ export function NewCardForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
placeholder="Add description..."
|
||||
onChange={(e) => setValue("description", e.target.value)}
|
||||
value={watch("description")}
|
||||
contentEditable
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6">
|
||||
<Editor
|
||||
content=""
|
||||
onChange={(value) => setValue("description", value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex space-x-1">
|
||||
<div className="w-fit">
|
||||
@@ -282,7 +287,7 @@ export function NewCardForm({
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!memberPublicIds.length ? (
|
||||
"Members"
|
||||
t`Members`
|
||||
) : (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{memberPublicIds.map((memberPublicId) => {
|
||||
@@ -319,11 +324,11 @@ export function NewCardForm({
|
||||
openModal("EDIT_LABEL", labelPublicId)
|
||||
}
|
||||
handleCreate={() => openModal("NEW_LABEL")}
|
||||
createNewItemLabel="Create new label"
|
||||
createNewItemLabel={t`Create new label`}
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!labelPublicIds.length ? (
|
||||
"Labels"
|
||||
t`Labels`
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -356,7 +361,9 @@ export function NewCardForm({
|
||||
})}
|
||||
</div>
|
||||
{labelPublicIds.length > 1 && (
|
||||
<div className="ml-1">{`${labelPublicIds.length} labels`}</div>
|
||||
<div className="ml-1">
|
||||
<Trans>{`${labelPublicIds.length} labels`}</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -381,13 +388,18 @@ export function NewCardForm({
|
||||
|
||||
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label="Create another"
|
||||
label={t`Create another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={handleToggleCreateAnother}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit">Create card</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={title.length === 0 || createCard.isPending}
|
||||
>
|
||||
{t`Create card`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
@@ -73,8 +74,8 @@ export function NewListForm({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to create list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to create list`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -108,7 +109,7 @@ export function NewListForm({
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
New list
|
||||
{t`New list`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -124,7 +125,7 @@ export function NewListForm({
|
||||
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="List name"
|
||||
placeholder={t`List name`}
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -136,7 +137,7 @@ export function NewListForm({
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label="Create another"
|
||||
label={t`Create another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||
@@ -144,7 +145,7 @@ export function NewListForm({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit">Create list</Button>
|
||||
<Button type="submit">{t`Create list`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const schema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: "Board URL must be at least 3 characters long",
|
||||
})
|
||||
.max(60, { message: "Board URL cannot exceed 60 characters" })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: "Board URL can only contain letters, numbers, and hyphens",
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
@@ -45,10 +34,25 @@ export function UpdateBoardSlugForm({
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const schema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: t`Board URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(60, { message: t`Board URL cannot exceed 60 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`Board URL can only contain letters, numbers, and hyphens`,
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, errors },
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
values: {
|
||||
@@ -57,11 +61,15 @@ export function UpdateBoardSlugForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const slug = watch("slug");
|
||||
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
|
||||
const updateBoardSlug = api.board.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to update board URL",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update board URL`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -71,6 +79,18 @@ export function UpdateBoardSlugForm({
|
||||
},
|
||||
});
|
||||
|
||||
const checkBoardSlugAvailability = api.board.checkSlugAvailability.useQuery(
|
||||
{
|
||||
boardSlug: debouncedSlug,
|
||||
boardPublicId,
|
||||
},
|
||||
{
|
||||
enabled: !!debouncedSlug && debouncedSlug !== boardSlug && !errors.slug,
|
||||
},
|
||||
);
|
||||
|
||||
const isBoardSlugAvailable = checkBoardSlugAvailability.data;
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#board-slug");
|
||||
@@ -78,6 +98,9 @@ export function UpdateBoardSlugForm({
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
if (!isBoardSlugAvailable) return;
|
||||
if (isBoardSlugAvailable.isReserved) return;
|
||||
|
||||
updateBoardSlug.mutate({
|
||||
slug: data.slug,
|
||||
boardPublicId,
|
||||
@@ -89,7 +112,7 @@ export function UpdateBoardSlugForm({
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
Edit board URL
|
||||
{t`Edit board URL`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -106,14 +129,26 @@ export function UpdateBoardSlugForm({
|
||||
<Input
|
||||
id="board-slug"
|
||||
{...register("slug")}
|
||||
errorMessage={errors.slug?.message}
|
||||
prefix={`kan.bn/${workspaceSlug}/`}
|
||||
errorMessage={
|
||||
errors.slug?.message ||
|
||||
(isBoardSlugAvailable?.isReserved
|
||||
? t`This board URL has already been taken`
|
||||
: undefined)
|
||||
}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
iconRight={
|
||||
!!errors.slug?.message || isBoardSlugAvailable?.isReserved ? (
|
||||
<HiXMark className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<HiCheck className="h-4 w-4 dark:text-dark-1000" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
@@ -124,10 +159,11 @@ export function UpdateBoardSlugForm({
|
||||
disabled={
|
||||
!isDirty ||
|
||||
updateBoardSlug.isPending ||
|
||||
errors.slug?.message !== undefined
|
||||
errors.slug?.message !== undefined ||
|
||||
isBoardSlugAvailable?.isReserved
|
||||
}
|
||||
>
|
||||
Update
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -20,6 +22,7 @@ const VisibilityButton = ({
|
||||
}: {
|
||||
visibility: "public" | "private";
|
||||
boardPublicId: string;
|
||||
boardSlug: string;
|
||||
queryParams: QueryParams;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
@@ -38,12 +41,16 @@ const VisibilityButton = ({
|
||||
|
||||
const updateBoardVisibility = api.board.update.useMutation({
|
||||
onSuccess: () => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
showPopup({
|
||||
header: t`Board visibility updated`,
|
||||
message: t`The visibility of your board has been set to ${isPublic ? "public" : "private"}.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to update board visibility",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update board visibility`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -52,23 +59,39 @@ const VisibilityButton = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateBoardVisibility = () => {
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: isPublic ? "private" : "public",
|
||||
boardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUpdateBoardVisibility}
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
isLoading={updateBoardVisibility.isPending}
|
||||
disabled={isLoading || !isAdmin}
|
||||
>
|
||||
{isPublic ? "Public" : "Private"}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !isAdmin}
|
||||
>
|
||||
{t`Visibility`}
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DropResult } from "react-beautiful-dnd";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
@@ -128,8 +129,8 @@ export default function BoardPage() {
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to update list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update list`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -184,8 +185,8 @@ export default function BoardPage() {
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to update card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update card`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -235,19 +236,20 @@ export default function BoardPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${boardData?.name ?? "Board"} | ${workspace.name ?? "Workspace"}`}
|
||||
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
|
||||
/>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 flex w-full justify-between p-8">
|
||||
{isLoading ? (
|
||||
<div className="z-10 flex w-full flex-col justify-between p-6 md:flex-row md:p-8">
|
||||
{isLoading && !boardData && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{boardData && (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
className="order-2 focus-visible:outline-none md:order-1"
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
@@ -258,20 +260,26 @@ export default function BoardPage() {
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
{t`Board not found`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={isLoading}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
<Filters
|
||||
labels={boardData?.labels ?? []}
|
||||
members={boardData?.workspace.members ?? []}
|
||||
position="left"
|
||||
isLoading={isLoading}
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
<Button
|
||||
iconLeft={
|
||||
@@ -283,11 +291,11 @@ export default function BoardPage() {
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
disabled={!boardData}
|
||||
>
|
||||
New list
|
||||
{t`New list`}
|
||||
</Button>
|
||||
<BoardDropdown isLoading={isLoading} />
|
||||
<BoardDropdown isLoading={!boardData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -298,17 +306,17 @@ export default function BoardPage() {
|
||||
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : (
|
||||
) : boardData ? (
|
||||
<>
|
||||
{boardData?.lists.length === 0 ? (
|
||||
{boardData.lists.length === 0 ? (
|
||||
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineSquare3Stack3D className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
No lists
|
||||
{t`No lists`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
Get started by creating a new list
|
||||
{t`Get started by creating a new list`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -316,7 +324,7 @@ export default function BoardPage() {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
>
|
||||
Create new list
|
||||
{t`Create new list`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -333,7 +341,7 @@ export default function BoardPage() {
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<div className="min-w-[2rem]" />
|
||||
{boardData?.lists.map((list, index) => (
|
||||
{boardData.lists.map((list, index) => (
|
||||
<List
|
||||
index={index}
|
||||
key={index}
|
||||
@@ -350,7 +358,7 @@ export default function BoardPage() {
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
|
||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600"
|
||||
>
|
||||
{list.cards.map((card, index) => (
|
||||
<Draggable
|
||||
@@ -404,7 +412,7 @@ export default function BoardPage() {
|
||||
</DragDropContext>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||
{modalContentType === "DELETE_BOARD" && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -18,7 +19,7 @@ export function BoardsList() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xxl:grid-cols-7">
|
||||
<div className="xxl:grid-cols-7 grid w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="mr-5 flex h-[150px] w-full animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
@@ -31,13 +32,15 @@ export function BoardsList() {
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
No boards
|
||||
{t`No boards`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
Get started by creating a new board
|
||||
{t`Get started by creating a new board`}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>Create new board</Button>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||
{t`Create new board`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,37 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FaTrello } from "react-icons/fa";
|
||||
import {
|
||||
HiChevronUpDown,
|
||||
HiMiniArrowTopRightOnSquare,
|
||||
HiOutlineQuestionMarkCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface TrelloFormValues {
|
||||
apiKey: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const sources = [{ source: "Trello" }];
|
||||
const integrationProviders: Record<
|
||||
string,
|
||||
{ name: string; icon: JSX.Element }
|
||||
> = {
|
||||
trello: {
|
||||
name: "Trello",
|
||||
icon: <FaTrello />,
|
||||
},
|
||||
};
|
||||
|
||||
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
const { data: integrations, refetch: refetchIntegrations } =
|
||||
api.integration.providers.useQuery();
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
source: "Trello",
|
||||
source: integrations?.[0]?.provider ?? "trello",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: trelloUrl } = api.integration.getAuthorizationUrl.useQuery(
|
||||
{ provider: "trello" },
|
||||
{
|
||||
enabled: !integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const hasIntegrations = integrations && integrations.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
refetchIntegrations();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
const onSubmit = () => {
|
||||
handleNextStep();
|
||||
if (!hasIntegrations && trelloUrl) {
|
||||
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||
} else {
|
||||
handleNextStep();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -47,9 +80,9 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
<div className="relative">
|
||||
<Listbox.Button className="focus-ring-light-700 block w-full rounded-md border-0 bg-dark-300 bg-white/5 px-4 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6">
|
||||
<span className="flex items-center">
|
||||
<FaTrello />
|
||||
<span className="ml-2 block truncate">
|
||||
{field.value}
|
||||
{integrationProviders[field.value]?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders[field.value]?.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
@@ -68,20 +101,41 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
|
||||
{sources.map(({ source }, index) => (
|
||||
{hasIntegrations ? (
|
||||
integrations.map((integration, index) => (
|
||||
<Listbox.Option
|
||||
key={`source_${index}`}
|
||||
className="relative cursor-default select-none px-1"
|
||||
value={integration.provider}
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{
|
||||
integrationProviders[integration.provider]
|
||||
?.icon
|
||||
}
|
||||
<span className="ml-2 block truncate text-sm font-normal">
|
||||
{
|
||||
integrationProviders[integration.provider]
|
||||
?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<Listbox.Option
|
||||
key={`source_${index}`}
|
||||
className="relative cursor-default select-none px-1"
|
||||
value={source}
|
||||
key="trello_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
<FaTrello className="ml-1" />
|
||||
<span className="ml-2 block truncate font-normal">
|
||||
{source}
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
)}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -94,7 +148,14 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit">Select source</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
iconRight={
|
||||
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||
}
|
||||
>
|
||||
{hasIntegrations ? t`Select source` : t`Connect Trello`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -103,31 +164,32 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
|
||||
const ImportTrello: React.FC = () => {
|
||||
const utils = api.useUtils();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const boards = api.import.trello.getBoards.useQuery(
|
||||
{ apiKey, token },
|
||||
{
|
||||
enabled: apiKey && token ? true : false,
|
||||
},
|
||||
);
|
||||
const { data: boards, isLoading: boardsLoading } =
|
||||
api.import.trello.getBoards.useQuery();
|
||||
|
||||
const handleSetAuthDetails = (apiKey: string, token: string) => {
|
||||
setApiKey(apiKey);
|
||||
setToken(token);
|
||||
};
|
||||
const {
|
||||
register: registerBoards,
|
||||
handleSubmit: handleSubmitBoards,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues: Object.fromEntries(
|
||||
boards?.map((board) => [board.id, true]) ?? [],
|
||||
),
|
||||
});
|
||||
|
||||
const importBoards = api.import.trello.importBoards.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: "Import complete",
|
||||
message: "Your boards have been imported.",
|
||||
header: t`Import complete`,
|
||||
message: t`Your boards have been imported.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
@@ -139,90 +201,108 @@ const ImportTrello: React.FC = () => {
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Import failed",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Import failed`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit } = useForm<TrelloFormValues>({
|
||||
defaultValues: {
|
||||
apiKey: "",
|
||||
token: "",
|
||||
},
|
||||
});
|
||||
const boardWatchers = boards?.map((board) => ({
|
||||
id: board.id,
|
||||
value: watch(board.id),
|
||||
}));
|
||||
|
||||
const onSubmit = (values: TrelloFormValues) => {
|
||||
handleSetAuthDetails(values.apiKey, values.token);
|
||||
};
|
||||
|
||||
const { register: registerBoards, handleSubmit: handleSubmitBoards } =
|
||||
useForm({
|
||||
defaultValues: Object.fromEntries(
|
||||
boards.data?.map((board) => [board.id, true]) ?? [],
|
||||
),
|
||||
});
|
||||
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
|
||||
|
||||
const onSubmitBoards = (values: Record<string, boolean>) => {
|
||||
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
||||
|
||||
importBoards.mutate({
|
||||
boardIds,
|
||||
apiKey,
|
||||
token,
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
};
|
||||
|
||||
if (boards.data?.length)
|
||||
return (
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-scroll px-5">
|
||||
{boards.data.map((board) => (
|
||||
<div key={board.id}>
|
||||
<label
|
||||
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
||||
htmlFor={board.id}
|
||||
>
|
||||
<input
|
||||
id={board.id}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||
{...registerBoards(board.id)}
|
||||
/>
|
||||
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
const renderContent = () => {
|
||||
if (boardsLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit" isLoading={importBoards.isPending}>
|
||||
Import boards
|
||||
</Button>
|
||||
</div>
|
||||
if (!boards?.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`No boards found`}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return boards.map((board) => (
|
||||
<div key={board.id}>
|
||||
<label
|
||||
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
||||
htmlFor={board.id}
|
||||
>
|
||||
<input
|
||||
id={board.id}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||
{...registerBoards(board.id)}
|
||||
/>
|
||||
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="text-neutral-900 dark:text-dark-1000"
|
||||
>
|
||||
<div className="space-y-4 px-5">
|
||||
<Input id="apiKey" placeholder="API key" {...register("apiKey")} />
|
||||
<Input id="token" placeholder="Token" {...register("token")} />
|
||||
</div>
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit" isLoading={boards.isLoading}>
|
||||
Fetch boards
|
||||
<Toggle
|
||||
label={t`Select all`}
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
onChange={() => {
|
||||
const newState = !isSelectAllEnabled;
|
||||
setIsSelectAllEnabled(newState);
|
||||
|
||||
for (const board of boards || []) {
|
||||
setValue(board.id, newState);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={importBoards.isPending}
|
||||
disabled={
|
||||
importBoards.isPending ||
|
||||
boardsLoading ||
|
||||
!boards?.length ||
|
||||
!boards.some(
|
||||
(board) =>
|
||||
boardWatchers?.find((w) => w.id === board.id)?.value === true,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>
|
||||
<Plural
|
||||
value={boardCount}
|
||||
one={`Import board (1)`}
|
||||
other={`Import boards (${boardCount})`}
|
||||
/>
|
||||
</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +319,7 @@ export function ImportBoardsForm() {
|
||||
<div className="flex w-full items-center justify-between px-5 pb-4 pt-5">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
New import
|
||||
{t`New import`}
|
||||
</h2>
|
||||
<Link
|
||||
href="https://docs.kan.bn/imports/trello"
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { NewBoardInput } from "@kan/api/types";
|
||||
|
||||
import type { Template } from "./TemplateBoards";
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import TemplateBoards, { getTemplates } from "./TemplateBoards";
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Board name is required` })
|
||||
.max(100, { message: t`Board name cannot exceed 100 characters` }),
|
||||
workspacePublicId: z.string(),
|
||||
template: z.custom<Template | null>(),
|
||||
});
|
||||
|
||||
interface NewBoardInputWithTemplate {
|
||||
name: string;
|
||||
workspacePublicId: string;
|
||||
template: Template | null;
|
||||
}
|
||||
|
||||
export function NewBoardForm() {
|
||||
const utils = api.useUtils();
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
const { register, handleSubmit } = useForm<NewBoardInput>({
|
||||
const templates = getTemplates();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<NewBoardInputWithTemplate>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
workspacePublicId: workspace.publicId || "",
|
||||
template: null,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTemplate = watch("template");
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const createBoard = api.board.create.useMutation({
|
||||
@@ -31,8 +63,13 @@ export function NewBoardForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewBoardInput) => {
|
||||
createBoard.mutate(data);
|
||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||
createBoard.mutate({
|
||||
name: data.name,
|
||||
workspacePublicId: data.workspacePublicId,
|
||||
lists: data.template?.lists ?? [],
|
||||
labels: data.template?.labels ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,7 +82,7 @@ export function NewBoardForm() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">New board</h2>
|
||||
<h2 className="text-sm font-bold">{t`New board`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
@@ -59,8 +96,9 @@ export function NewBoardForm() {
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Name"
|
||||
placeholder={t`Name`}
|
||||
{...register("name", { required: true })}
|
||||
errorMessage={errors.name?.message}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -69,11 +107,25 @@ export function NewBoardForm() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TemplateBoards
|
||||
currentBoard={currentTemplate}
|
||||
setCurrentBoard={(t) => setValue("template", t)}
|
||||
showTemplates={showTemplates}
|
||||
/>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
isChecked={showTemplates}
|
||||
onChange={() => {
|
||||
setShowTemplates(!showTemplates);
|
||||
if (!showTemplates && !currentTemplate) {
|
||||
setValue("template", templates[0] ?? null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit" isLoading={createBoard.isPending}>
|
||||
Create board
|
||||
{t`Create board`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
191
apps/web/src/views/boards/components/TemplateBoards.tsx
Normal file
191
apps/web/src/views/boards/components/TemplateBoards.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HiCheckCircle } from "react-icons/hi2";
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
lists: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export const getTemplates = (): Template[] => [
|
||||
{
|
||||
id: "basic",
|
||||
name: t`Basic Kanban`,
|
||||
lists: [t`To Do`, t`In Progress`, t`Done`],
|
||||
labels: [t`High Priority`, t`Medium Priority`, t`Low Priority`],
|
||||
},
|
||||
{
|
||||
id: "software-dev",
|
||||
name: t`Software Development`,
|
||||
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
||||
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "content-creation",
|
||||
name: t`Content Creation`,
|
||||
lists: [
|
||||
t`Brainstorming`,
|
||||
t`Writing`,
|
||||
t`Editing`,
|
||||
t`Design`,
|
||||
t`Approval`,
|
||||
t`Publishing`,
|
||||
t`Done`,
|
||||
],
|
||||
labels: [t`Blog Post`, t`Social Media`, t`Video`, t`Newsletter`, t`Urgent`],
|
||||
},
|
||||
{
|
||||
id: "customer-support",
|
||||
name: t`Customer Support`,
|
||||
lists: [
|
||||
t`New Ticket`,
|
||||
t`Triaging`,
|
||||
t`In Progress`,
|
||||
t`Awaiting Customer`,
|
||||
t`Resolution`,
|
||||
t`Done`,
|
||||
],
|
||||
labels: [
|
||||
t`Bug Report`,
|
||||
t`Feature Request`,
|
||||
t`Question`,
|
||||
t`Urgent`,
|
||||
t`Billing`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "recruitment",
|
||||
name: t`Recruitment`,
|
||||
lists: [
|
||||
t`Applicants`,
|
||||
t`Screening`,
|
||||
t`Interviewing`,
|
||||
t`Offer`,
|
||||
t`Onboarding`,
|
||||
t`Hired`,
|
||||
],
|
||||
labels: [t`Remote`, t`Full-time`, t`Part-time`, t`Senior`, t`Junior`],
|
||||
},
|
||||
{
|
||||
id: "personal-project",
|
||||
name: t`Personal Project`,
|
||||
lists: [
|
||||
t`Ideas`,
|
||||
t`Research`,
|
||||
t`Planning`,
|
||||
t`Execution`,
|
||||
t`Review`,
|
||||
t`Next Steps`,
|
||||
t`Complete`,
|
||||
],
|
||||
labels: [t`Important`, t`Quick Win`, t`Long-term`, t`Learning`, t`Fun`],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TemplateBoards({
|
||||
currentBoard,
|
||||
setCurrentBoard,
|
||||
showTemplates,
|
||||
}: {
|
||||
currentBoard: Template | null;
|
||||
setCurrentBoard: (board: Template | null) => void;
|
||||
showTemplates: boolean;
|
||||
}) {
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const templates = getTemplates();
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
|
||||
const isAtTop = scrollTop <= 5;
|
||||
|
||||
setShowFade(!isAtBottom);
|
||||
setShowTopFade(!isAtTop);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
handleScroll();
|
||||
|
||||
scrollElement.addEventListener("scroll", handleScroll);
|
||||
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
||||
}, [showTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTemplates && currentBoard && scrollRef.current) {
|
||||
const selectedElement = scrollRef.current.querySelector(
|
||||
`[data-template-id="${currentBoard.id}"]`,
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, [showTemplates, currentBoard]);
|
||||
|
||||
const handleBoardSelect = (boardId: string) => {
|
||||
if (currentBoard?.id === boardId) {
|
||||
setCurrentBoard(null);
|
||||
} else {
|
||||
setCurrentBoard(
|
||||
templates.find((template) => template.id === boardId) ?? null,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!showTemplates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 pt-4">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scroll-container -mr-2 flex max-h-[200px] flex-col gap-3 overflow-y-auto pr-2 pt-0.5"
|
||||
>
|
||||
{templates.map((template) => (
|
||||
<label
|
||||
key={template.id}
|
||||
data-template-id={template.id}
|
||||
onClick={() => handleBoardSelect(template.id)}
|
||||
className={`scroll-container relative flex cursor-pointer rounded-lg border p-3 transition-all hover:bg-light-100 dark:hover:bg-dark-200 ${
|
||||
currentBoard?.id === template.id
|
||||
? "border-light-700 bg-light-100 ring-1 ring-inset ring-light-700 dark:border-dark-700 dark:bg-dark-200 dark:ring-dark-700"
|
||||
: "border-light-600 dark:border-dark-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{template.name}
|
||||
</h4>
|
||||
<p className="text-xs text-light-950 dark:text-dark-900">
|
||||
{template.lists.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{currentBoard?.id === template.id && (
|
||||
<div className="absolute right-3 top-3 text-light-1000 dark:text-dark-1000">
|
||||
<HiCheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{showTopFade && (
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 h-6 bg-gradient-to-b from-white/80 to-transparent dark:from-dark-100/80" />
|
||||
)}
|
||||
{showFade && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-white/80 to-transparent dark:from-dark-100/80" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -17,39 +19,33 @@ export default function BoardsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`Boards | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-8 flex w-full justify-between">
|
||||
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto h-full max-w-[1600px] px-5 py-6 md:px-8 md:py-8">
|
||||
<div className="mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
Boards
|
||||
{t`Boards`}
|
||||
</h1>
|
||||
<div className="flex">
|
||||
<button
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-dark-3000 mr-2 flex items-center gap-x-1.5 rounded-md border-[1px] border-light-600 px-3 py-2 text-sm text-neutral-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-dark-600 dark:text-dark-1000"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center">
|
||||
<HiArrowDownTray
|
||||
className="-mr-0.5 h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
{t`Import`}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
|
||||
variant="primary"
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
<div className="h-5 w-5 items-center">
|
||||
<HiOutlinePlusSmall
|
||||
className="-mr-0.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
New
|
||||
</button>
|
||||
{t`New`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineArrowRight,
|
||||
@@ -10,22 +13,22 @@ import {
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": "created the card",
|
||||
"card.updated.title": "updated the title",
|
||||
"card.updated.description": "updated the description",
|
||||
"card.updated.list": "moved the card to another list",
|
||||
"card.updated.label.added": "added a label to the card",
|
||||
"card.updated.label.removed": "removed a label from the card",
|
||||
"card.updated.member.added": "added a member to the card",
|
||||
"card.updated.member.removed": "removed a member from the card",
|
||||
const dateLocaleMap = {
|
||||
en: enGB,
|
||||
fr: fr,
|
||||
de: de,
|
||||
es: es,
|
||||
it: it,
|
||||
nl: nl,
|
||||
} as const;
|
||||
|
||||
const getActivityText = ({
|
||||
@@ -45,6 +48,17 @@ const getActivityText = ({
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
}) => {
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": t`created the card`,
|
||||
"card.updated.title": t`updated the title`,
|
||||
"card.updated.description": t`updated the description`,
|
||||
"card.updated.list": t`moved the card to another list`,
|
||||
"card.updated.label.added": t`added a label to the card`,
|
||||
"card.updated.label.removed": t`removed a label from the card`,
|
||||
"card.updated.member.added": t`added a member to the card`,
|
||||
"card.updated.member.removed": t`removed a member from the card`,
|
||||
} as const;
|
||||
|
||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
|
||||
|
||||
@@ -56,54 +70,54 @@ const getActivityText = ({
|
||||
|
||||
if (type === "card.updated.title" && toTitle) {
|
||||
return (
|
||||
<>
|
||||
<Trans>
|
||||
updated the title to <TextHighlight>{toTitle}</TextHighlight>
|
||||
</>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.list" && fromList && toList) {
|
||||
return (
|
||||
<>
|
||||
<Trans>
|
||||
moved the card from <TextHighlight>{fromList}</TextHighlight> to
|
||||
<TextHighlight>{toList}</TextHighlight>
|
||||
</>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.added" && memberName) {
|
||||
if (isSelf) return <>self-assigned the card</>;
|
||||
if (isSelf) return <Trans>self-assigned the card</Trans>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Trans>
|
||||
assigned <TextHighlight>{memberName}</TextHighlight> to the card
|
||||
</>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.removed" && memberName) {
|
||||
if (isSelf) return <>unassigned themselves from the card</>;
|
||||
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Trans>
|
||||
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
|
||||
</>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.label.added" && label) {
|
||||
return (
|
||||
<>
|
||||
<Trans>
|
||||
added label <TextHighlight>{label}</TextHighlight>
|
||||
</>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.label.removed" && label) {
|
||||
return (
|
||||
<>
|
||||
<Trans>
|
||||
removed label <TextHighlight>{label}</TextHighlight>
|
||||
</>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,6 +161,11 @@ const ActivityList = ({
|
||||
isLoading: boolean;
|
||||
isAdmin?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
const { locale } = useLocalisation();
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] || enGB;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
{activities.map((activity, index) => {
|
||||
@@ -156,7 +175,7 @@ const ActivityList = ({
|
||||
fromList: activity.fromList?.name ?? null,
|
||||
toList: activity.toList?.name ?? null,
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
isSelf: activity.member?.user?.id === activity.user?.id,
|
||||
isSelf: activity.member?.user?.id === data?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
});
|
||||
|
||||
@@ -169,10 +188,10 @@ const ActivityList = ({
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
isLoading={isLoading}
|
||||
createdAt={activity.createdAt}
|
||||
createdAt={activity.createdAt.toISOString()}
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === activity.user?.id}
|
||||
isAuthor={activity.comment?.createdBy === data?.user.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
/>
|
||||
);
|
||||
@@ -211,6 +230,7 @@ const ActivityList = ({
|
||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||
{formatDistanceToNow(new Date(activity.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: currentDateLocale,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
@@ -57,8 +58,8 @@ const Comment = ({
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to update comment",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update comment`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -76,7 +77,7 @@ const Comment = ({
|
||||
...(isAuthor
|
||||
? [
|
||||
{
|
||||
label: "Edit comment",
|
||||
label: t`Edit comment`,
|
||||
action: () => setIsEditing(true),
|
||||
icon: <HiPencil className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
@@ -85,7 +86,7 @@ const Comment = ({
|
||||
...(isAuthor || isAdmin
|
||||
? [
|
||||
{
|
||||
label: "Delete comment",
|
||||
label: t`Delete comment`,
|
||||
action: () => openModal("DELETE_COMMENT", publicId),
|
||||
icon: <HiTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
@@ -117,7 +118,7 @@ const Comment = ({
|
||||
</span>
|
||||
{isEdited && (
|
||||
<span className="text-light-900 dark:text-dark-800">
|
||||
{" (edited)"}
|
||||
{t` (edited)`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -132,15 +133,19 @@ const Comment = ({
|
||||
)}
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<p className="mt-2 text-sm">{comment}</p>
|
||||
<ContentEditable
|
||||
html={comment ?? ""}
|
||||
disabled={true}
|
||||
className="mt-2 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<ContentEditable
|
||||
placeholder="Add a comment..."
|
||||
placeholder={t`Add a comment...`}
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
@@ -148,14 +153,14 @@ const Comment = ({
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={updateCommentMutation.isPending}
|
||||
type="submit"
|
||||
size="sm"
|
||||
>
|
||||
Save
|
||||
{t`Save`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -47,8 +48,8 @@ export function DeleteCardConfirmation({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to delete card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to delete card`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -71,10 +72,10 @@ export function DeleteCardConfirmation({
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
Are you sure you want to delete this card?
|
||||
{t`Are you sure you want to delete this card?`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"This action can't be undone."}
|
||||
{t`This action can't be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end sm:mt-6">
|
||||
@@ -82,13 +83,13 @@ export function DeleteCardConfirmation({
|
||||
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleDeleteCard}
|
||||
isLoading={deleteCardMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -39,8 +41,8 @@ export function DeleteCommentConfirmation({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to delete comment",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to delete comment`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -60,10 +62,10 @@ export function DeleteCommentConfirmation({
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
Are you sure you want to delete this comment?
|
||||
{t`Are you sure you want to delete this comment?`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"This action can't be undone."}
|
||||
{t`This action can't be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end sm:mt-6">
|
||||
@@ -71,13 +73,13 @@ export function DeleteCommentConfirmation({
|
||||
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleDeleteComment}
|
||||
isLoading={deleteCommentMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
@@ -10,7 +11,7 @@ export default function BoardDropdown() {
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Delete card",
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -68,8 +69,8 @@ export default function LabelSelector({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: "Unable to update labels",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update labels`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -94,7 +95,7 @@ export default function LabelSelector({
|
||||
}}
|
||||
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={() => openModal("NEW_LABEL")}
|
||||
createNewItemLabel="Create new label"
|
||||
createNewItemLabel={t`Create new label`}
|
||||
asChild
|
||||
>
|
||||
{selectedLabels.length ? (
|
||||
@@ -105,13 +106,16 @@ export default function LabelSelector({
|
||||
</Menu.Button>
|
||||
))}
|
||||
<Menu.Button>
|
||||
<Badge value="Add label" iconLeft={<HiMiniPlus size={14} />} />
|
||||
<Badge
|
||||
value={t`Add label`}
|
||||
iconLeft={<HiMiniPlus size={14} />}
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
Add label
|
||||
{t`Add label`}
|
||||
</Menu.Button>
|
||||
)}
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -48,8 +49,8 @@ export default function ListSelector({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: "Unable to update list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update list`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -55,6 +56,8 @@ export default function MemberSelector({
|
||||
...oldCard.members,
|
||||
{
|
||||
publicId: update.workspaceMemberPublicId,
|
||||
email: memberToAdd?.email ?? "",
|
||||
deletedAt: null,
|
||||
user: {
|
||||
id: memberToAdd?.user?.id ?? "",
|
||||
name: memberToAdd?.user?.name ?? "",
|
||||
@@ -73,8 +76,8 @@ export default function MemberSelector({
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: "Unable to update members",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update members`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -106,7 +109,7 @@ export default function MemberSelector({
|
||||
});
|
||||
}}
|
||||
handleCreate={handleInviteMember}
|
||||
createNewItemLabel="Invite member"
|
||||
createNewItemLabel={t`Invite member`}
|
||||
asChild
|
||||
>
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">
|
||||
@@ -124,7 +127,7 @@ export default function MemberSelector({
|
||||
) : (
|
||||
<>
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{"Add member"}
|
||||
{t`Add member`}
|
||||
</>
|
||||
)}
|
||||
</Menu.Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
@@ -26,8 +27,8 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const addCommentMutation = api.card.addComment.useMutation({
|
||||
onError: (_error, _newList) => {
|
||||
showPopup({
|
||||
header: "Unable to add comment",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to add comment`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -53,7 +54,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
className="flex w-full max-w-[1000px] flex-col rounded-xl border border-light-600 bg-light-200 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<ContentEditable
|
||||
placeholder="Add a comment..."
|
||||
placeholder={t`Add a comment...`}
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import { LabelForm } from "~/components/LabelForm";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import Modal from "~/components/modal";
|
||||
@@ -31,29 +32,18 @@ interface FormValues {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function CardPage() {
|
||||
export function CardRightPanel() {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { modalContentType, entityId } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery({
|
||||
const { data: card } = api.card.byId.useQuery({
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const refetchCard = async () => {
|
||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||
};
|
||||
|
||||
const board = card?.list.board;
|
||||
const boardId = board?.publicId;
|
||||
const labels = board?.labels;
|
||||
const activities = card?.activities;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
const selectedLabels = card?.labels;
|
||||
const selectedMembers = card?.members;
|
||||
@@ -108,11 +98,64 @@ export default function CardPage() {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-[360px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
|
||||
<ListSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
lists={formattedLists}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Labels`}</p>
|
||||
<LabelSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
labels={formattedLabels}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CardPage() {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { modalContentType, entityId } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery({
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
|
||||
const refetchCard = async () => {
|
||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||
};
|
||||
|
||||
const board = card?.list.board;
|
||||
const boardId = board?.publicId;
|
||||
const activities = card?.activities;
|
||||
|
||||
const updateCard = api.card.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to update card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to update card`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -142,18 +185,19 @@ export default function CardPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
|
||||
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
|
||||
/>
|
||||
<div className="flex h-full flex-1 flex-row">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
||||
<div className="h-full max-h-[calc(100dvh-3rem)] overflow-y-auto p-6 md:max-h-[calc(100dvh-4rem)] md:p-8">
|
||||
<div className="mb-8 flex w-full items-center justify-between">
|
||||
{isLoading ? (
|
||||
{!card && isLoading && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
{card && (
|
||||
<>
|
||||
<Link
|
||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||
@@ -184,66 +228,46 @@ export default function CardPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!card && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Card not found`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<ContentEditable
|
||||
placeholder="Add description..."
|
||||
html={watch("description")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("description", e.target.value)}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
{card && (
|
||||
<>
|
||||
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
Activity
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={isLoading}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[475px] border-l-[1px] border-light-600 bg-light-200 p-8 text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||
<div className="mb-4 flex w-full">
|
||||
<p className="my-2 w-[100px] text-sm">List</p>
|
||||
<ListSelector
|
||||
cardPublicId={cardId}
|
||||
lists={formattedLists}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full">
|
||||
<p className="my-2 w-[100px] text-sm">Labels</p>
|
||||
<LabelSelector
|
||||
cardPublicId={cardId}
|
||||
labels={formattedLabels}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
<p className="my-2 w-[100px] text-sm">Members</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId}
|
||||
members={formattedMembers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="border-t-[1px] border-light-600 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
{t`Activity`}
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={!card}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -50,15 +51,14 @@ const Cta = ({ theme }: { theme: string }) => {
|
||||
<p>kan.bn/{currentWorkspaceSlug}</p>
|
||||
</div>
|
||||
<h2 className="text-balance text-4xl font-bold tracking-tight text-light-1000 dark:text-dark-1000 sm:text-4xl">
|
||||
Get started for free today
|
||||
{t`Get started for free today`}
|
||||
</h2>
|
||||
<p className="text-md/8 mx-auto mt-6 max-w-[375px] text-pretty text-light-900 dark:text-dark-900">
|
||||
Unlimited boards, unlimited lists, unlimited cards. No credit card
|
||||
required.
|
||||
{t`Unlimited boards, unlimited lists, unlimited cards. No credit card required.`}
|
||||
</p>
|
||||
<Link href="/signup">
|
||||
<div className="mt-10 flex items-center justify-center gap-x-6">
|
||||
<Button size="lg">Get started</Button>
|
||||
<Button size="lg">{t`Get started`}</Button>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HiMiniMinusSmall, HiMiniPlusSmall } from "react-icons/hi2";
|
||||
|
||||
const Text = ({ children }: { children: React.ReactNode }) => {
|
||||
@@ -14,143 +16,145 @@ const Text = ({ children }: { children: React.ReactNode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "Why make an open source Trello?",
|
||||
answer: (
|
||||
<Text>
|
||||
When Trello launched in 2011, it blew everyone away with its carefully
|
||||
designed simplicity, but over time (and atlassification) it lost its
|
||||
magic and grew into something closer to "Jira Lite". This project is our
|
||||
attempt to recapture the original magic of Trello’s simplicity, in open
|
||||
source.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "What's the difference between Kan and Trello?",
|
||||
answer: (
|
||||
<Text>
|
||||
The main difference between Kan and Trello is that Kan is open source,
|
||||
allowing anyone to view, modify, and contribute to our code. Our cloud
|
||||
offering also offers no restrictions on features for individual use,
|
||||
whereas Trello locks basic features such as the number of boards you can
|
||||
create behind a paywall.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How is the project funded?",
|
||||
answer: (
|
||||
<Text>
|
||||
For long-term sustainability, we recognise all good open source projects
|
||||
need a source of revenue. Ours comes from the paid cloud offering for
|
||||
workspaces with multiple users (free during the beta) and for custom
|
||||
workspace slugs. There's no obligation to use it, and you can self-host
|
||||
the software on your own infrastructure free of charge.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I import my Trello boards?",
|
||||
answer: (
|
||||
<Text>
|
||||
Importing your Trello boards into Kan is easy. You can follow our
|
||||
step-by-step guide{" "}
|
||||
<Link href="https://docs.kan.bn/imports/trello" className="underline">
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I get a custom URL?",
|
||||
answer: (
|
||||
<Text>
|
||||
You can get a custom workspace URL, like{" "}
|
||||
<Link href="https://kan.bn/kan" className="underline">
|
||||
kan.bn/kan
|
||||
</Link>
|
||||
, by going into your{" "}
|
||||
<Link href="https://kan.bn/settings" className="underline">
|
||||
workspace settings
|
||||
</Link>{" "}
|
||||
and purchasing a pro workspace subscription. All subscriptions help fund
|
||||
the development of the project!
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "Do you offer a free plan?",
|
||||
answer: (
|
||||
<Text>
|
||||
Yes, we offer an forever free plan for individual use. No restrictions,
|
||||
no paywalls, no limits.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "What license are you using?",
|
||||
answer: (
|
||||
<Text>
|
||||
We are using the{" "}
|
||||
<Link
|
||||
href="https://github.com/kan-bn/kan/blob/main/LICENSE"
|
||||
className="underline"
|
||||
>
|
||||
AGPL-3.0 license
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I invite team members?",
|
||||
answer: (
|
||||
<Text>
|
||||
You can invite team members by clicking the "Invite" button in the top
|
||||
right corner of the{" "}
|
||||
<Link href="https://kan.bn/members" className="underline">
|
||||
members page
|
||||
</Link>{" "}
|
||||
and entering their email address. They will receive an email with a link
|
||||
to join the workspace.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: "How do I self-host?",
|
||||
answer: (
|
||||
<Text>
|
||||
You can self-host by following the instructions in our{" "}
|
||||
<Link href="https://github.com/kanbn/kan" className="underline">
|
||||
repo
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const Faqs = () => {
|
||||
const faqs = [
|
||||
{
|
||||
question: t`Why make an open source Trello?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`When Trello launched in 2011, it blew everyone away with its carefully designed simplicity, but over time (and atlassification) it lost its magic and grew into something closer to "Jira Lite". This project is our attempt to recapture the original magic of Trello's simplicity, in open source.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`What's the difference between Kan and Trello?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`The main difference between Kan and Trello is that Kan is open source, allowing anyone to view, modify, and contribute to our code. Our cloud offering also offers no restrictions on features for individual use, whereas Trello locks basic features such as the number of boards you can create behind a paywall.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How is the project funded?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`For long-term sustainability, we recognise all good open source projects need a source of revenue. Ours comes from the paid cloud offering for workspaces with multiple users (free during the beta) and for custom workspace slugs. There's no obligation to use it, and you can self-host the software on your own infrastructure free of charge.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I import my Trello boards?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
Importing your Trello boards into Kan is easy. You can follow our
|
||||
step-by-step guide{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn/imports/trello"
|
||||
className="underline"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I get a custom URL?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
You can get a custom workspace URL, like{" "}
|
||||
<Link href="https://kan.bn/kan" className="underline">
|
||||
kan.bn/kan
|
||||
</Link>
|
||||
, by going into your{" "}
|
||||
<Link href="https://kan.bn/settings" className="underline">
|
||||
workspace settings
|
||||
</Link>{" "}
|
||||
and purchasing a pro workspace subscription. All subscriptions help
|
||||
fund the development of the project!
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`Do you offer a free plan?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`Yes, we offer an forever free plan for individual use. No restrictions, no paywalls, no limits.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`What license are you using?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
We are using the{" "}
|
||||
<Link
|
||||
href="https://github.com/kan-bn/kan/blob/main/LICENSE"
|
||||
className="underline"
|
||||
>
|
||||
AGPL-3.0 license
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I invite team members?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
You can invite team members by clicking the "Invite" button in the
|
||||
top right corner of the{" "}
|
||||
<Link href="https://kan.bn/members" className="underline">
|
||||
members page
|
||||
</Link>{" "}
|
||||
and entering their email address. They will receive an email with a
|
||||
link to join the workspace.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`How do I self-host?`,
|
||||
answer: (
|
||||
<Text>
|
||||
<Trans>
|
||||
You can self-host by following the instructions in our{" "}
|
||||
<Link href="https://github.com/kanbn/kan" className="underline">
|
||||
repo
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[900px] px-4">
|
||||
<div className="flex flex-col items-center justify-center pb-12">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>FAQs</p>
|
||||
<p>{t`FAQs`}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
Questions?
|
||||
{t`Questions?`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
Find answers to common questions about the project. Can't find what
|
||||
you're looking for? Feel free to{" "}
|
||||
<Link href="mailto:support@kan.bn" className="underline">
|
||||
contact us
|
||||
</Link>
|
||||
.
|
||||
<Trans>
|
||||
Find answers to common questions about the project. Can't find what
|
||||
you're looking for? Feel free to{" "}
|
||||
<Link href="mailto:support@kan.bn" className="underline">
|
||||
contact us
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-400">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
|
||||
import activityLogsIconDark from "~/assets/activity-logs-dark.json";
|
||||
@@ -62,7 +63,7 @@ const FeatureItem = ({
|
||||
|
||||
{feature.comingSoon && (
|
||||
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
|
||||
Coming soon
|
||||
{t`Coming soon`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -71,47 +72,47 @@ const FeatureItem = ({
|
||||
|
||||
const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "Board visibility",
|
||||
description: "Control who can view and edit your boards.",
|
||||
title: t`Board visibility`,
|
||||
description: t`Control who can view and edit your boards.`,
|
||||
icon: isDark ? boardVisibilityIconDark : boardVisibilityIconLight,
|
||||
},
|
||||
{
|
||||
title: "Workspace members",
|
||||
description: "Collaborate seamlessly with your team.",
|
||||
title: t`Workspace members`,
|
||||
description: t`Collaborate seamlessly with your team.`,
|
||||
icon: isDark ? membersIconDark : membersIconLight,
|
||||
},
|
||||
{
|
||||
title: "Trello imports",
|
||||
description: "Import your Trello boards and hit the ground running.",
|
||||
title: t`Trello imports`,
|
||||
description: t`Import your Trello boards and hit the ground running.`,
|
||||
icon: isDark ? importsIconDark : importsIconLight,
|
||||
},
|
||||
{
|
||||
title: "Labels & Filters",
|
||||
description:
|
||||
"Organize and find cards quickly with powerful filtering tools.",
|
||||
title: t`Labels & Filters`,
|
||||
description: t`Organize and find cards quickly with powerful filtering tools.`,
|
||||
icon: isDark ? labelsIconDark : labelsIconLight,
|
||||
},
|
||||
{
|
||||
title: "Comments",
|
||||
description: "Discuss and collaborate on cards.",
|
||||
title: t`Comments`,
|
||||
description: t`Discuss and collaborate on cards.`,
|
||||
icon: isDark ? commentsIconDark : commentsIconLight,
|
||||
},
|
||||
{
|
||||
title: "Activity logs",
|
||||
description: "Track all card changes with detailed activity history.",
|
||||
title: t`Activity logs`,
|
||||
description: t`Track all card changes with detailed activity history.`,
|
||||
icon: isDark ? activityLogsIconDark : activityLogsIconLight,
|
||||
},
|
||||
{
|
||||
title: "Templates",
|
||||
description: "Save time with reusable board templates.",
|
||||
title: t`Templates`,
|
||||
description: t`Save time with reusable board templates.`,
|
||||
icon: isDark ? templatesIconDark : templatesIconLight,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
description: "Connect your favorite tools to streamline your workflow.",
|
||||
title: t`Integrations`,
|
||||
description: t`Connect your favorite tools to streamline your workflow.`,
|
||||
icon: isDark ? integrationsIconDark : integrationsIconLight,
|
||||
comingSoon: true,
|
||||
},
|
||||
@@ -121,15 +122,14 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-24">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>Features</p>
|
||||
<p>{t`Features`}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
Kanban reimagined
|
||||
{t`Kanban reimagined`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
Simple, visual task management that just works. Drag and drop cards,
|
||||
collaborate with your team, and get more done.
|
||||
{t`Simple, visual task management that just works. Drag and drop cards, collaborate with your team, and get more done.`}
|
||||
</p>
|
||||
<div className="mx-auto mt-16 w-full max-w-7xl">
|
||||
<div className="grid w-full grid-cols-1 gap-4 [mask-image:linear-gradient(to_bottom,black_92%,transparent_100%)] sm:grid-cols-2 md:grid-cols-3 md:[mask-image:linear-gradient(to_bottom,black_85%,transparent_100%)] lg:grid-cols-4 lg:[mask-image:linear-gradient(to_bottom,black_85%,transparent_100%)]">
|
||||
@@ -142,9 +142,9 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
<div>
|
||||
<div className="mt-8 flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-sm text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<p className="text-xs lg:text-sm">
|
||||
{`We're just getting started. `}
|
||||
{t`We're just getting started. `}
|
||||
<Link href="/kan/roadmap" className="underline">
|
||||
View our roadmap.
|
||||
{t`View our roadmap.`}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||
|
||||
const navigation = {
|
||||
documentation: [
|
||||
{ name: "Getting started", href: "https://docs.kan.bn/introduction" },
|
||||
{
|
||||
name: "Importing from Trello",
|
||||
href: "https://docs.kan.bn/imports/trello",
|
||||
},
|
||||
{
|
||||
name: "API Reference",
|
||||
href: "https://docs.kan.bn/api-reference/introduction",
|
||||
},
|
||||
],
|
||||
company: [
|
||||
{ name: "Roadmap", href: "/kan/roadmap" },
|
||||
{ name: "GitHub", href: "https://github.com/kanbn/kan" },
|
||||
{ name: "Contact", href: "mailto:support@kan.bn" },
|
||||
],
|
||||
legal: [
|
||||
{ name: "Terms of service", href: "/terms" },
|
||||
{ name: "Privacy policy", href: "/privacy" },
|
||||
{
|
||||
name: "License",
|
||||
href: "https://github.com/kanbn/kan?tab=AGPL-3.0-1-ov-file#readme",
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{ name: "Features", href: "/#features" },
|
||||
{ name: "Pricing", href: "/#pricing" },
|
||||
{ name: "FAQs", href: "/#faq" },
|
||||
],
|
||||
};
|
||||
import { LanguageSelector } from "~/components/LanguageSelector";
|
||||
|
||||
const StatusMarker = () => (
|
||||
<Link
|
||||
@@ -44,11 +15,43 @@ const StatusMarker = () => (
|
||||
<span className="absolute -inset-[1px] animate-[ping_1s_infinite] rounded-full bg-green-500/30"></span>
|
||||
<span className="absolute inset-0 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
All systems operational
|
||||
{t`All systems operational`}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const Footer = () => {
|
||||
const navigation = {
|
||||
documentation: [
|
||||
{ name: t`Getting started`, href: "https://docs.kan.bn/introduction" },
|
||||
{
|
||||
name: t`Importing from Trello`,
|
||||
href: "https://docs.kan.bn/imports/trello",
|
||||
},
|
||||
{
|
||||
name: t`API Reference`,
|
||||
href: "https://docs.kan.bn/api-reference/introduction",
|
||||
},
|
||||
],
|
||||
company: [
|
||||
{ name: t`Roadmap`, href: "/kan/roadmap" },
|
||||
{ name: t`GitHub`, href: "https://github.com/kanbn/kan" },
|
||||
{ name: t`Contact`, href: "mailto:support@kan.bn" },
|
||||
],
|
||||
legal: [
|
||||
{ name: t`Terms of service`, href: "/terms" },
|
||||
{ name: t`Privacy policy`, href: "/privacy" },
|
||||
{
|
||||
name: t`License`,
|
||||
href: "https://github.com/kanbn/kan?tab=AGPL-3.0-1-ov-file#readme",
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{ name: t`Features`, href: "/#features" },
|
||||
{ name: t`Pricing`, href: "/#pricing" },
|
||||
{ name: t`FAQs`, href: "/#faq" },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="z-10 w-full border-t border-light-300 bg-light-50 py-8 dark:border-dark-300 dark:bg-dark-50">
|
||||
<div className="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 lg:py-24">
|
||||
@@ -64,13 +67,14 @@ const Footer = () => {
|
||||
</div>
|
||||
|
||||
<StatusMarker />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
||||
<div className="md:grid md:grid-cols-2 md:gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
|
||||
Documentation
|
||||
{t`Documentation`}
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.documentation.map((item) => (
|
||||
@@ -87,7 +91,7 @@ const Footer = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mt-10 text-sm/6 font-semibold text-light-1000 dark:text-dark-1000 md:mt-0">
|
||||
Company
|
||||
{t`Company`}
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.company.map((item) => (
|
||||
@@ -106,7 +110,7 @@ const Footer = () => {
|
||||
<div className="md:grid md:grid-cols-2 md:gap-8">
|
||||
<div>
|
||||
<h3 className="text-sm/6 font-semibold text-light-1000 dark:text-dark-1000">
|
||||
Resources
|
||||
{t`Resources`}
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.resources.map((item) => (
|
||||
@@ -123,7 +127,7 @@ const Footer = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mt-10 text-sm/6 font-semibold text-light-1000 dark:text-dark-1000 md:mt-0">
|
||||
Legal
|
||||
{t`Legal`}
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{navigation.legal.map((item) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -9,10 +10,10 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const menuItems = [
|
||||
{ label: "Roadmap", href: "/kan/roadmap", openInNewTab: true },
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "Pricing", href: "#pricing" },
|
||||
{ label: "Docs", href: "https://docs.kan.bn", openInNewTab: true },
|
||||
{ label: t`Roadmap`, href: "/kan/roadmap", openInNewTab: true },
|
||||
{ label: t`Features`, href: "#features" },
|
||||
{ label: t`Pricing`, href: "#pricing" },
|
||||
{ label: t`Docs`, href: "https://docs.kan.bn", openInNewTab: true },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,7 +70,7 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="z-50 p-2 lg:hidden"
|
||||
aria-label="Toggle menu"
|
||||
aria-label={t`Toggle menu`}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -92,13 +93,13 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
</button>
|
||||
<div className="hidden w-[200px] justify-end gap-2 lg:flex">
|
||||
{isLoggedIn ? (
|
||||
<Button href="/boards">Go to app</Button>
|
||||
<Button href="/boards">{t`Go to app`}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button href="/login" variant="ghost">
|
||||
Sign in
|
||||
{t`Sign in`}
|
||||
</Button>
|
||||
<Button href="/signup">Get started</Button>
|
||||
<Button href="/signup">{t`Get started`}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -129,15 +130,15 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
{isLoggedIn ? (
|
||||
<Button href="/boards" onClick={toggleMenu}>
|
||||
Go to app
|
||||
{t`Go to app`}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button href="/login" variant="ghost" onClick={toggleMenu}>
|
||||
Sign in
|
||||
{t`Sign in`}
|
||||
</Button>
|
||||
<Button href="/signup" onClick={toggleMenu}>
|
||||
Get started
|
||||
{t`Get started`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { api } from "~/utils/api";
|
||||
import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useTheme();
|
||||
|
||||
const token =
|
||||
typeof window !== "undefined" ? Cookies.get("kan.session_token") : null;
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { data } = api.user.getUser.useQuery(undefined, {
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
const isLoggedIn = !!data;
|
||||
const isLoggedIn = !!session?.user;
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
|
||||
|
||||
@@ -1,108 +1,104 @@
|
||||
import Link from "next/link";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
import { HiCheckCircle } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Frequency = "monthly" | "annually";
|
||||
|
||||
const frequencies = [
|
||||
{
|
||||
value: "monthly" as Frequency,
|
||||
label: "Monthly",
|
||||
priceSuffix: "per user/month",
|
||||
},
|
||||
{
|
||||
value: "annually" as Frequency,
|
||||
label: "Yearly",
|
||||
priceSuffix: "per user/month",
|
||||
},
|
||||
];
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: "Individuals",
|
||||
id: "tier-individuals",
|
||||
href: "signup",
|
||||
buttonText: "Get Started",
|
||||
price: { monthly: "Free", annually: "Free" },
|
||||
description:
|
||||
"Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.",
|
||||
featureHeader: "Free, forever",
|
||||
features: [
|
||||
"1 user",
|
||||
"Unlimited boards",
|
||||
"Unlimited lists",
|
||||
"Unlimited cards",
|
||||
"Unlimited comments",
|
||||
"Unlimited activity log",
|
||||
],
|
||||
showPrice: true,
|
||||
},
|
||||
{
|
||||
name: "Teams",
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: "Get Started",
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description:
|
||||
"Kanban is better with a team. Perfect for small and growing teams looking to collaborate.",
|
||||
featureHeader: "Everything in the free plan, plus:",
|
||||
features: [
|
||||
"Workspace members",
|
||||
"Admin roles",
|
||||
"Priority email support",
|
||||
"Support the development of the project",
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
},
|
||||
{
|
||||
name: "Self Host",
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: "View docs",
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description:
|
||||
"Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.",
|
||||
featureHeader: "Complete control and ownership:",
|
||||
features: [
|
||||
"Run on your own infrastructure",
|
||||
"Own your data",
|
||||
"Custom domain",
|
||||
],
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
const Pricing = () => {
|
||||
const initialFrequency = frequencies[1]!;
|
||||
const [frequency, setFrequency] = useState(initialFrequency);
|
||||
const frequencies = [
|
||||
{
|
||||
value: "monthly" as Frequency,
|
||||
label: t`Monthly`,
|
||||
priceSuffix: t`per user/month`,
|
||||
},
|
||||
{
|
||||
value: "annually" as Frequency,
|
||||
label: t`Yearly`,
|
||||
priceSuffix: t`per user/month`,
|
||||
},
|
||||
];
|
||||
|
||||
const [frequency, setFrequency] = useState(frequencies[1]);
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: t`Individuals`,
|
||||
id: "tier-individuals",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: t`Free`, annually: t`Free` },
|
||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
||||
featureHeader: t`Free, forever`,
|
||||
features: [
|
||||
t`1 user`,
|
||||
t`Unlimited boards`,
|
||||
t`Unlimited lists`,
|
||||
t`Unlimited cards`,
|
||||
t`Unlimited comments`,
|
||||
t`Unlimited activity log`,
|
||||
],
|
||||
showPrice: true,
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description: t`Kanban is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
||||
featureHeader: t`Everything in the free plan, plus:`,
|
||||
features: [
|
||||
t`Workspace members`,
|
||||
t`Admin roles`,
|
||||
t`Priority email support`,
|
||||
t`Support the development of the project`,
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
},
|
||||
{
|
||||
name: t`Self Host`,
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: t`View docs`,
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
||||
featureHeader: t`Complete control and ownership:`,
|
||||
features: [
|
||||
t`Run on your own infrastructure`,
|
||||
t`Own your data`,
|
||||
t`Custom domain`,
|
||||
],
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>Pricing</p>
|
||||
<p>{t`Pricing`}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
Simple pricing
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
Get started for free, with no usage limits. For collaboration, upgrade
|
||||
to a plan that fits the size of your team.
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
|
||||
{/* Beta free pricing pill */}
|
||||
<div className="mt-4 flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800 dark:bg-green-700 dark:text-green-100">
|
||||
All seats are free during the beta until Sept 1st!
|
||||
{t`All seats are free during the beta until Sept 1st!`}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex justify-center">
|
||||
<fieldset aria-label="Payment frequency">
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
onChange={(value) => setFrequency(value)}
|
||||
@@ -145,7 +141,7 @@ const Pricing = () => {
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
{tier.highlighted && frequency.value === "annually" ? (
|
||||
{tier.highlighted && frequency?.value === "annually" ? (
|
||||
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
|
||||
-20%
|
||||
</p>
|
||||
@@ -169,11 +165,11 @@ const Pricing = () => {
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency.value]}
|
||||
{tier.price[frequency?.value || "monthly"]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
{frequency.priceSuffix}
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -21,43 +23,66 @@ export default function HomeView() {
|
||||
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
|
||||
<div className="w-full pb-10 pt-32 lg:py-32">
|
||||
<div className="my-10 flex h-full w-full animate-fade-down flex-col items-center justify-center px-4">
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://github.com/kanbn/kan"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
Star on Github
|
||||
<IoLogoGithub size={20} />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
<div className="gradient-border absolute inset-0 animate-border-spin" />
|
||||
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://github.com/kanbn/kan"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
{t`Star on Github`}
|
||||
<IoLogoGithub size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-full bg-gradient-to-b from-light-300 to-light-400 p-[2px] dark:from-dark-300 dark:to-dark-400">
|
||||
<div className="relative z-10 rounded-full bg-light-50 dark:bg-dark-50">
|
||||
<Link
|
||||
href="https://news.ycombinator.com/item?id=44157177"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 px-4 py-1 text-center text-xs text-light-1000 dark:text-dark-1000 lg:text-sm"
|
||||
>
|
||||
{t`#1 Hacker News`}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-1 bg-white" />
|
||||
<IoLogoHackernews
|
||||
size={20}
|
||||
className="relative text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-4xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
The open source <br />
|
||||
alternative to Trello
|
||||
<Trans>
|
||||
The open source <br /> alternative to Trello
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-md mt-3 max-w-[450px] text-center text-dark-900 lg:max-w-[600px] lg:text-lg">
|
||||
A powerful, flexible kanban app that helps you organise work,
|
||||
track progress, and deliver results—all in one place.
|
||||
{t`A powerful, flexible kanban app that helps you organise work, track progress, and deliver results—all in one place.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button href="/signup">Get started on Cloud</Button>
|
||||
<Button href="/signup">{t`Get started on Cloud`}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="https://github.com/kanbn/kan"
|
||||
openInNewTab
|
||||
>
|
||||
Self host with Github
|
||||
{t`Self host with Github`}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-sm text-dark-900">
|
||||
No credit card required
|
||||
{t`No credit card required`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -21,8 +24,8 @@ export function DeleteMemberConfirmation() {
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to remove member",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to remove member`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
closeModal();
|
||||
@@ -41,18 +44,18 @@ export function DeleteMemberConfirmation() {
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{`Are you sure want to remove ${entityLabel}?`}
|
||||
<Trans>Are you sure want to remove {entityLabel}?</Trans>
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"They won't be able to access this workspace."}
|
||||
{t`They won't be able to access this workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteMember} isLoading={deleteMember.isPending}>
|
||||
Remove
|
||||
{t`Remove`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
@@ -46,7 +47,7 @@ export function InviteMemberForm() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">Add member</h2>
|
||||
<h2 className="text-sm font-bold">{t`Add member`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
@@ -60,7 +61,7 @@ export function InviteMemberForm() {
|
||||
</div>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
placeholder={t`Email`}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -78,7 +79,7 @@ export function InviteMemberForm() {
|
||||
isLoading={createBoard.isPending}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
Invite member
|
||||
{t`Invite member`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
@@ -25,6 +28,7 @@ export default function MembersPage() {
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
memberImage,
|
||||
@@ -34,6 +38,7 @@ export default function MembersPage() {
|
||||
showSkeleton,
|
||||
}: {
|
||||
memberPublicId?: string;
|
||||
memberId?: string | null | undefined;
|
||||
memberName?: string | null | undefined;
|
||||
memberEmail?: string | null | undefined;
|
||||
memberImage?: string | null | undefined;
|
||||
@@ -42,13 +47,19 @@ export default function MembersPage() {
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
}) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}>
|
||||
<div className="flex items-center p-4">
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-full sm:w-[65%]",
|
||||
isLastRow ? "rounded-bl-lg" : "",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center p-2 sm:p-4">
|
||||
<div className="flex-shrink-0">
|
||||
{showSkeleton ? (
|
||||
<div className="h-9 w-9 animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||
<div className="h-8 w-8 animate-pulse rounded-full bg-light-200 dark:bg-dark-200 sm:h-9 sm:w-9" />
|
||||
) : (
|
||||
<Avatar
|
||||
name={memberName ?? ""}
|
||||
@@ -62,7 +73,7 @@ export default function MembersPage() {
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={twMerge(
|
||||
"mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000",
|
||||
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -72,7 +83,7 @@ export default function MembersPage() {
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-sm text-dark-900",
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -85,15 +96,15 @@ export default function MembersPage() {
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-[35%] min-w-[150px]",
|
||||
"w-auto min-w-[120px] sm:w-[35%] sm:min-w-[150px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-3">
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between px-2 sm:px-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20",
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
@@ -102,8 +113,8 @@ export default function MembersPage() {
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
</span>
|
||||
{memberStatus === "invited" && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
|
||||
Pending
|
||||
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
|
||||
{t`Pending`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -113,24 +124,26 @@ export default function MembersPage() {
|
||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||
)}
|
||||
>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Remove member",
|
||||
action: () =>
|
||||
openModal(
|
||||
"REMOVE_MEMBER",
|
||||
memberPublicId,
|
||||
memberEmail ?? "",
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={25}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</Dropdown>
|
||||
{session?.user.id !== memberId && (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Remove member`,
|
||||
action: () =>
|
||||
openModal(
|
||||
"REMOVE_MEMBER",
|
||||
memberPublicId,
|
||||
memberEmail ?? "",
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -140,11 +153,11 @@ export default function MembersPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`Members | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto max-w-[1600px] px-28 py-12">
|
||||
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto h-full max-w-[1600px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="mb-8 flex w-full justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
Members
|
||||
{t`Members`}
|
||||
</h1>
|
||||
<div className="flex">
|
||||
<Button
|
||||
@@ -152,29 +165,29 @@ export default function MembersPage() {
|
||||
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
|
||||
disabled={workspace.role !== "admin"}
|
||||
>
|
||||
Invite
|
||||
{t`Invite`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="-mx-4 -my-2 overflow-x-visible sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full px-4 py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[65%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
|
||||
className="w-full rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[65%] sm:pl-6"
|
||||
>
|
||||
User
|
||||
{t`User`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[35%] rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
className="w-auto whitespace-nowrap rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:w-[35%]"
|
||||
>
|
||||
Role
|
||||
{t`Role`}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -184,6 +197,7 @@ export default function MembersPage() {
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Editor from "~/components/Editor";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import ActivityList from "~/views/card/components/ActivityList";
|
||||
@@ -16,6 +20,7 @@ export function CardModal({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal, isOpen } = useModal();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
{
|
||||
@@ -90,15 +95,13 @@ export function CardModal({
|
||||
{data?.description && (
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<div className="mt-2">
|
||||
<p className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6">
|
||||
{data.description}
|
||||
</p>
|
||||
<Editor content={data.description} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t-[1px] border-light-600 pb-4 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
Activity
|
||||
{t`Activity`}
|
||||
</h2>
|
||||
<div>
|
||||
{cardPublicId && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiLink } from "react-icons/hi2";
|
||||
@@ -47,9 +48,9 @@ export default function PublicBoardView() {
|
||||
}
|
||||
|
||||
showPopup({
|
||||
header: "Link copied",
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: "Board URL copied to clipboard",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
});
|
||||
}}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
@@ -82,7 +83,7 @@ export default function PublicBoardView() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${data?.name ?? "Board"} | ${data?.workspace?.name ?? "Workspace"}`}
|
||||
title={`${data?.name ?? "Board"} | ${data?.workspace.name ?? "Workspace"}`}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
html {
|
||||
@@ -133,11 +134,11 @@ export default function PublicBoardView() {
|
||||
{list.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600">
|
||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||
{list.cards.map((card) => (
|
||||
<Link
|
||||
key={card.publicId}
|
||||
href={`/${data.workspace?.slug}/${data.slug}/${card.publicId}`}
|
||||
href={`/${data.workspace.slug}/${data.slug}/${card.publicId}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col`}
|
||||
shallow={true}
|
||||
onClick={() => {
|
||||
@@ -177,7 +178,7 @@ export default function PublicBoardView() {
|
||||
<Modal modalSize={"md"} positionFromTop={"sm"}>
|
||||
<CardModal
|
||||
cardPublicId={cardPublicId}
|
||||
workspaceSlug={data?.workspace?.slug}
|
||||
workspaceSlug={data?.workspace.slug}
|
||||
boardSlug={data?.slug}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -20,8 +21,8 @@ export default function Avatar({
|
||||
const updateUser = api.user.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: "Profile image updated",
|
||||
message: "Your profile image has been updated.",
|
||||
header: t`Profile image updated`,
|
||||
message: t`Your profile image has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
@@ -33,8 +34,8 @@ export default function Avatar({
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Error updating profile image",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Error updating profile image`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -50,8 +51,8 @@ export default function Avatar({
|
||||
|
||||
if (!file || !userId) {
|
||||
return showPopup({
|
||||
header: "Error uploading profile image",
|
||||
message: "Please select a file to upload.",
|
||||
header: t`Error uploading profile image`,
|
||||
message: t`Please select a file to upload.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
@@ -91,8 +92,8 @@ export default function Avatar({
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: "Error uploading profile image",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Error uploading profile image`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -44,12 +46,12 @@ const CreateAPIKeyForm = ({
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="danger" onClick={handleRevokeAPIKey}>
|
||||
Revoke
|
||||
{t`Revoke`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleCreateAPIKey}>Create new key</Button>
|
||||
<Button onClick={handleCreateAPIKey}>{t`Create new key`}</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
@@ -37,19 +39,17 @@ export function CustomURLConfirmation({
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{`Confirm URL change`}
|
||||
{t`Confirm URL change`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{
|
||||
"Custom URLs are a premium feature. You'll be directed to upgrade your account."
|
||||
}
|
||||
{t`Custom URLs are a premium feature. You'll be directed to upgrade your account.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade}>Upgrade</Button>
|
||||
<Button onClick={handleUpgrade}>{t`Upgrade`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteAccountConfirmation() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationFn: () => authClient.deleteUser(),
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Account deleted`,
|
||||
message: t`Your account has been deleted.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
utils.invalidate();
|
||||
|
||||
router.push("/");
|
||||
},
|
||||
onError: () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Error deleting account`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
deleteAccountMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Are you sure you want to delete your account?`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Keep in mind that this action is irreversible.`}
|
||||
</p>
|
||||
<p className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`This will result in the permanent deletion of all data associated with your account.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
id="acknowledgment"
|
||||
name="acknowledgment"
|
||||
type="checkbox"
|
||||
aria-describedby="acknowledgment-description"
|
||||
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
|
||||
checked={isAcknowledgmentChecked}
|
||||
onChange={() =>
|
||||
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<p
|
||||
id="comments-description"
|
||||
className="text-light-900 dark:text-dark-1000"
|
||||
>
|
||||
{t`I acknowledge that all of my account data will be permanently deleted and want to proceed.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={!isAcknowledgmentChecked}
|
||||
isLoading={deleteAccountMutation.isPending}
|
||||
>
|
||||
{t`Delete account`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -15,14 +17,19 @@ export function DeleteWorkspaceConfirmation() {
|
||||
|
||||
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "Workspace deleted",
|
||||
message: "Your workspace has been deleted.",
|
||||
header: t`Workspace deleted`,
|
||||
message: t`Your workspace has been deleted.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
await utils.workspace.all.refetch();
|
||||
|
||||
const filteredWorkspaces = availableWorkspaces.filter(
|
||||
(ws) => ws.publicId !== workspace.publicId,
|
||||
);
|
||||
@@ -35,8 +42,8 @@ export function DeleteWorkspaceConfirmation() {
|
||||
onError: () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "Error deleting workspace",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Error deleting workspace`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -52,14 +59,15 @@ export function DeleteWorkspaceConfirmation() {
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{`Are you sure you want to delete the workspace ${workspace.name}?`}
|
||||
<Trans>
|
||||
Are you sure you want to delete the workspace {workspace.name}?
|
||||
</Trans>
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||
Keep in mind that this action is irreversible.
|
||||
{t`Keep in mind that this action is irreversible.`}
|
||||
</p>
|
||||
<p className="text-sm text-light-900 dark:text-dark-900">
|
||||
This will result in the permanent deletion of all data associated with
|
||||
this workspace.
|
||||
{t`This will result in the permanent deletion of all data associated with this workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
@@ -81,14 +89,13 @@ export function DeleteWorkspaceConfirmation() {
|
||||
id="comments-description"
|
||||
className="text-light-900 dark:text-dark-1000"
|
||||
>
|
||||
I acknowledge that all of the workspace data will be permanently
|
||||
deleted and want to proceed.
|
||||
{t`I acknowledge that all of the workspace data will be permanently deleted and want to proceed.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
@@ -96,7 +103,7 @@ export function DeleteWorkspaceConfirmation() {
|
||||
disabled={!isAcknowledgmentChecked}
|
||||
isLoading={deleteWorkspaceMutation.isPending}
|
||||
>
|
||||
Delete workspace
|
||||
{t`Delete workspace`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,22 +8,22 @@ import Input from "~/components/Input";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: "Display namemust be at least 3 characters long",
|
||||
})
|
||||
.max(280, {
|
||||
message: "Display name cannot exceed 280 characters",
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: t`Display name must be at least 3 characters long`,
|
||||
})
|
||||
.max(280, {
|
||||
message: t`Display name cannot exceed 280 characters`,
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -37,8 +38,8 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
|
||||
const updateDisplayName = api.user.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: "Display name updated",
|
||||
message: "Your display name has been updated.",
|
||||
header: t`Display name updated`,
|
||||
message: t`Your display name has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
@@ -50,8 +51,8 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Error updating display name",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Error updating display name`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -75,7 +76,7 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
|
||||
disabled={!isDirty || updateDisplayName.isPending}
|
||||
isLoading={updateDisplayName.isPending}
|
||||
>
|
||||
Update
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,19 +8,6 @@ import Input from "~/components/Input";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const schema = z.object({
|
||||
description: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: "Workspace description must be at least 3 characters long",
|
||||
})
|
||||
.max(280, {
|
||||
message: "Workspace description cannot exceed 280 characters",
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const UpdateWorkspaceDescriptionForm = ({
|
||||
workspacePublicId,
|
||||
workspaceDescription,
|
||||
@@ -29,6 +17,20 @@ const UpdateWorkspaceDescriptionForm = ({
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const schema = z.object({
|
||||
description: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: t`Workspace description must be at least 3 characters long`,
|
||||
})
|
||||
.max(280, {
|
||||
message: t`Workspace description cannot exceed 280 characters`,
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -43,8 +45,8 @@ const UpdateWorkspaceDescriptionForm = ({
|
||||
const updateWorkspaceDescription = api.workspace.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: "Workspace description updated",
|
||||
message: "Your workspace description has been updated.",
|
||||
header: t`Workspace description updated`,
|
||||
message: t`Your workspace description has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
@@ -56,8 +58,8 @@ const UpdateWorkspaceDescriptionForm = ({
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Error updating workspace description",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Error updating workspace description`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -85,7 +87,7 @@ const UpdateWorkspaceDescriptionForm = ({
|
||||
disabled={!isDirty || updateWorkspaceDescription.isPending}
|
||||
isLoading={updateWorkspaceDescription.isPending}
|
||||
>
|
||||
Update
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user