Compare commits
1 Commits
cloud/pric
...
fix/openap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb85a44676 |
@@ -3,8 +3,6 @@
|
|||||||
# Required environment variables
|
# Required environment variables
|
||||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||||
|
|
||||||
# Fill if you want to use an external database
|
|
||||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||||
|
|
||||||
# Only required if deploying from compose
|
# Only required if deploying from compose
|
||||||
@@ -18,15 +16,11 @@ SMTP_PASSWORD=
|
|||||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||||
SMTP_SECURE= # set to "false" to use port 587
|
SMTP_SECURE= # set to "false" to use port 587
|
||||||
|
|
||||||
# Switch email features off entirely (optional)
|
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
|
||||||
|
|
||||||
# S3 storage (optional)
|
# S3 storage (optional)
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
S3_ENDPOINT=
|
S3_ENDPOINT=
|
||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
S3_FORCE_PATH_STYLE=
|
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -50,6 +50,3 @@ docker-compose.override.yml
|
|||||||
# runtime env
|
# runtime env
|
||||||
__ENV.js
|
__ENV.js
|
||||||
i18n.cache
|
i18n.cache
|
||||||
|
|
||||||
# pgdata
|
|
||||||
/apps/web/pgdata
|
|
||||||
|
|||||||
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
|||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm dev",
|
"command": "pnpm dev",
|
||||||
"cwd": "${workspaceFolder}/apps/web",
|
"cwd": "${workspaceFolder}/apps/nextjs",
|
||||||
"skipFiles": ["<node_internals>/**"],
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"sourceMapPathOverrides": {
|
"sourceMapPathOverrides": {
|
||||||
|
|||||||
38
CHANGELOG.md
@@ -5,43 +5,7 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.4...HEAD)
|
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.2...HEAD)
|
||||||
|
|
||||||
## [0.2.4](https://github.com/kanbn/kan/compare/v0.2.3...v0.2.4) - 2025-01-14
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Button to view and update board public URL directly from board page
|
|
||||||
- Redirect from root to login page for self-hosted instances
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Updated all label router endpoints to return consistent structure
|
|
||||||
- Added verbose error logging to magic link invitation process
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Updated tRPC dependencies and added OpenAPI meta to providers endpoint
|
|
||||||
|
|
||||||
## [0.2.3](https://github.com/kanbn/kan/compare/v0.2.2...v0.2.3) - 2025-07-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Mobile support with responsive navigation and layout
|
|
||||||
- Template selection when creating new boards with predefined lists and labels
|
|
||||||
- S3_FORCE_PATH_STYLE configuration for MinIO compatibility
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Improved mobile UI styling and layout containers
|
|
||||||
- Enhanced iOS mobile support with viewport fixes and zoom prevention
|
|
||||||
- Improved card view layout for mobile devices
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Mobile dashboard min-width issues
|
|
||||||
- Dynamic viewport height on mobile devices
|
|
||||||
- Scroll functionality on card pages
|
|
||||||
|
|
||||||
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28
|
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28
|
||||||
|
|
||||||
|
|||||||
66
README.md
@@ -27,7 +27,7 @@
|
|||||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||||
- 💬 **Comments**: Discuss and collaborate with your team
|
- 💬 **Comments**: Discuss and collaborate with your team
|
||||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
||||||
- 🎨 **Templates** : Save time with reusable custom board templates
|
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
|
||||||
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||||
|
|
||||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||||
@@ -138,41 +138,35 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
| -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `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_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `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_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | 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_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | 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` |
|
||||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
|
||||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
|
||||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
|
||||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -30,5 +30,3 @@ Install our Github App to auto propagate changes from your repo to your deployme
|
|||||||
|
|
||||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||||
|
|
||||||
#BUMP
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Introduction"
|
|
||||||
description: "Overview and quick start to run Kan on your own infrastructure using Docker Compose."
|
|
||||||
mode: "wide"
|
|
||||||
tag: "NEW"
|
|
||||||
---
|
|
||||||
|
|
||||||
This guide introduces how to self-host Kan. It starts with the minimal Docker Compose setup (web + PostgreSQL) and points you to optional features like email and S3-based file storage.
|
|
||||||
|
|
||||||
## What you’ll set up
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Kan" icon="globe" color="#0284c7" horizontal>
|
|
||||||
Next.js application served on port 3000.
|
|
||||||
</Card>
|
|
||||||
<Card title="PostgreSQL 15" icon="database" color="#65a30d" horizontal>
|
|
||||||
Primary database for Kan data.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
For file uploads (avatars), OAuth, and other advanced options, see the
|
|
||||||
Environment Variables section in the README and the dedicated [S3
|
|
||||||
guide](/guides/self-hosting/s3). The [full
|
|
||||||
compose](https://github.com/kanbn/kan/blob/main/docker-compose.yml) in the
|
|
||||||
repo includes a richer configuration via <code>.env</code>.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker and Docker Compose
|
|
||||||
- A long random string for <code>BETTER_AUTH_SECRET</code> (32+ chars)
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Create a docker-compose.yml">
|
|
||||||
Paste the following minimal configuration into a new <code>docker-compose.yml</code> file:
|
|
||||||
|
|
||||||
```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:
|
|
||||||
```
|
|
||||||
|
|
||||||
<Tip>
|
|
||||||
The example above is intentionally minimal. The repository provides a more feature-complete compose file at [docker-compose.yml](https://github.com/kanbn/kan/blob/main/docker-compose.yml) if you want environment-based configuration, OAuth, S3, and more.
|
|
||||||
</Tip>
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Start the stack">
|
|
||||||
Bring everything up in detached mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Once started, open [http://localhost:3000](http://localhost:3000).
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Manage the containers">
|
|
||||||
Useful commands while developing or testing:
|
|
||||||
|
|
||||||
- Stop the containers: <code>docker compose down</code>
|
|
||||||
- View logs: <code>docker compose logs -f</code>
|
|
||||||
- Restart: <code>docker compose restart</code>
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Configure environment (optional)">
|
|
||||||
For a production-like setup and more features (email, OAuth, file uploads, etc.), create a <code>.env</code> file and set the relevant variables shown in the README’s Environment Variables section.
|
|
||||||
|
|
||||||
<Accordion title="Common variables">
|
|
||||||
```bash
|
|
||||||
# Required
|
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
|
||||||
BETTER_AUTH_SECRET=replace_with_long_random_string
|
|
||||||
POSTGRES_URL=postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
|
||||||
|
|
||||||
# Optional: Email
|
|
||||||
EMAIL_FROM="Kan <hello@mail.kan.bn>"
|
|
||||||
SMTP_HOST=smtp.resend.com
|
|
||||||
SMTP_PORT=465
|
|
||||||
SMTP_USER=resend
|
|
||||||
SMTP_PASSWORD=re_xxxx
|
|
||||||
SMTP_SECURE=true
|
|
||||||
|
|
||||||
# Optional: Auth toggles
|
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP=false
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note type="warning">
|
|
||||||
If you plan to enable file uploads (avatars, etc.), you’ll also need S3 variables (<code>S3_ENDPOINT</code>, <code>S3_ACCESS_KEY_ID</code>, <code>S3_SECRET_ACCESS_KEY</code>, <code>NEXT_PUBLIC_STORAGE_URL</code>, <code>NEXT_PUBLIC_STORAGE_DOMAIN</code>, …). See the S3 guide linked at the top.
|
|
||||||
</Note>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
- [GitHub README](https://github.com/kanbn/kan/blob/main/README.md#self-hosting-)
|
|
||||||
- [GitHub docker-compose.yml](https://github.com/kanbn/kan/blob/main/docker-compose.yml)
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Kan + MinIO (S3)"
|
|
||||||
mode: "wide"
|
|
||||||
tag: "NEW"
|
|
||||||
---
|
|
||||||
|
|
||||||
Deploy Kan with PostgreSQL and MinIO (S3-compatible storage) using Docker Compose, with clear steps and production notes.
|
|
||||||
|
|
||||||
## What you’ll set up
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Kan" icon="globe" color="#0284c7" horizontal>
|
|
||||||
Kan web app (Next.js), on port 3000.
|
|
||||||
</Card>
|
|
||||||
<Card title="PostgreSQL 15" icon="database" color="#65a30d" horizontal>
|
|
||||||
PostgreSQL database for Kan.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
<Card title="MinIO (S3-compatible)" icon="cloud" color="#ca8a04" horizontal>
|
|
||||||
MinIO object storage (console port 9001, S3 API port 9000).
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
- Kan stores data in PostgreSQL.
|
|
||||||
- Kan uploads files (e.g., avatars) to MinIO over the S3 API.
|
|
||||||
- The browser fetches public files directly from MinIO’s public URL.
|
|
||||||
- The Next.js image optimizer in Kan must be explicitly allowed to fetch from your storage host.
|
|
||||||
|
|
||||||
Key domain settings:
|
|
||||||
|
|
||||||
- <code>NEXT_PUBLIC_BASE_URL</code> → the Kan site
|
|
||||||
- <code>NEXT_PUBLIC_STORAGE_URL</code> → the public S3 base URL
|
|
||||||
- <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> → the exact S3 hostname (no scheme)
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker and Docker Compose
|
|
||||||
- Open local ports: 3000 (Kan), 5432 (Postgres), 9000/9001 (MinIO)
|
|
||||||
- A long random string for <code>BETTER_AUTH_SECRET</code> (32+ chars)
|
|
||||||
|
|
||||||
<Note type="warning">
|
|
||||||
For production you’ll want a reverse proxy (Traefik/Nginx/Caddy), valid TLS
|
|
||||||
certificates, and DNS for your domains (e.g., <code>kan.example.com</code>,{" "}
|
|
||||||
<code>s3.example.com</code>).
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
<Tip type="info">
|
|
||||||
Why <code>localtest.me</code>? It resolves to <code>127.0.0.1</code>{" "}
|
|
||||||
automatically, so you can test domain-based configs locally without editing
|
|
||||||
hosts.
|
|
||||||
</Tip>
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Set environment variables">
|
|
||||||
Provide the minimum required configuration (local example):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000
|
|
||||||
BETTER_AUTH_SECRET=<long random string>
|
|
||||||
POSTGRES_URL=postgresql://kan:<password>@postgres:5432/kan_db
|
|
||||||
|
|
||||||
# MinIO/S3
|
|
||||||
S3_ENDPOINT=http://s3.localtest.me:9000
|
|
||||||
S3_ACCESS_KEY_ID=<minio-access-key>
|
|
||||||
S3_SECRET_ACCESS_KEY=<minio-secret-key>
|
|
||||||
S3_REGION=none
|
|
||||||
S3_FORCE_PATH_STYLE=true
|
|
||||||
|
|
||||||
# Public storage access
|
|
||||||
NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000
|
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me
|
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note type="info">
|
|
||||||
Issue #109 fix: make sure <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> exactly
|
|
||||||
equals the hostname that serves your images (no scheme, no port).
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
Optional (see README for full list): Email (`EMAIL_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE`), OAuth/OIDC (`GOOGLE_*`, `GITHUB_*`, `OIDC_*`), auth toggles, Trello import, etc.
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Create or review Docker Compose files">
|
|
||||||
You can start from the minimal compose at the repository root (<code>docker-compose.yml</code>) and review the production-oriented settings in <code>cloud/docker-compose.yml</code>.
|
|
||||||
|
|
||||||
Start with the minimal setup (web + postgres + minio) and ensure environment variables are passed to the web service.
|
|
||||||
|
|
||||||
<Accordion title="Docker Compose example">
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:15
|
|
||||||
container_name: kan-db
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: kan
|
|
||||||
POSTGRES_PASSWORD: changeme
|
|
||||||
POSTGRES_DB: kan_db
|
|
||||||
volumes:
|
|
||||||
- pg_data:/var/lib/postgresql/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
container_name: kan-minio
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
ports:
|
|
||||||
- "9000:9000" # S3 API
|
|
||||||
- "9001:9001" # Console
|
|
||||||
environment:
|
|
||||||
# Use the same credentials in your .env
|
|
||||||
# as S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
|
|
||||||
MINIO_ROOT_USER: minio
|
|
||||||
MINIO_ROOT_PASSWORD: minio123456789
|
|
||||||
volumes:
|
|
||||||
- minio_data:/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
web:
|
|
||||||
image: ghcr.io/kanbn/kan:latest
|
|
||||||
container_name: kan-web
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
- minio
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
# Load variables from .env
|
|
||||||
# (see the "Set environment variables" step)
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pg_data:
|
|
||||||
minio_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note type="info">
|
|
||||||
Ensure your <code>.env</code> contains values that match this compose file.
|
|
||||||
For example:
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<code>POSTGRES_URL=postgresql://kan:changeme@postgres:5432/kan_db</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>S3_ENDPOINT=http://s3.localtest.me:9000</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>S3_ACCESS_KEY_ID=minio</code> and{" "}
|
|
||||||
<code>S3_SECRET_ACCESS_KEY=minio123456789</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>S3_FORCE_PATH_STYLE=true</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan</code>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Start services">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open:
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Kan: <a href="http://kan.localtest.me:3000">http://kan.localtest.me:3000</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
MinIO Console:{" "}
|
|
||||||
<a href="http://minio.localtest.me:9001">http://minio.localtest.me:9001</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
MinIO S3 API:{" "}
|
|
||||||
<a href="http://s3.localtest.me:9000">http://s3.localtest.me:9000</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Initialize MinIO">
|
|
||||||
1) Log into the MinIO Console (http://minio.localtest.me:9001).
|
|
||||||
|
|
||||||
2. Create a bucket (e.g., <code>kan</code>).
|
|
||||||
|
|
||||||
3. For simple public avatars, apply a read-only policy so GET requests are allowed for objects:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": { "AWS": ["*"] },
|
|
||||||
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
|
|
||||||
"Resource": ["arn:aws:s3:::kan"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": { "AWS": ["*"] },
|
|
||||||
"Action": ["s3:GetObject"],
|
|
||||||
"Resource": ["arn:aws:s3:::kan/*"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<Note type="warning">
|
|
||||||
Alternatively, keep the bucket private and use presigned URLs. In that case,
|
|
||||||
ensure your server and browser access paths are correctly configured.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Verify the setup">
|
|
||||||
<ul>
|
|
||||||
<li>Sign in to Kan and upload an avatar (Settings).</li>
|
|
||||||
<li>Confirm the object is created in your MinIO bucket.</li>
|
|
||||||
<li>The avatar should render without errors.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
If you see a 400 from <code>/\_next/image</code> with “url parameter is not allowed”:
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<code>NEXT_PUBLIC_STORAGE_DOMAIN</code> must exactly match the S3 hostname
|
|
||||||
that serves images.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>NEXT_PUBLIC_STORAGE_URL</code> should use the same host (with
|
|
||||||
scheme/port).
|
|
||||||
</li>
|
|
||||||
<li>Ensure you’re using the latest Kan image.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
## Production setup
|
|
||||||
|
|
||||||
<Tabs>
|
|
||||||
<Tab title="Local">
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li><code>NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000</code></li>
|
|
||||||
<li><code>S3_ENDPOINT=http://s3.localtest.me:9000</code></li>
|
|
||||||
<li><code>NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000</code></li>
|
|
||||||
<li><code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me</code></li>
|
|
||||||
<li>Keep <code>S3_FORCE_PATH_STYLE=true</code> for MinIO.</li>
|
|
||||||
</ul>
|
|
||||||
</Tab>
|
|
||||||
<Tab title="Production">
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li><code>NEXT_PUBLIC_BASE_URL=https://kan.example.com</code></li>
|
|
||||||
<li><code>S3_ENDPOINT=https://s3.example.com</code></li>
|
|
||||||
<li><code>NEXT_PUBLIC_STORAGE_URL=https://s3.example.com</code></li>
|
|
||||||
<li><code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.example.com</code></li>
|
|
||||||
<li>Keep <code>S3_FORCE_PATH_STYLE=true</code> for MinIO.</li>
|
|
||||||
<li>Put Kan and MinIO behind HTTPS with a reverse proxy (Traefik/Nginx) and valid TLS.</li>
|
|
||||||
</ul>
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
<AccordionGroup>
|
|
||||||
<Accordion title="Files upload but don’t display">
|
|
||||||
<ul>
|
|
||||||
<li>If public: confirm GET is allowed on objects (bucket policy).</li>
|
|
||||||
<li>If private: ensure presigned URLs are generated and valid.</li>
|
|
||||||
<li>403 AccessDenied indicates permissions, not CORS. CORS is not required for simple <code><img></code> GETs.</li>
|
|
||||||
</ul>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion title="Make the bucket public (read-only) with mc" defaultOpen>
|
|
||||||
Use your MinIO root credentials to allow anonymous reads:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Replace with your MINIO_ROOT_PASSWORD
|
|
||||||
MINIO_PASS='<your-minio-password>'
|
|
||||||
|
|
||||||
# Point mc at MinIO via the container network (no ports required)
|
|
||||||
docker run --rm --network container:kan-minio minio/mc \
|
|
||||||
mc alias set local http://127.0.0.1:9000 minio "$MINIO_PASS"
|
|
||||||
|
|
||||||
# Allow public downloads from the bucket
|
|
||||||
docker run --rm --network container:kan-minio minio/mc \
|
|
||||||
mc anonymous set download local/kan
|
|
||||||
|
|
||||||
# Optional: verify anonymous status
|
|
||||||
docker run --rm --network container:kan-minio minio/mc \
|
|
||||||
mc anonymous get local/kan
|
|
||||||
```
|
|
||||||
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion title="Alternative: S3 bucket policy (AWS CLI)">
|
|
||||||
If you prefer a bucket policy, apply a public-read policy for objects:
|
|
||||||
|
|
||||||
```json policy.json
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Sid": "PublicReadGetObject",
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": "*",
|
|
||||||
"Action": ["s3:GetObject"],
|
|
||||||
"Resource": ["arn:aws:s3:::kan/*"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use your MinIO root credentials
|
|
||||||
MINIO_PASS='<your-minio-password>'
|
|
||||||
|
|
||||||
docker run --rm --network container:kan-minio \
|
|
||||||
-e AWS_ACCESS_KEY_ID=minio \
|
|
||||||
-e AWS_SECRET_ACCESS_KEY="$MINIO_PASS" \
|
|
||||||
-e AWS_DEFAULT_REGION=us-east-1 -e AWS_S3_FORCE_PATH_STYLE=true \
|
|
||||||
-v "$PWD:/work" amazon/aws-cli \
|
|
||||||
s3api put-bucket-policy --bucket kan --policy file:///work/policy.json \
|
|
||||||
--endpoint-url http://127.0.0.1:9000
|
|
||||||
```
|
|
||||||
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion title="Next.js optimizer 400 — url parameter is not allowed">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Exact match on <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> with your storage
|
|
||||||
host.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Same host in <code>NEXT_PUBLIC_STORAGE_URL</code>.
|
|
||||||
</li>
|
|
||||||
<li>Update to the latest Kan image.</li>
|
|
||||||
</ul>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion title="Next/Image: “url parameter is valid but upstream response is invalid”">
|
|
||||||
<ul>
|
|
||||||
<li>This means Next.js accepted the URL, but the upstream returned a non-image (e.g., 403 HTML/XML).</li>
|
|
||||||
<li>Fix: make the bucket/object publicly readable (see above), or use presigned URLs.</li>
|
|
||||||
<li>Sanity test from the web network (replace with your image URL):</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
IMG_URL="https://s3.example.com/kan/path/to/avatar.jpg"
|
|
||||||
|
|
||||||
# Headers/content-type as seen from the app network
|
|
||||||
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
|
|
||||||
-I -L --max-redirs 5 "$IMG_URL"
|
|
||||||
|
|
||||||
# Quick status + content-type summary
|
|
||||||
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
|
|
||||||
-s -o /dev/null -w "HTTP:%{http_code} CT:%{content_type} URL:%{url_effective}\n" -L "$IMG_URL"
|
|
||||||
```
|
|
||||||
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Accordion title="Connectivity checks">
|
|
||||||
<ul>
|
|
||||||
<li>The Kan container must reach <code>S3_ENDPOINT</code>.</li>
|
|
||||||
<li>Verify DNS/ports inside the container (e.g., <code>docker exec -it <kan-container> sh</code>).</li>
|
|
||||||
</ul>
|
|
||||||
</Accordion>
|
|
||||||
</AccordionGroup>
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Kan README](https://github.com/kanbn/kan/blob/main/README.md)
|
|
||||||
- [Cloud compose reference](https://github.com/kanbn/kan/blob/main/cloud/docker-compose.yml)
|
|
||||||
- [Kan #109 Issue](https://github.com/kanbn/kan/issues/109)
|
|
||||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 433 KiB |
@@ -44,18 +44,6 @@
|
|||||||
"group": "Get Started",
|
"group": "Get Started",
|
||||||
"pages": ["introduction"]
|
"pages": ["introduction"]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"group": "Guides",
|
|
||||||
"pages": [
|
|
||||||
{
|
|
||||||
"group": "Self-Hosting",
|
|
||||||
"pages": [
|
|
||||||
"guides/self-hosting/introduction",
|
|
||||||
"guides/self-hosting/s3"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"group": "Import",
|
"group": "Import",
|
||||||
"pages": ["imports/trello"]
|
"pages": ["imports/trello"]
|
||||||
|
|||||||
@@ -81,4 +81,5 @@ ARG PORT=3000
|
|||||||
ENV PORT=${PORT}
|
ENV PORT=${PORT}
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": 0,
|
"version": 0,
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru"]
|
"targets": ["fr", "de", "es", "it", "nl"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"po": {
|
||||||
|
|||||||
@@ -3,76 +3,48 @@ checksums:
|
|||||||
8033ed03a24d7c4898c90a5365297c20:
|
8033ed03a24d7c4898c90a5365297c20:
|
||||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||||
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
|
||||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||||
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
|
||||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||||
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
|
"%231%20Hacker%20News/singular": de271b412c6c022d5c427d29fc70e0ba
|
||||||
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
|
||||||
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
|
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
|
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||||
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
|
||||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||||
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
|
||||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||||
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
||||||
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
||||||
Add%20an%20item.../singular: f8e7993980420698700b07f56b3a38af
|
Add%20description...%20(type%20'%2F'%20to%20open%20commands)/singular: df99ea599521682e9ad0a90716955dc6
|
||||||
Add%20checklist/singular: 05d73200b56cfea75fb3a0f94c9bfb74
|
|
||||||
Add%20description...%20(type%20'%2F'%20to%20open%20commands%20or%20'%40'%20to%20mention)/singular: c6eb35a796dd7ac0e3dbc66244b71248
|
|
||||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
|
||||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||||
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
|
||||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
|
||||||
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
||||||
added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2
|
added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2
|
||||||
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
|
added%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1caba6b6bca4c6fa1ab173d241059dc2
|
||||||
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
|
|
||||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
|
||||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
|
||||||
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
|
||||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||||
|
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
|
||||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
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
|
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
|
||||||
Anyone%20with%20this%20link%20can%20join%20your%20workspace/singular: 2366ed295eb2c03c425559c24cb31606
|
|
||||||
API/singular: 01d9819514e27056dcc69463194b63d2
|
|
||||||
API%20key%20created/singular: 8dbb2b60a719b0d120e774d6666c8c45
|
|
||||||
API%20key%20name/singular: 2d8aeb08b2cce3b750a584bbc5ce6d1d
|
|
||||||
API%20key%20name%20cannot%20exceed%2030%20characters/singular: 3ea1c7d68e1074a75128017b097b727a
|
|
||||||
API%20key%20name%20is%20required/singular: 870970743b14cb56e7fb7b410af0e933
|
|
||||||
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
|
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
|
||||||
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
|
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
|
||||||
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
|
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
|
||||||
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
||||||
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
|
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%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20%7B0%7D%3F/singular: e980ce9469e329ddfed14c7da2cb5e52
|
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
|
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
|
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
|
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
|
||||||
assigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
|
assigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20to%20the%20card/singular: 4fb1c419a78a0f010a3e5790801551fc
|
||||||
Assignees/singular: 96fc0e28a783d80decfd4716a440ac08
|
|
||||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||||
billed%20annually/singular: c782a8085ffee456bfc56c34ccc5e71c
|
|
||||||
billed%20monthly/singular: 793f2728acf06e9f06d6cdc26dfcf0cd
|
|
||||||
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
|
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
|
||||||
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
|
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
|
||||||
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
|
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
|
||||||
Board/singular: 101ff39aab674c033c15ab978d0d8bac
|
Board/singular: 101ff39aab674c033c15ab978d0d8bac
|
||||||
Board%20analytics/singular: 336a8c70a91adc49c15266167ee84cc0
|
|
||||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
||||||
Board%20sharing%20%26%20invites/singular: c9222fbcfac4e99dcaa859b46c163e55
|
|
||||||
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
|
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
|
||||||
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
||||||
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
||||||
@@ -80,32 +52,25 @@ checksums:
|
|||||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||||
|
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
||||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||||
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
|
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
|
||||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
|
||||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
|
||||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
|
||||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
|
||||||
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
|
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
|
||||||
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
||||||
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
||||||
Comments/singular: 502237cd1044491c48494aa60967a985
|
Comments/singular: 502237cd1044491c48494aa60967a985
|
||||||
Comments%20%26%20mentions/singular: 1f3a822f4f97a9b07db82d24c4204f75
|
|
||||||
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||||
Compare%20our%20features%20to%20see%20why%20Kan%20is%20the%20best%20choice./singular: c186ed37ff27cfc2a1ac11e66b2b30f1
|
|
||||||
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
||||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
|
||||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
|
||||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
|
||||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||||
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
|
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||||
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
|
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
|
||||||
@@ -115,46 +80,29 @@ checksums:
|
|||||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
|
||||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
|
||||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||||
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
|
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
|
||||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||||
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
|
||||||
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
|
|
||||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||||
Create%20new%20%7B0%7D/singular: 163dbd5cf86d512fadd4c3eaf9709a11
|
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
||||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||||
Create%20template/singular: 57bfe6b58c42958a90f67d605a69ecdc
|
|
||||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||||
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
|
|
||||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
|
||||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
|
||||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
Custom%20URLs%20are%20a%20premium%20feature.%20You'll%20be%20directed%20to%20upgrade%20your%20account./singular: b52e4f6122cf739c9551d29beb89b25e
|
||||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
|
||||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
|
||||||
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
|
||||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||||
Deactivate%20invite%20link/singular: 8cf4bf82e153e4dc82d026448cb4dd10
|
|
||||||
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
||||||
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
|
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
|
||||||
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
|
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
|
||||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||||
Delete%20template/singular: bdf3e8342f581d498b7238cdd4897a4b
|
|
||||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
|
||||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
|
||||||
deleted%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 60f9107197e756487d3f95a1083846b5
|
|
||||||
deleted%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 8b7e270f612f9e91bda7f5cfb853d843
|
|
||||||
Design/singular: 991b75727b6784c1a063a7462b76186d
|
Design/singular: 991b75727b6784c1a063a7462b76186d
|
||||||
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
|
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
|
||||||
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
|
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
|
||||||
@@ -171,45 +119,28 @@ checksums:
|
|||||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
|
||||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||||
email/singular: f31eb214738e037d58e26149797739df
|
email/singular: f31eb214738e037d58e26149797739df
|
||||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
|
||||||
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
|
||||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||||
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
||||||
Enter%20your%20new%20password/singular: c67251e3002b68bc20a7cf5de23e43ac
|
|
||||||
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
||||||
Error/singular: 3c95bcb32c2104b99a46f5b3dd015248
|
|
||||||
Error%20Changing%20Password/singular: ebecb5c1b72ba4b063117241f5ba4f2d
|
|
||||||
Error%20creating%20invite%20link/singular: cbedc3f3213dfc4fdc8b7503ae1a5cd6
|
|
||||||
Error%20deactivating%20invite%20link/singular: ccf42cd5aa8481692003e87e836de66c
|
|
||||||
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
|
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
|
||||||
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
|
|
||||||
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||||
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
|
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
|
||||||
Error%20inviting%20member/singular: 91553b606a6ebed3534cd2e5635ce004
|
|
||||||
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
|
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
|
||||||
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
|
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
|
||||||
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
|
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
|
||||||
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
|
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
|
||||||
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
|
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
|
||||||
Error%20upgrading%20subscription/singular: 57cc47581c7faaffb3b1721b6352b576
|
|
||||||
Error%20upgrading%20to%20Pro/singular: 934c6867d9bdbe6099b2c0f5a3107f78
|
|
||||||
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
|
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
|
||||||
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
|
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
|
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
|
||||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
|
||||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
|
||||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
|
||||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||||
Fast%20%26%20lightweight/singular: 4f2a80c555a5427b94eef5124aa10775
|
|
||||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||||
Feature%20breakdown/singular: cae23c2a3fbb21735289571f2e1f60db
|
|
||||||
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
||||||
Features/singular: 341ff316a339b106a178f0b8d362951b
|
Features/singular: 341ff316a339b106a178f0b8d362951b
|
||||||
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
||||||
@@ -218,20 +149,18 @@ checksums:
|
|||||||
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
|
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
|
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/singular: 0326365539c004f6088656f692602078
|
||||||
Free%20Plan/singular: c995acccc1e3cba379786892a5b4d4e5
|
|
||||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||||
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
|
||||||
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
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%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
|
Get%20started%20on%20Cloud/singular: ed926f526266ad2063283c58e6f4284a
|
||||||
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
|
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
|
||||||
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||||
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
|
|
||||||
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
||||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||||
@@ -248,41 +177,23 @@ checksums:
|
|||||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||||
Import%20from%20Trello/singular: 9cce591a63763174952ffc11ae829f0c
|
|
||||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
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
|
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/singular: 1f06ad6763244d5e9927853e65cc6274
|
|
||||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||||
Intelligent%20search/singular: da688e7cfb2775f719a12df6dd606f6e
|
|
||||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||||
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
|
||||||
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
|
|
||||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||||
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
|
|
||||||
Invite%20link%20copied%20to%20clipboard/singular: 6fc055a0ea0ed1aa58c5e0c502efe17f
|
|
||||||
Invite%20links/singular: 36ea61a3193245fa1b57d3392f342aaa
|
|
||||||
Invite%20links%20require%20a%20Team%20or%20Pro%20subscription.%20Please%20upgrade%20your%20workspace./singular: 18db646fc55f99789572c8df0539ee69
|
|
||||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||||
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
|
||||||
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
|
|
||||||
Join%20workspace%20%7C%20kan.bn/singular: 97855216a0e00214b2dcf917e93164f2
|
|
||||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
||||||
Kan/singular: dd1232a5a28bf47e939d3b8a199bc229
|
|
||||||
Kan%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: d13f4467d3dc0a0786c3843a9b50785d
|
|
||||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||||
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
|
||||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||||
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
|
||||||
Launch%20offer%3A%20Get%20unlimited%20members%20with%20Pro/singular: 8c1be80f5b2fcc112922282afbe30287
|
|
||||||
Launch%20offer%3A%20unlimited%20seats%20for%20just%20%2429%2Fmonth%20with%20Pro/singular: 235d1cda21811c296afdc0dcc438cc24
|
|
||||||
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
||||||
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
||||||
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
||||||
@@ -295,74 +206,50 @@ checksums:
|
|||||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||||
Make%20template/singular: 7a1c63b75cea690b3fda6c3f203e31ff
|
|
||||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
|
||||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
|
||||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||||
monthly%20billing/singular: e228107df80015377112d41f4f155cb3
|
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%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
|
|
||||||
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
||||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||||
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
|
||||||
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
|
|
||||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||||
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
|
||||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||||
New%20label/singular: 890040da52a88c16c249a4ac78980f81
|
New%20label/singular: 890040da52a88c16c249a4ac78980f81
|
||||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||||
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
|
||||||
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
|
||||||
New%20template/singular: 10dca64383235d1596a010e559b2e35a
|
|
||||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||||
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
||||||
No%20%7B0%7D/singular: a7a95e1dfdb4396da9e2be0e27f42323
|
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
|
||||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
|
||||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
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%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
|
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||||
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
|
||||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
|
||||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||||
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
|
||||||
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
|
|
||||||
Password%20is%20required%20to%20sign%20up./singular: 3fa1abbf2e6ac8ac4d18c215191bdbcc
|
|
||||||
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
|
|
||||||
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
|
|
||||||
Paused/singular: edb1f7b7219e1c9b7aa67159090d6991
|
|
||||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||||
Planning/singular: 353f58c75248275fe091740607501610
|
Planning/singular: 353f58c75248275fe091740607501610
|
||||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
|
||||||
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
|
|
||||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
|
||||||
Price%20per%20user%2Fmonth/singular: ae4b93ad0f9fad454b40f82010372fc2
|
|
||||||
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
||||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||||
Private/singular: d740d0660e68c61db1853489356030ae
|
Private/singular: d740d0660e68c61db1853489356030ae
|
||||||
Pro%20Plan/singular: 76a09fd843b496610f8cf1dced417b62
|
|
||||||
Pro%20Plan%20%E2%88%9E/singular: a898be9e4610706b9b8146a209e8ba16
|
|
||||||
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
|
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
|
||||||
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
|
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
|
||||||
Public/singular: c834f11dab0881632aded3f01d4f8fa9
|
Public/singular: c834f11dab0881632aded3f01d4f8fa9
|
||||||
@@ -376,39 +263,28 @@ checksums:
|
|||||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
removed%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1e7ebc1866136352bdc3cd90afc083f0
|
||||||
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
|
||||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
|
||||||
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
|
||||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
|
||||||
Review/singular: 299f75db25382980b2895622d7712927
|
Review/singular: 299f75db25382980b2895622d7712927
|
||||||
|
Revoke/singular: be57685a85b6dfeaeb6eab4e9560b520
|
||||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||||
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
|
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
|
||||||
Search%20boards%20and%20cards.../singular: 137017b2b3e885c4894ca8c899af5bc2
|
|
||||||
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
|
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
|
||||||
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
|
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
|
||||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||||
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
Send/singular: 76a8497d7b546628b03bb81d5c1ce995
|
||||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
|
||||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||||
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
|
Settings%20%7C%20%7B0%7D/singular: b8fc73080bc9c8f4f1403b2a69bd1ac5
|
||||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
|
||||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
|
||||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
|
||||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
|
||||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||||
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
|
|
||||||
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
|
|
||||||
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
|
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
|
||||||
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
|
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
|
||||||
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
|
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
|
||||||
@@ -418,68 +294,45 @@ checksums:
|
|||||||
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
|
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
|
||||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||||
Subscription%20Required/singular: 05f2b4abfc36def17756a6969983cf86
|
|
||||||
Success/singular: c43827becada6750f7a25890905f38b9
|
Success/singular: c43827becada6750f7a25890905f38b9
|
||||||
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
|
|
||||||
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
|
|
||||||
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
|
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
|
||||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
|
||||||
Teams/singular: b63448c05270497973ac4407047dae02
|
Teams/singular: b63448c05270497973ac4407047dae02
|
||||||
Template/singular: 87c2704ff2033e3c76bc97a45ae57d5c
|
|
||||||
Template%20created/singular: 2c7a515a5d8a9afb26de6365cdaa0c92
|
|
||||||
Template%20created%20successfully/singular: 2750600da57c5cd3c3c072810c8c9604
|
|
||||||
Template%20name%20cannot%20exceed%20100%20characters/singular: 14527c9246e558a085ceee2e8d4e6560
|
|
||||||
Template%20name%20is%20required/singular: 34d674d59c414c2051ef5bc0a62e3c00
|
|
||||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
|
||||||
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%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%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
|
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||||
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
|
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
|
||||||
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
|
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
|
||||||
This%20API%20key%20will%20only%20be%20shown%20once.%20Please%20save%20it%20in%20a%20secure%20location./singular: 7df18d2978d317375f780822f8321c4d
|
|
||||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
|
||||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||||
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
|
||||||
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%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
|
||||||
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
|
||||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||||
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
||||||
Trello/singular: b5131f6488b5a439d58db3e22d6de45b
|
|
||||||
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
|
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
|
||||||
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
|
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
|
||||||
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
|
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
|
||||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
|
||||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
|
||||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||||
Unable%20to%20create%20template/singular: 7495f1434c9188849bffd015811d5337
|
|
||||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
|
||||||
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
|
||||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||||
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
||||||
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
|
|
||||||
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
|
|
||||||
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
||||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||||
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
unassigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20from%20the%20card/singular: 105d4eeb32044126b8dde243bad11d56
|
||||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||||
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
|
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
|
||||||
@@ -487,32 +340,23 @@ checksums:
|
|||||||
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
|
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
|
||||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
|
||||||
Unlimited%20workspaces/singular: b504d01997bf908af1d486d9942fed36
|
|
||||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
|
||||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||||
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
updated%20the%20title%20to%20%3C0%3E%7BtoTitle%7D%3C%2F0%3E/singular: 70cdf681ec2b6ef8e14397bc1f93f945
|
||||||
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
|
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
|
||||||
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
|
||||||
Upgrade%20to%20Pro%20(%2429%2Fmonth)/singular: 9a91ea70f5eb55aac96640bf0fd7feff
|
|
||||||
Upgrade%20to%20Team%20Plan/singular: 40e2ef151266937f8bbe63b3d361f645
|
|
||||||
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
|
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
|
||||||
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
|
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
|
||||||
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
|
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
|
||||||
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
|
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
|
||||||
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
|
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||||
User/singular: 61073457a5c3901084b557d065f876be
|
User/singular: 61073457a5c3901084b557d065f876be
|
||||||
User%20is%20already%20a%20member%20of%20this%20workspace/singular: bc246386f0975f750f2133222bb4735e
|
|
||||||
Video/singular: 8050c90e4289b105a0780f0fdda6ff66
|
Video/singular: 8050c90e4289b105a0780f0fdda6ff66
|
||||||
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
|
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
|
||||||
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
|
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
|
||||||
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
|
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
|
||||||
View%20only/singular: 21f5f7ba3b49050c4f406113479d6a7a
|
|
||||||
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
|
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
|
||||||
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
|
|
||||||
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
|
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
|
||||||
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
|
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
|
||||||
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
|
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
|
||||||
@@ -520,10 +364,8 @@ checksums:
|
|||||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||||
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
|
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
|
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
|
||||||
White%20label/singular: 2570386f73a010b07219e7658aaf9d9b
|
|
||||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||||
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
|
||||||
Workspace%20deleted/singular: bc5c267ce3f04de94ff1379cf2ca48a3
|
Workspace%20deleted/singular: bc5c267ce3f04de94ff1379cf2ca48a3
|
||||||
Workspace%20description/singular: 7159d620b7ef5c5afef734989a5be08b
|
Workspace%20description/singular: 7159d620b7ef5c5afef734989a5be08b
|
||||||
Workspace%20description%20cannot%20exceed%20280%20characters/singular: d0e83b9a0304d7c36cf8c85d98783ce2
|
Workspace%20description%20cannot%20exceed%20280%20characters/singular: d0e83b9a0304d7c36cf8c85d98783ce2
|
||||||
@@ -532,28 +374,21 @@ checksums:
|
|||||||
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
||||||
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
||||||
Workspace%20name%20cannot%20exceed%2024%20characters/singular: c8b8f79fb83abf8329cc34c1f51fcfc9
|
Workspace%20name%20cannot%20exceed%2024%20characters/singular: c8b8f79fb83abf8329cc34c1f51fcfc9
|
||||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
|
||||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||||
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
|
||||||
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
|
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
|
||||||
Yearly/singular: 87f43e016c19cb25860f456549a2f431
|
Yearly/singular: 87f43e016c19cb25860f456549a2f431
|
||||||
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
|
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
|
||||||
You%20are%20about%20to%20change%20your%20password./singular: df4783d593505766d78c5bc08d3d48ac
|
|
||||||
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%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%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%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%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
|
||||||
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
|
||||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
|
||||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||||
Your%20password%20has%20been%20changed./singular: 8f730f68d52b7efa727aa0784282ebcb
|
|
||||||
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
||||||
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
||||||
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
|
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf";
|
import type { LinguiConfig } from "@lingui/conf";
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
const config: LinguiConfig = {
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru"],
|
locales: ["en", "fr", "de", "es", "it", "nl"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ const config = {
|
|||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname:
|
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||||
env("S3_FORCE_PATH_STYLE") === "true"
|
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
|
||||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
@@ -52,27 +49,6 @@ const config = {
|
|||||||
// instrumentationHook: true,
|
// instrumentationHook: true,
|
||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
},
|
},
|
||||||
|
|
||||||
async rewrites() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/settings",
|
|
||||||
destination: "/settings/account",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only allow external images when OIDC is configured (for OIDC provider avatars)
|
|
||||||
if (
|
|
||||||
env("OIDC_CLIENT_ID") &&
|
|
||||||
env("OIDC_CLIENT_SECRET") &&
|
|
||||||
env("OIDC_DISCOVERY_URL")
|
|
||||||
) {
|
|
||||||
config.images?.remotePatterns?.push({
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "**",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev",
|
"dev": "pnpm with-env next dev | pino-pretty",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
@@ -33,7 +33,6 @@
|
|||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tiptap/extension-link": "^2.22.2",
|
"@tiptap/extension-link": "^2.22.2",
|
||||||
"@tiptap/extension-mention": "^3.0.9",
|
|
||||||
"@tiptap/extension-placeholder": "^2.14.0",
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
"@tiptap/pm": "^2.14.0",
|
"@tiptap/pm": "^2.14.0",
|
||||||
"@tiptap/react": "^2.14.0",
|
"@tiptap/react": "^2.14.0",
|
||||||
@@ -49,7 +48,6 @@
|
|||||||
"next": "^15.3.4",
|
"next": "^15.3.4",
|
||||||
"next-logger": "^5.0.1",
|
"next-logger": "^5.0.1",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"nextjs-cors": "^2.2.0",
|
"nextjs-cors": "^2.2.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"posthog-js": "^1.254.0",
|
"posthog-js": "^1.254.0",
|
||||||
@@ -59,7 +57,6 @@
|
|||||||
"react-dom": "catalog:react18",
|
"react-dom": "catalog:react18",
|
||||||
"react-hook-form": "^7.51.1",
|
"react-hook-form": "^7.51.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-image-crop": "^11.0.10",
|
|
||||||
"react-lottie-player": "^1.5.5",
|
"react-lottie-player": "^1.5.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 433 KiB |
@@ -1,11 +1,10 @@
|
|||||||
import type { SocialProvider } from "better-auth/social-providers";
|
import type { SocialProvider } from "better-auth/social-providers";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
FaApple,
|
FaApple,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
FaGoogle,
|
FaGoogle,
|
||||||
FaLinkedin,
|
FaLinkedin,
|
||||||
FaMicrosoft,
|
FaMicrosoft,
|
||||||
FaOpenid,
|
|
||||||
FaReddit,
|
FaReddit,
|
||||||
FaSpotify,
|
FaSpotify,
|
||||||
FaTiktok,
|
FaTiktok,
|
||||||
@@ -35,8 +33,6 @@ import Button from "~/components/Button";
|
|||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
|
|
||||||
type AuthProvider = SocialProvider | "oidc";
|
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
name?: string;
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -145,39 +141,16 @@ const availableSocialProviders = {
|
|||||||
name: "Zoom",
|
name: "Zoom",
|
||||||
icon: SiZoom,
|
icon: SiZoom,
|
||||||
},
|
},
|
||||||
oidc: {
|
|
||||||
id: "oidc",
|
|
||||||
name: "OIDC",
|
|
||||||
icon: FaOpenid,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
|
||||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||||
useState<null | AuthProvider>(null);
|
useState<null | SocialProvider>(null);
|
||||||
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
|
const isCredentialsEnabled =
|
||||||
const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false);
|
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const oidcProviderName = "OIDC";
|
|
||||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const redirect = useSearchParams().get("next");
|
|
||||||
const callbackURL = redirect ?? "/boards";
|
|
||||||
|
|
||||||
// Safely get environment variables on client side to avoid hydration mismatch
|
|
||||||
useEffect(() => {
|
|
||||||
const credentialsAllowed =
|
|
||||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
|
||||||
const emailSendingEnabled =
|
|
||||||
env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true";
|
|
||||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
|
||||||
setIsCloudEnv(isCloudEnv);
|
|
||||||
setIsEmailSendingEnabled(emailSendingEnabled);
|
|
||||||
setIsCredentialsEnabled(credentialsAllowed);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -195,7 +168,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
|
|
||||||
const handleLoginWithEmail = async (
|
const handleLoginWithEmail = async (
|
||||||
email: string,
|
email: string,
|
||||||
password?: string | null,
|
password?: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
) => {
|
) => {
|
||||||
setIsLoginWithEmailPending(true);
|
setIsLoginWithEmailPending(true);
|
||||||
@@ -207,7 +180,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackURL,
|
callbackURL: "/boards",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
@@ -224,7 +197,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
{
|
{
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackURL,
|
callbackURL: "/boards",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
@@ -238,51 +211,28 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only allow magic link if email sending is enabled and not in sign up mode
|
await authClient.signIn.magicLink(
|
||||||
if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) {
|
{
|
||||||
await authClient.signIn.magicLink(
|
email,
|
||||||
{
|
callbackURL: "/boards",
|
||||||
email,
|
},
|
||||||
callbackURL,
|
{
|
||||||
},
|
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||||
{
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
},
|
||||||
onError: ({ error }) => setLoginError(error.message),
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Provide a clear error feedback when password omitted but magic link unavailable
|
|
||||||
setLoginError(
|
|
||||||
isSignUp
|
|
||||||
? t`Password is required to sign up.`
|
|
||||||
: t`Password is required to login.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoginWithEmailPending(false);
|
setIsLoginWithEmailPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginWithProvider = async (provider: AuthProvider) => {
|
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||||
setIsLoginWithProviderPending(provider);
|
setIsLoginWithProviderPending(provider);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
let error;
|
provider,
|
||||||
if (provider === "oidc") {
|
callbackURL: "/boards",
|
||||||
// Use oauth2 signin for OIDC provider
|
});
|
||||||
const result = await authClient.signIn.oauth2({
|
|
||||||
providerId: "oidc",
|
|
||||||
callbackURL,
|
|
||||||
});
|
|
||||||
error = result.error;
|
|
||||||
} else {
|
|
||||||
// Use social signin for traditional social providers
|
|
||||||
const result = await authClient.signIn.social({
|
|
||||||
provider,
|
|
||||||
callbackURL,
|
|
||||||
});
|
|
||||||
error = result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoginWithProviderPending(null);
|
setIsLoginWithProviderPending(null);
|
||||||
|
|
||||||
@@ -294,43 +244,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
// Treat empty password string as undefined to trigger magic link path
|
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||||
const sanitizedPassword = values.password?.trim()
|
|
||||||
? values.password
|
|
||||||
: undefined;
|
|
||||||
await handleLoginWithEmail(values.email, sanitizedPassword, values.name);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const password = watch("password");
|
const password = watch("password");
|
||||||
|
|
||||||
// Determine if we should operate in magic link mode for current form state (login only)
|
|
||||||
const isMagicLinkMode = useMemo(() => {
|
|
||||||
// Magic link only viable when email sending enabled AND not sign up.
|
|
||||||
if (!isEmailSendingEnabled || isSignUp) return false;
|
|
||||||
// If credentials disabled we always default to magic link.
|
|
||||||
if (!isCredentialsEnabled) return true;
|
|
||||||
// Credentials enabled: user chooses magic link by leaving password blank.
|
|
||||||
return !password;
|
|
||||||
}, [isEmailSendingEnabled, isSignUp, isCredentialsEnabled, password]);
|
|
||||||
|
|
||||||
// Auto-focus password field when an error indicates it's required
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCredentialsEnabled) return;
|
|
||||||
// Focus when: sign up and missing password; login error requiring password; validation error on password.
|
|
||||||
const pwdEmpty = (password ?? "").length === 0;
|
|
||||||
let needsPassword = false;
|
|
||||||
if (isSignUp && pwdEmpty) {
|
|
||||||
needsPassword = true;
|
|
||||||
} else if (loginError?.toLowerCase().includes("password")) {
|
|
||||||
needsPassword = true;
|
|
||||||
} else if (errors.password) {
|
|
||||||
needsPassword = true;
|
|
||||||
}
|
|
||||||
if (needsPassword && passwordRef.current) {
|
|
||||||
passwordRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [isSignUp, password, loginError, errors.password, isCredentialsEnabled]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{socialProviders?.length !== 0 && (
|
{socialProviders?.length !== 0 && (
|
||||||
@@ -341,17 +259,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||||
onClick={() => handleLoginWithProvider(key as AuthProvider)}
|
|
||||||
isLoading={isLoginWithProviderPending === key}
|
isLoading={isLoginWithProviderPending === key}
|
||||||
iconLeft={<provider.icon />}
|
iconLeft={<provider.icon />}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Trans>
|
<Trans>Continue with {provider.name}</Trans>
|
||||||
Continue with{" "}
|
|
||||||
{key === "oidc" ? oidcProviderName : provider.name}
|
|
||||||
</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -401,7 +315,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{errors.password.message ?? t`Please enter a valid password`}
|
{t`Please enter a valid password`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +332,9 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||||
|
? t`email`
|
||||||
|
: t`magic link`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import LoadingSpinner from "./LoadingSpinner";
|
|||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
size?: "xs" | "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
iconLeft?: React.ReactNode;
|
iconLeft?: React.ReactNode;
|
||||||
iconRight?: React.ReactNode;
|
iconRight?: React.ReactNode;
|
||||||
@@ -28,7 +28,6 @@ const Button = ({
|
|||||||
}: ButtonProps) => {
|
}: ButtonProps) => {
|
||||||
const classes = twMerge(
|
const classes = twMerge(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
|
||||||
size === "xs" && "text-xs px-2 py-1",
|
|
||||||
size === "sm" && "text-xs",
|
size === "sm" && "text-xs",
|
||||||
size === "lg" && "py-[0.65rem]",
|
size === "lg" && "py-[0.65rem]",
|
||||||
fullWidth && "w-full",
|
fullWidth && "w-full",
|
||||||
|
|||||||
@@ -55,67 +55,53 @@ export default function CheckboxDropdown({
|
|||||||
|
|
||||||
const renderMenuItems = (items: Item[], groupKey: string | null) => (
|
const renderMenuItems = (items: Item[], groupKey: string | null) => (
|
||||||
<>
|
<>
|
||||||
{items.length > 0 ? (
|
{items.map((item) => (
|
||||||
items.map((item) => (
|
<Menu.Item key={item.key}>
|
||||||
<Menu.Item key={item.key}>
|
<div
|
||||||
<div
|
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
}}
|
||||||
}}
|
>
|
||||||
|
<input
|
||||||
|
id={item.key}
|
||||||
|
name={item.key}
|
||||||
|
type="checkbox"
|
||||||
|
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onChange={() =>
|
||||||
|
handleSelect(groupKey, { key: item.key, value: item.value })
|
||||||
|
}
|
||||||
|
checked={item.selected}
|
||||||
|
/>
|
||||||
|
{item.leftIcon && (
|
||||||
|
<span className="ml-3 flex items-center">{item.leftIcon}</span>
|
||||||
|
)}
|
||||||
|
<label
|
||||||
|
htmlFor={item.key}
|
||||||
|
className="ml-3 text-[12px] text-dark-900"
|
||||||
>
|
>
|
||||||
<input
|
{item.value}
|
||||||
id={item.key}
|
</label>
|
||||||
name={item.key}
|
{handleEdit && (
|
||||||
type="checkbox"
|
<button
|
||||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
className="invisible ml-auto group-hover:visible"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => {
|
||||||
onChange={() =>
|
event.stopPropagation();
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value })
|
handleEdit(item.key);
|
||||||
}
|
}}
|
||||||
checked={item.selected}
|
|
||||||
/>
|
|
||||||
{item.leftIcon && (
|
|
||||||
<span className="ml-3 flex items-center">{item.leftIcon}</span>
|
|
||||||
)}
|
|
||||||
<label
|
|
||||||
htmlFor={item.key}
|
|
||||||
className="ml-3 text-[12px] text-dark-900"
|
|
||||||
>
|
>
|
||||||
{item.value}
|
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
||||||
</label>
|
</button>
|
||||||
{handleEdit && (
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="invisible ml-auto group-hover:visible"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
handleEdit(item.key);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Menu.Item>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
!handleCreate && (
|
|
||||||
<div className="flex items-center p-2 text-[12px] text-dark-900">
|
|
||||||
No items
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</Menu.Item>
|
||||||
)}
|
))}
|
||||||
{handleCreate && (
|
{handleCreate && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
|
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
onClick={(e) => {
|
onClick={() => handleCreate()}
|
||||||
e.preventDefault();
|
|
||||||
handleCreate();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<HiMiniPlus size={20} className="pr-1.5" />
|
<HiMiniPlus size={20} className="pr-1.5" />
|
||||||
{createNewItemLabel}
|
{createNewItemLabel}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
interface CircularProgressProps {
|
|
||||||
progress: number; // 0-100
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CircularProgress = ({
|
|
||||||
progress,
|
|
||||||
size = "md",
|
|
||||||
className,
|
|
||||||
}: CircularProgressProps) => {
|
|
||||||
const radius = 40;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const strokeDashoffset = circumference - (progress / 100) * circumference;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={twMerge("relative", className)}>
|
|
||||||
<svg
|
|
||||||
className={twMerge(
|
|
||||||
"-rotate-90 transform",
|
|
||||||
size === "sm" && "h-4 w-4",
|
|
||||||
size === "md" && "h-5 w-5",
|
|
||||||
size === "lg" && "h-8 w-8",
|
|
||||||
)}
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="14"
|
|
||||||
className="text-light-300 dark:text-dark-400"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="14"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={strokeDashoffset}
|
|
||||||
strokeLinecap="round"
|
|
||||||
className={twMerge(
|
|
||||||
"transition-all duration-300 ease-in-out",
|
|
||||||
progress === 100 ? "text-green-500" : "text-blue-500",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CircularProgress;
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
import {
|
|
||||||
Combobox,
|
|
||||||
ComboboxInput,
|
|
||||||
ComboboxOption,
|
|
||||||
ComboboxOptions,
|
|
||||||
Dialog,
|
|
||||||
DialogBackdrop,
|
|
||||||
DialogPanel,
|
|
||||||
} from "@headlessui/react";
|
|
||||||
import { t } from "@lingui/macro";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { HiDocumentText, HiFolder, HiMagnifyingGlass } from "react-icons/hi2";
|
|
||||||
|
|
||||||
import { useDebounce } from "~/hooks/useDebounce";
|
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
type SearchResult =
|
|
||||||
| {
|
|
||||||
publicId: string;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
slug: string;
|
|
||||||
updatedAt: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
type: "board";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
publicId: string;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
boardPublicId: string;
|
|
||||||
boardName: string;
|
|
||||||
listName: string;
|
|
||||||
updatedAt: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
type: "card";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CommandPallette({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Debounce to avoid too many reqs
|
|
||||||
const [debouncedQuery] = useDebounce(query, 300);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: searchResults,
|
|
||||||
isLoading,
|
|
||||||
isFetched,
|
|
||||||
isPlaceholderData,
|
|
||||||
} = api.workspace.search.useQuery(
|
|
||||||
{
|
|
||||||
workspacePublicId: workspace.publicId,
|
|
||||||
query: debouncedQuery,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear results when query is empty, otherwise show search results
|
|
||||||
const results =
|
|
||||||
debouncedQuery.trim().length === 0
|
|
||||||
? []
|
|
||||||
: ((searchResults ?? []) as SearchResult[]);
|
|
||||||
|
|
||||||
const hasSearched = Boolean(debouncedQuery.trim().length > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
className="relative z-50"
|
|
||||||
open={isOpen}
|
|
||||||
onClose={() => {
|
|
||||||
onClose();
|
|
||||||
setQuery("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogBackdrop
|
|
||||||
transition
|
|
||||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
|
||||||
<DialogPanel
|
|
||||||
transition
|
|
||||||
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
|
|
||||||
>
|
|
||||||
<Combobox>
|
|
||||||
<div className="grid grid-cols-1">
|
|
||||||
<ComboboxInput
|
|
||||||
autoFocus
|
|
||||||
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700"
|
|
||||||
placeholder={t`Search boards and cards...`}
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && results.length > 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// Find the active option or fallback to first option
|
|
||||||
const targetOption =
|
|
||||||
document.querySelector(
|
|
||||||
'[data-headlessui-state*="active"][role="option"]',
|
|
||||||
) ?? document.querySelector('[role="option"]');
|
|
||||||
|
|
||||||
if (targetOption) {
|
|
||||||
(targetOption as HTMLElement).click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<HiMagnifyingGlass
|
|
||||||
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{results.length > 0 && (
|
|
||||||
<ComboboxOptions
|
|
||||||
static
|
|
||||||
className={`max-h-72 scroll-py-2 overflow-y-auto py-2 ${
|
|
||||||
isPlaceholderData ? "opacity-75" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{results.map((result) => {
|
|
||||||
const url =
|
|
||||||
result.type === "board"
|
|
||||||
? `/boards/${result.publicId}`
|
|
||||||
: `/cards/${result.publicId}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComboboxOption
|
|
||||||
key={`${result.type}-${result.publicId}`}
|
|
||||||
value={result}
|
|
||||||
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
|
||||||
onClick={() => {
|
|
||||||
console.log("clicked", url);
|
|
||||||
void router.push(url);
|
|
||||||
onClose();
|
|
||||||
setQuery("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-0.5 flex-shrink-0">
|
|
||||||
{result.type === "board" ? (
|
|
||||||
<HiFolder className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
|
||||||
) : (
|
|
||||||
<HiDocumentText className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 text-left">
|
|
||||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
|
||||||
{result.title}
|
|
||||||
</div>
|
|
||||||
{result.type === "card" && (
|
|
||||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
|
||||||
{`${t`in`} ${result.boardName} → ${result.listName}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ComboboxOption>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ComboboxOptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasSearched &&
|
|
||||||
!isLoading &&
|
|
||||||
searchResults !== undefined &&
|
|
||||||
results.length === 0 && (
|
|
||||||
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
|
|
||||||
{t`No results found for "${debouncedQuery}".`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
|
||||||
</DialogPanel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
TbLayoutSidebarLeftCollapse,
|
TbLayoutSidebarLeftCollapse,
|
||||||
@@ -9,11 +10,7 @@ import {
|
|||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||||
import { useModal } from "~/providers/modal";
|
import FeedbackButton from "./FeedbackButton";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { WorkspaceProvider } from "~/providers/workspace";
|
|
||||||
import FeedbackModal from "./FeedbackModal";
|
|
||||||
import Modal from "./modal";
|
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -22,30 +19,12 @@ interface DashboardProps {
|
|||||||
hasRightPanel?: boolean;
|
hasRightPanel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDashboardLayout(
|
|
||||||
page: React.ReactElement,
|
|
||||||
rightPanel?: React.ReactNode,
|
|
||||||
hasRightPanel = false,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<WorkspaceProvider>
|
|
||||||
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
|
|
||||||
{page}
|
|
||||||
</Dashboard>
|
|
||||||
</WorkspaceProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard({
|
export default function Dashboard({
|
||||||
children,
|
children,
|
||||||
rightPanel,
|
rightPanel,
|
||||||
hasRightPanel = false,
|
hasRightPanel = false,
|
||||||
}: DashboardProps) {
|
}: DashboardProps) {
|
||||||
const { theme } = useTheme();
|
|
||||||
const { modalContentType } = useModal();
|
|
||||||
|
|
||||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||||
|
|
||||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||||
|
|
||||||
@@ -61,10 +40,6 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeSideNav = () => {
|
|
||||||
setIsSideNavOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRightPanel = () => {
|
const toggleRightPanel = () => {
|
||||||
setIsRightPanelOpen(!isRightPanelOpen);
|
setIsRightPanelOpen(!isRightPanelOpen);
|
||||||
if (!isRightPanelOpen) {
|
if (!isRightPanelOpen) {
|
||||||
@@ -90,93 +65,98 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDarkMode = theme === "dark";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
html {
|
html {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 320px;
|
|
||||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
|
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
|
<div className="h-screen flex-col items-center bg-light-100 dark:bg-dark-50 md:flex md:min-w-[800px]">
|
||||||
{/* Mobile Header */}
|
<div className="m-auto 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="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
|
<div className="my-auto flex w-full items-center justify-between">
|
||||||
<button
|
|
||||||
ref={sideNavButtonRef}
|
|
||||||
onClick={toggleSideNav}
|
|
||||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
|
||||||
>
|
|
||||||
{isSideNavOpen ? (
|
|
||||||
<TbLayoutSidebarLeftCollapse
|
|
||||||
size={20}
|
|
||||||
className="text-light-900 dark:text-dark-900"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TbLayoutSidebarLeftExpand
|
|
||||||
size={20}
|
|
||||||
className="text-light-900 dark:text-dark-900"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasRightPanel && (
|
|
||||||
<button
|
<button
|
||||||
ref={rightPanelButtonRef}
|
ref={sideNavButtonRef}
|
||||||
onClick={toggleRightPanel}
|
onClick={toggleSideNav}
|
||||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
className="text-neutral-900 dark:text-dark-1000 md:hidden"
|
||||||
>
|
>
|
||||||
{isRightPanelOpen ? (
|
{isSideNavOpen ? (
|
||||||
<TbLayoutSidebarRightCollapse
|
<TbLayoutSidebarLeftCollapse
|
||||||
size={20}
|
size={20}
|
||||||
className="text-light-900 dark:text-dark-900"
|
className="text-light-900 dark:text-dark-900"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TbLayoutSidebarRightExpand
|
<TbLayoutSidebarLeftExpand
|
||||||
size={20}
|
size={20}
|
||||||
className="text-light-900 dark:text-dark-900"
|
className="text-light-900 dark:text-dark-900"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
|
<div className="flex h-full w-full">
|
||||||
<div
|
<div
|
||||||
ref={sideNavRef}
|
ref={sideNavRef}
|
||||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
className={`fixed left-0 z-50 h-[calc(100vh-3rem)] transform transition-transform duration-300 ease-in-out md:relative md:h-[calc(100vh-4rem)] md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||||
>
|
>
|
||||||
<SideNavigation
|
<SideNavigation
|
||||||
user={{ email: session?.user.email, image: session?.user.image }}
|
user={{ email: session?.user.email, image: session?.user.image }}
|
||||||
isLoading={sessionLoading}
|
isLoading={sessionLoading}
|
||||||
onCloseSideNav={closeSideNav}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
|
<div className="relative flex w-full overflow-hidden">
|
||||||
<div className="relative flex h-full min-h-0 w-full overflow-hidden">
|
<div className="w-full overflow-hidden">{children}</div>
|
||||||
<div className="h-full w-full overflow-y-auto">{children}</div>
|
|
||||||
|
|
||||||
{/* Mobile Right Panel */}
|
{/* Mobile Right Panel */}
|
||||||
{hasRightPanel && rightPanel && (
|
{hasRightPanel && rightPanel && (
|
||||||
<div
|
<div
|
||||||
ref={rightPanelRef}
|
ref={rightPanelRef}
|
||||||
className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
|
className={`fixed right-0 top-12 z-50 h-[calc(100vh-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"
|
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="h-full">{rightPanel}</div>
|
<div className="h-full overflow-y-auto">{rightPanel}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop Right Panel */}
|
{/* Desktop Right Panel */}
|
||||||
{hasRightPanel && rightPanel && (
|
{hasRightPanel && rightPanel && (
|
||||||
<div className="hidden md:block">{rightPanel}</div>
|
<div className="hidden md:block">{rightPanel}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -12,23 +10,21 @@ export function DeleteLabelConfirmation({
|
|||||||
labelPublicId: string;
|
labelPublicId: string;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { closeModal, closeModals } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const deleteLabelMutation = api.label.delete.useMutation({
|
const deleteLabelMutation = api.label.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => refetch(),
|
||||||
refetch();
|
|
||||||
closeModals(2);
|
|
||||||
},
|
|
||||||
onError: () =>
|
onError: () =>
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Error deleting label`,
|
header: "Error deleting label",
|
||||||
message: t`Please try again later, or contact customer support.`,
|
message: "Please try again later, or contact customer support.",
|
||||||
icon: "error",
|
icon: "error",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDeleteLabel = () => {
|
const handleDeleteLabel = () => {
|
||||||
|
closeModal();
|
||||||
deleteLabelMutation.mutate({
|
deleteLabelMutation.mutate({
|
||||||
labelPublicId,
|
labelPublicId,
|
||||||
});
|
});
|
||||||
@@ -38,17 +34,17 @@ export function DeleteLabelConfirmation({
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex w-full flex-col justify-between pb-4">
|
<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">
|
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||||
{t`Are you sure you want to delete this label?`}
|
Are you sure you want to delete this label?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||||
{t`This action can't be undone.`}
|
{"This action can't be undone."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||||
<Button variant="secondary" onClick={() => closeModal()}>
|
<Button variant="secondary" onClick={() => closeModal()}>
|
||||||
{t`Cancel`}
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDeleteLabel}>{t`Delete`}</Button>
|
<Button onClick={handleDeleteLabel}>Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function Dropdown({
|
|||||||
<div>
|
<div>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 focus:outline-none dark:hover:bg-dark-200"
|
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
@@ -30,7 +30,7 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-30 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label}>
|
<Menu.Item key={item.label}>
|
||||||
|
|||||||
@@ -17,13 +17,7 @@ import {
|
|||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import {
|
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||||
forwardRef,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
HiH1,
|
HiH1,
|
||||||
HiH2,
|
HiH2,
|
||||||
@@ -39,9 +33,6 @@ import {
|
|||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import Mention from "@tiptap/extension-mention";
|
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -77,16 +68,6 @@ export interface RenderSuggestionsProps {
|
|||||||
command: (item: SlashCommandItem) => void;
|
command: (item: SlashCommandItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceMember = {
|
|
||||||
publicId: string;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
image: string | null;
|
|
||||||
} | null;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CommandsList = forwardRef<
|
const CommandsList = forwardRef<
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
{
|
{
|
||||||
@@ -200,125 +181,6 @@ const RenderSuggestions = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type MentionItem = { id: string; label: string; image: string | null };
|
|
||||||
|
|
||||||
const MentionList = forwardRef<
|
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
|
||||||
{
|
|
||||||
items: MentionItem[];
|
|
||||||
command: (item: MentionItem) => 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 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
|
||||||
{items.length > 0 ? items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => command(item)}
|
|
||||||
className={twMerge(
|
|
||||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
|
||||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
size="xs"
|
|
||||||
name={item.label}
|
|
||||||
imageUrl={
|
|
||||||
item.image ? getAvatarUrl(item.image) : undefined
|
|
||||||
}
|
|
||||||
email={item.label}
|
|
||||||
/>
|
|
||||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)) : (
|
|
||||||
<div className="flex items-center justify-start p-2">
|
|
||||||
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
MentionList.displayName = "MentionList";
|
|
||||||
|
|
||||||
const renderMentionSuggestions = () => {
|
|
||||||
let reactRenderer: ReactRenderer;
|
|
||||||
let popup: TippyInstance[];
|
|
||||||
|
|
||||||
return {
|
|
||||||
onStart: (props: any) => {
|
|
||||||
reactRenderer = new ReactRenderer(MentionList, {
|
|
||||||
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: any) {
|
|
||||||
reactRenderer.updateProps(props);
|
|
||||||
if (!props.clientRect) return;
|
|
||||||
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
|
||||||
},
|
|
||||||
onKeyDown(props: SuggestionKeyDownProps) {
|
|
||||||
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>({
|
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
||||||
name: "slash-commands",
|
name: "slash-commands",
|
||||||
addOptions() {
|
addOptions() {
|
||||||
@@ -422,13 +284,11 @@ export default function Editor({
|
|||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -439,7 +299,7 @@ export default function Editor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: readOnly
|
placeholder: readOnly
|
||||||
? ""
|
? ""
|
||||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
: t`Add description... (type '/' to open commands)`,
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
@@ -460,38 +320,6 @@ export default function Editor({
|
|||||||
char: "/",
|
char: "/",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Mention.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: 'mention',
|
|
||||||
},
|
|
||||||
suggestion: {
|
|
||||||
char: "@",
|
|
||||||
items: ({ query }: { query: string }) => {
|
|
||||||
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
|
|
||||||
id: member.publicId,
|
|
||||||
label: member?.user?.name ?? member.email,
|
|
||||||
image: member?.user?.image ?? null,
|
|
||||||
}));
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
|
||||||
},
|
|
||||||
command: ({ editor, range, props }: any) => {
|
|
||||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
|
||||||
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.insertContent(mentionHTML)
|
|
||||||
.focus()
|
|
||||||
.run();
|
|
||||||
},
|
|
||||||
render: renderMentionSuggestions,
|
|
||||||
},
|
|
||||||
renderText({ options, node }) {
|
|
||||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||||
@@ -515,23 +343,14 @@ export default function Editor({
|
|||||||
editable: !readOnly,
|
editable: !readOnly,
|
||||||
injectCSS: false,
|
injectCSS: false,
|
||||||
},
|
},
|
||||||
[], // creating the editor only once
|
[content],
|
||||||
);
|
);
|
||||||
|
|
||||||
// this will sync external content changes without re-creating the editor instance
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const currentHTML = editor.getHTML();
|
|
||||||
const safeContent = content ?? "";
|
|
||||||
if (safeContent !== currentHTML) {
|
|
||||||
editor.commands.setContent(safeContent, false);
|
|
||||||
}
|
|
||||||
}, [content, editor]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
.tiptap p.is-empty::before {
|
.tiptap p.is-empty::before {
|
||||||
|
color: #adb5bd;
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -540,19 +359,11 @@ export default function Editor({
|
|||||||
.tiptap p {
|
.tiptap p {
|
||||||
margin: 0 0 1rem 0 !important;
|
margin: 0 0 1rem 0 !important;
|
||||||
}
|
}
|
||||||
.tiptap .mention {
|
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 0.125rem 0.25rem;
|
|
||||||
color: rgb(59, 130, 246);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={editor}
|
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-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-light-950 [&_p]:dark:text-dark-950 [&_ul]:!text-xs"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
149
apps/web/src/components/FeedbackButton.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
import chatIconDark from "~/assets/chat-dark.json";
|
||||||
|
import chatIconLight from "~/assets/chat-light.json";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Input from "~/components/Input";
|
||||||
|
import LottieIcon from "~/components/LottieIcon";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { useTheme } from "~/providers/theme";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
interface NewFeedbackFormInput {
|
||||||
|
feedback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeedbackButton: React.FC = () => {
|
||||||
|
const outsideRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { activeTheme } = useTheme();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
const { handleSubmit, setValue, watch, reset } =
|
||||||
|
useForm<NewFeedbackFormInput>({
|
||||||
|
defaultValues: {
|
||||||
|
feedback: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFeedback = api.feedback.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
reset();
|
||||||
|
showPopup({
|
||||||
|
header: t`Feedback sent`,
|
||||||
|
message: t`Thank you for your feedback!`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: async () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to send feedback`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsHovered(true);
|
||||||
|
setIndex((index) => index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: NewFeedbackFormInput) => {
|
||||||
|
createFeedback.mutate({
|
||||||
|
feedback: values.feedback,
|
||||||
|
url: window.location.href,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={outsideRef} />
|
||||||
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
|
<div>
|
||||||
|
<Menu.Button
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
|
||||||
|
>
|
||||||
|
<LottieIcon
|
||||||
|
index={index}
|
||||||
|
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
|
||||||
|
isPlaying={isHovered}
|
||||||
|
/>
|
||||||
|
<span className="ml-1">{t`Feedback`}</span>
|
||||||
|
</Menu.Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
as="div"
|
||||||
|
static
|
||||||
|
className="absolute right-0 z-30 mt-2 w-[350px] origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
|
||||||
|
<Input
|
||||||
|
placeholder={t`Ideas to improve this page...`}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue("feedback", e.target.value);
|
||||||
|
}}
|
||||||
|
value={watch("feedback")}
|
||||||
|
contentEditable
|
||||||
|
className="max-h-[300px] min-h-[100px]"
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter" && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
await handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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">
|
||||||
|
{t`Need help?`}{" "}
|
||||||
|
<Link
|
||||||
|
href="mailto:support@kan.bn"
|
||||||
|
className="text-blue-600 underline dark:text-blue-300"
|
||||||
|
>
|
||||||
|
{t`Contact us`}
|
||||||
|
</Link>
|
||||||
|
{t`, or see our`}{" "}
|
||||||
|
<Link
|
||||||
|
href="https://docs.kan.bn"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 underline dark:text-blue-300"
|
||||||
|
>
|
||||||
|
{t`docs`}
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="justify-end">
|
||||||
|
<Button size="sm" isLoading={createFeedback.isPending}>
|
||||||
|
{t`Send`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeedbackButton;
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { HiXMark } from "react-icons/hi2";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Input from "~/components/Input";
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { usePopup } from "~/providers/popup";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
interface NewFeedbackFormInput {
|
|
||||||
feedback: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FeedbackModal() {
|
|
||||||
const { closeModal } = useModal();
|
|
||||||
const { showPopup } = usePopup();
|
|
||||||
|
|
||||||
const { handleSubmit, setValue, watch, reset } =
|
|
||||||
useForm<NewFeedbackFormInput>({
|
|
||||||
defaultValues: {
|
|
||||||
feedback: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createFeedback = api.feedback.create.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
reset();
|
|
||||||
closeModal();
|
|
||||||
showPopup({
|
|
||||||
header: t`Feedback sent`,
|
|
||||||
message: t`Thank you for your feedback!`,
|
|
||||||
icon: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: async () => {
|
|
||||||
showPopup({
|
|
||||||
header: t`Unable to send feedback`,
|
|
||||||
message: t`Please try again later, or contact customer support.`,
|
|
||||||
icon: "error",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (values: NewFeedbackFormInput) => {
|
|
||||||
createFeedback.mutate({
|
|
||||||
feedback: values.feedback,
|
|
||||||
url: window.location.href,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<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">
|
|
||||||
{t`Feedback`}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
closeModal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="feedback"
|
|
||||||
placeholder={t`Ideas to improve this page...`}
|
|
||||||
onChange={(e) => setValue("feedback", e.target.value)}
|
|
||||||
contentEditable
|
|
||||||
value={watch("feedback")}
|
|
||||||
className="min-h-[120px]"
|
|
||||||
onKeyDown={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === "Enter" && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
|
||||||
<div className="text-xs text-neutral-600 dark:text-dark-800">
|
|
||||||
<p>
|
|
||||||
{t`Need help?`}{" "}
|
|
||||||
<Link
|
|
||||||
href="mailto:support@kan.bn"
|
|
||||||
className="text-blue-600 underline dark:text-blue-300"
|
|
||||||
>
|
|
||||||
{t`Contact us`}
|
|
||||||
</Link>
|
|
||||||
{t`, or see our`}{" "}
|
|
||||||
<Link
|
|
||||||
href="https://docs.kan.bn"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-blue-600 underline dark:text-blue-300"
|
|
||||||
>
|
|
||||||
{t`docs`}
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button type="submit" isLoading={createFeedback.isPending}>
|
|
||||||
{t`Send feedback`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ export function LabelForm({
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { closeModal, entityId, openModal, setModalState } = useModal();
|
const { closeModal, entityId, openModal } = useModal();
|
||||||
|
|
||||||
const label = api.label.byPublicId.useQuery(
|
const label = api.label.byPublicId.useQuery(
|
||||||
{
|
{
|
||||||
@@ -57,23 +57,18 @@ export function LabelForm({
|
|||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
|
|
||||||
const createLabel = api.label.create.useMutation({
|
const createLabel = api.label.create.useMutation({
|
||||||
onSuccess: (newLabel) => {
|
onSuccess: () => {
|
||||||
const currentColourIndex = colours.findIndex(
|
const currentColourIndex = colours.findIndex(
|
||||||
(c) => c.code === watch("colour").code,
|
(c) => c.code === watch("colour").code,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
refetch();
|
refetch();
|
||||||
setModalState("NEW_LABEL_CREATED", newLabel.publicId);
|
if (!isCreateAnotherEnabled) closeModal();
|
||||||
if (!isCreateAnotherEnabled) {
|
reset({
|
||||||
closeModal();
|
name: "",
|
||||||
} else {
|
colour: colours[(currentColourIndex + 1) % colours.length],
|
||||||
const newFormState = {
|
isCreateAnotherEnabled,
|
||||||
name: "",
|
});
|
||||||
colour: colours[(currentColourIndex + 1) % colours.length],
|
|
||||||
isCreateAnotherEnabled,
|
|
||||||
};
|
|
||||||
reset(newFormState);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
@@ -84,6 +79,10 @@ export function LabelForm({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetch();
|
refetch();
|
||||||
closeModal();
|
closeModal();
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
colour: colours[0],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,7 +220,6 @@ export function LabelForm({
|
|||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => openModal("DELETE_LABEL", entityId)}
|
onClick={() => openModal("DELETE_LABEL", entityId)}
|
||||||
>
|
>
|
||||||
@@ -231,9 +229,6 @@ export function LabelForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||||
disabled={
|
|
||||||
!watch("name")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isEdit ? t`Update label` : t`Create label`}
|
{isEdit ? t`Update label` : t`Create label`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function LanguageSelector() {
|
|||||||
id="language-select"
|
id="language-select"
|
||||||
value={locale}
|
value={locale}
|
||||||
onChange={(e) => setLocale(e.target.value as any)}
|
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 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
|
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) => (
|
{availableLocales.map((loc) => (
|
||||||
<option key={loc} value={loc}>
|
<option key={loc} value={loc}>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import dynamic from "next/dynamic";
|
|||||||
|
|
||||||
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
|
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
|
||||||
|
|
||||||
interface IconProps {
|
type IconProps = {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
index: number;
|
index: number;
|
||||||
json: object;
|
json: object;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||||
return (
|
return (
|
||||||
@@ -15,7 +15,7 @@ const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
|||||||
animationData={json}
|
animationData={json}
|
||||||
play={isPlaying}
|
play={isPlaying}
|
||||||
loop={false}
|
loop={false}
|
||||||
style={{ width: 18, height: 18, fill: "white" }}
|
style={{ width: 20, height: 20, fill: "white" }}
|
||||||
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,151 +1,37 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { useEffect } from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import { HiXMark } from "react-icons/hi2";
|
||||||
HiBolt,
|
|
||||||
HiCheck,
|
|
||||||
HiCheckBadge,
|
|
||||||
HiInformationCircle,
|
|
||||||
HiXMark,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import Toggle from "~/components/Toggle";
|
|
||||||
import { useDebounce } from "~/hooks/useDebounce";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
|
||||||
|
|
||||||
const schema = z.object({
|
interface FormValues {
|
||||||
name: z.string().min(1, { message: t`Workspace name is required` }),
|
name: string;
|
||||||
slug: z
|
}
|
||||||
.string()
|
|
||||||
.min(3, {
|
|
||||||
message: t`URL must be at least 3 characters long`,
|
|
||||||
})
|
|
||||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.or(z.literal("")),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
export function NewWorkspaceForm() {
|
export function NewWorkspaceForm() {
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { switchWorkspace, availableWorkspaces } = useWorkspace();
|
const { switchWorkspace } = useWorkspace();
|
||||||
const {
|
const { register, handleSubmit } = useForm<FormValues>();
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
watch,
|
|
||||||
trigger,
|
|
||||||
clearErrors,
|
|
||||||
setValue,
|
|
||||||
} = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
},
|
|
||||||
mode: "onSubmit",
|
|
||||||
});
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
|
||||||
|
|
||||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
|
||||||
|
|
||||||
const slug = watch("slug");
|
|
||||||
const [debouncedSlug] = useDebounce(slug, 500);
|
|
||||||
const isTyping = slug !== debouncedSlug;
|
|
||||||
|
|
||||||
// Pro toggle state management
|
|
||||||
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
|
|
||||||
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
|
|
||||||
|
|
||||||
// Validate slug only after debounce
|
|
||||||
useEffect(() => {
|
|
||||||
if (isTyping) {
|
|
||||||
// Clear errors while typing
|
|
||||||
clearErrors("slug");
|
|
||||||
} else if (debouncedSlug) {
|
|
||||||
// Validate after debounce
|
|
||||||
void trigger("slug");
|
|
||||||
}
|
|
||||||
}, [isTyping, debouncedSlug, trigger, clearErrors]);
|
|
||||||
|
|
||||||
const checkWorkspaceSlugAvailability =
|
|
||||||
api.workspace.checkSlugAvailability.useQuery(
|
|
||||||
{
|
|
||||||
workspaceSlug: debouncedSlug ?? "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!debouncedSlug && debouncedSlug.length >= 3 && !errors.slug,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
|
||||||
|
|
||||||
const createWorkspace = api.workspace.create.useMutation({
|
const createWorkspace = api.workspace.create.useMutation({
|
||||||
onSuccess: async (values, variables) => {
|
onSuccess: (values) => {
|
||||||
if (values.publicId && values.name) {
|
if (values.publicId && values.name) {
|
||||||
void utils.workspace.all.invalidate();
|
utils.workspace.all.invalidate();
|
||||||
switchWorkspace({
|
switchWorkspace({
|
||||||
publicId: values.publicId,
|
publicId: values.publicId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
slug: values.slug,
|
slug: values.slug,
|
||||||
plan: values.plan,
|
plan: values.plan,
|
||||||
role: "admin",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
|
||||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"/api/stripe/create_checkout_session",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
slug: slug || undefined,
|
|
||||||
workspacePublicId: values.publicId,
|
|
||||||
cancelUrl: "/settings/workspace?upgrade=pro",
|
|
||||||
successUrl: "/boards",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const url = (data as { url: string }).url;
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
window.location.href = url;
|
|
||||||
return; // Don't close modal if redirecting to checkout
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating checkout session:", error);
|
|
||||||
showPopup({
|
|
||||||
header: t`Error upgrading to Pro`,
|
|
||||||
message: t`Workspace created successfully. You can upgrade later in settings.`,
|
|
||||||
icon: "warning",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -164,74 +50,12 @@ export function NewWorkspaceForm() {
|
|||||||
if (nameElement) nameElement.focus();
|
if (nameElement) nameElement.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
|
|
||||||
|
|
||||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
isCloudEnv &&
|
|
||||||
!checkWorkspaceSlugAvailability.isPending &&
|
|
||||||
isValidSlug
|
|
||||||
) {
|
|
||||||
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
|
|
||||||
setShouldShowBenefits(isAvailable);
|
|
||||||
|
|
||||||
// Automatically enable Pro toggle when slug is available
|
|
||||||
if (isAvailable) {
|
|
||||||
setIsProToggleEnabled(true);
|
|
||||||
setLastAvailableSlug(slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isValidSlug,
|
|
||||||
isWorkspaceSlugAvailable?.isAvailable,
|
|
||||||
checkWorkspaceSlugAvailability.isPending,
|
|
||||||
slug,
|
|
||||||
isCloudEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Reset benefits when slug becomes invalid
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isValidSlug) {
|
|
||||||
setShouldShowBenefits(false);
|
|
||||||
}
|
|
||||||
}, [isValidSlug]);
|
|
||||||
|
|
||||||
// Handle Pro toggle changes
|
|
||||||
const handleProToggleChange = () => {
|
|
||||||
if (isProToggleEnabled) {
|
|
||||||
// Turning off: clear the slug and disable Pro
|
|
||||||
setValue("slug", "");
|
|
||||||
setIsProToggleEnabled(false);
|
|
||||||
} else {
|
|
||||||
// Turning on: restore the last available slug if we have one
|
|
||||||
if (lastAvailableSlug) {
|
|
||||||
setValue("slug", lastAvailableSlug);
|
|
||||||
}
|
|
||||||
setIsProToggleEnabled(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showProBenefits = isProToggleEnabled;
|
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
// Don't submit if slug is provided but not available
|
|
||||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createWorkspace.mutate({
|
createWorkspace.mutate({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSlugAvailable =
|
|
||||||
isValidSlug &&
|
|
||||||
isWorkspaceSlugAvailable?.isAvailable &&
|
|
||||||
!isWorkspaceSlugAvailable?.isReserved;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="px-5 pt-5">
|
<div className="px-5 pt-5">
|
||||||
@@ -241,10 +65,7 @@ export function NewWorkspaceForm() {
|
|||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={twMerge(
|
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||||
"rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300",
|
|
||||||
!hasAvailableWorkspaces && "invisible",
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -258,7 +79,6 @@ export function NewWorkspaceForm() {
|
|||||||
id="workspace-name"
|
id="workspace-name"
|
||||||
placeholder={t`Workspace name`}
|
placeholder={t`Workspace name`}
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
errorMessage={errors.name?.message}
|
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -266,121 +86,10 @@ export function NewWorkspaceForm() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Input
|
|
||||||
id="workspace-slug"
|
|
||||||
placeholder={t`workspace-url`}
|
|
||||||
{...register("slug")}
|
|
||||||
className={`${
|
|
||||||
isSlugAvailable
|
|
||||||
? "focus:ring-green-500 dark:focus:ring-green-500"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
errorMessage={
|
|
||||||
errors.slug?.message ??
|
|
||||||
(isWorkspaceSlugAvailable?.isAvailable === false &&
|
|
||||||
isWorkspaceSlugAvailable?.isReserved === false
|
|
||||||
? t`This workspace URL has already been taken`
|
|
||||||
: isWorkspaceSlugAvailable?.isReserved
|
|
||||||
? t`This workspace URL is reserved`
|
|
||||||
: undefined)
|
|
||||||
}
|
|
||||||
prefix={
|
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
|
||||||
? "kan.bn/"
|
|
||||||
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
|
||||||
}
|
|
||||||
iconRight={
|
|
||||||
slug && slug.length >= 3 && !errors.slug ? (
|
|
||||||
isWorkspaceSlugAvailable?.isAvailable ? (
|
|
||||||
<HiCheck className="h-4 w-4 text-green-500" />
|
|
||||||
) : checkWorkspaceSlugAvailability.isPending || isTyping ? (
|
|
||||||
<LoadingSpinner />
|
|
||||||
) : null
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
onKeyDown={async (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{slug && slug.length >= 3 && shouldShowBenefits && (
|
|
||||||
<div className="mt-2 flex items-center gap-1">
|
|
||||||
<HiInformationCircle className="h-4 w-4 text-dark-900" />
|
|
||||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
|
||||||
{t`Custom URLs require upgrading to a Pro plan`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showProBenefits && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Unlimited members`}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
|
||||||
{t`Launch offer`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
|
||||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Custom workspace URL`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Board analytics`}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
|
||||||
{t`Coming soon`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
className={twMerge(
|
|
||||||
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
|
|
||||||
!showProBenefits && "mt-12",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Pro Toggle - only show in cloud environment */}
|
|
||||||
{isCloudEnv && (
|
|
||||||
<Toggle
|
|
||||||
isChecked={isProToggleEnabled}
|
|
||||||
onChange={handleProToggleChange}
|
|
||||||
label={t`Upgrade to Pro ($29/month)`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button type="submit" isLoading={createWorkspace.isPending}>
|
||||||
type="submit"
|
|
||||||
isLoading={createWorkspace.isPending}
|
|
||||||
disabled={
|
|
||||||
createWorkspace.isPending ||
|
|
||||||
(!!slug &&
|
|
||||||
(checkWorkspaceSlugAvailability.isPending ||
|
|
||||||
isWorkspaceSlugAvailable?.isAvailable === false ||
|
|
||||||
isTyping))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t`Create workspace`}
|
{t`Create workspace`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ export const PageHead = ({ title }: { title: string }) => {
|
|||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
|
||||||
/>
|
|
||||||
</Head>
|
</Head>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ const PatternedBackground = () => (
|
|||||||
<svg className="h-full w-full">
|
<svg className="h-full w-full">
|
||||||
<pattern
|
<pattern
|
||||||
id="pattern"
|
id="pattern"
|
||||||
x="10"
|
x="0.034759358288862785"
|
||||||
y="10"
|
y="3.335370511841166"
|
||||||
width="14.423223834988539"
|
width="14.423223834988539"
|
||||||
height="14.423223834988539"
|
height="14.423223834988539"
|
||||||
patternUnits="userSpaceOnUse"
|
patternUnits="userSpaceOnUse"
|
||||||
|
|||||||
@@ -1,51 +1,37 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import LottieIcon from "~/components/LottieIcon";
|
import LottieIcon from "~/components/LottieIcon";
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
|
||||||
|
function classNames(...classes: string[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
const Button: React.FC<{
|
const Button: React.FC<{
|
||||||
href: string;
|
href: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
json: object;
|
json: object;
|
||||||
isCollapsed?: boolean;
|
}> = ({ href, current, name, json }) => {
|
||||||
onCloseSideNav?: () => void;
|
|
||||||
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
setIndex((index) => index + 1);
|
setIndex((index) => index + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onClick={handleClick}
|
className={classNames(
|
||||||
className={twMerge(
|
current ? "bg-light-200 dark:bg-dark-200" : "dark:bg-dark-50",
|
||||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
"group flex items-center gap-x-3 rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||||
current
|
|
||||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
|
||||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
|
||||||
isCollapsed
|
|
||||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
|
||||||
: "gap-x-3",
|
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? name : undefined}
|
|
||||||
>
|
>
|
||||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
{name}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import {
|
|
||||||
Listbox,
|
|
||||||
ListboxButton,
|
|
||||||
ListboxOption,
|
|
||||||
ListboxOptions,
|
|
||||||
} from "@headlessui/react";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
HiChevronDown,
|
|
||||||
HiOutlineBanknotes,
|
|
||||||
HiOutlineCodeBracketSquare,
|
|
||||||
HiOutlineRectangleGroup,
|
|
||||||
HiOutlineUser,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
currentTab: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
|
||||||
|
|
||||||
const settingsTabs = [
|
|
||||||
{
|
|
||||||
key: "account",
|
|
||||||
icon: <HiOutlineUser />,
|
|
||||||
label: t`Account`,
|
|
||||||
condition: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "workspace",
|
|
||||||
icon: <HiOutlineRectangleGroup />,
|
|
||||||
label: t`Workspace`,
|
|
||||||
condition: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "billing",
|
|
||||||
label: t`Billing`,
|
|
||||||
icon: <HiOutlineBanknotes />,
|
|
||||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "api",
|
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
|
||||||
label: t`API`,
|
|
||||||
condition: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "integrations",
|
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
|
||||||
label: t`Integrations`,
|
|
||||||
condition: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const availableTabs = settingsTabs.filter((tab) => tab.condition);
|
|
||||||
|
|
||||||
// Update selected tab when currentTab prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
const tabIndex = availableTabs.findIndex((tab) => tab.key === currentTab);
|
|
||||||
if (tabIndex !== -1) {
|
|
||||||
setSelectedTabIndex(tabIndex);
|
|
||||||
}
|
|
||||||
}, [currentTab, availableTabs]);
|
|
||||||
|
|
||||||
const isTabActive = (tabKey: string) => {
|
|
||||||
return currentTab === tabKey;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
|
||||||
<div className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]">
|
|
||||||
<div className="m-auto max-w-[1100px] px-5 py-6 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]">
|
|
||||||
{t`Settings`}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="focus:outline-none">
|
|
||||||
<div className="sm:hidden">
|
|
||||||
{/* Mobile dropdown */}
|
|
||||||
<Listbox
|
|
||||||
value={selectedTabIndex}
|
|
||||||
onChange={(index) => {
|
|
||||||
const tabKey = availableTabs[index]?.key;
|
|
||||||
if (tabKey) {
|
|
||||||
void router.push(`/settings/${tabKey}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative mb-4">
|
|
||||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
|
||||||
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
|
||||||
<HiChevronDown
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
|
||||||
/>
|
|
||||||
</ListboxButton>
|
|
||||||
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-lg bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
|
|
||||||
{availableTabs.map((tab) => (
|
|
||||||
<ListboxOption
|
|
||||||
key={tab.key}
|
|
||||||
value={availableTabs.indexOf(tab)}
|
|
||||||
className="relative cursor-pointer select-none py-2 pl-3 pr-9 text-light-1000 dark:text-dark-1000"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</ListboxOption>
|
|
||||||
))}
|
|
||||||
</ListboxOptions>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<div className="border-b border-gray-200 dark:border-white/10">
|
|
||||||
<nav
|
|
||||||
aria-label="Tabs"
|
|
||||||
className="-mb-px flex space-x-8 focus:outline-none"
|
|
||||||
>
|
|
||||||
{availableTabs.map((tab) => (
|
|
||||||
<Link
|
|
||||||
key={tab.key}
|
|
||||||
href={`/settings/${tab.key}`}
|
|
||||||
className={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium transition-colors focus:outline-none ${
|
|
||||||
isTabActive(tab.key)
|
|
||||||
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
|
|
||||||
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="focus:outline-none">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Button } from "@headlessui/react";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
TbLayoutSidebarLeftCollapse,
|
|
||||||
TbLayoutSidebarLeftExpand,
|
|
||||||
} from "react-icons/tb";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import boardsIconDark from "~/assets/boards-dark.json";
|
import boardsIconDark from "~/assets/boards-dark.json";
|
||||||
import boardsIconLight from "~/assets/boards-light.json";
|
import boardsIconLight from "~/assets/boards-light.json";
|
||||||
@@ -16,16 +7,14 @@ import membersIconDark from "~/assets/members-dark.json";
|
|||||||
import membersIconLight from "~/assets/members-light.json";
|
import membersIconLight from "~/assets/members-light.json";
|
||||||
import settingsIconDark from "~/assets/settings-dark.json";
|
import settingsIconDark from "~/assets/settings-dark.json";
|
||||||
import settingsIconLight from "~/assets/settings-light.json";
|
import settingsIconLight from "~/assets/settings-light.json";
|
||||||
import templatesIconDark from "~/assets/templates-dark.json";
|
|
||||||
import templatesIconLight from "~/assets/templates-light.json";
|
|
||||||
import ReactiveButton from "~/components/ReactiveButton";
|
import ReactiveButton from "~/components/ReactiveButton";
|
||||||
import UserMenu from "~/components/UserMenu";
|
import UserMenu from "~/components/UserMenu";
|
||||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||||
|
import { useTheme } from "~/providers/theme";
|
||||||
|
|
||||||
interface SideNavigationProps {
|
interface SideNavigationProps {
|
||||||
user: UserType;
|
user: UserType;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onCloseSideNav?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
@@ -36,34 +25,14 @@ interface UserType {
|
|||||||
export default function SideNavigation({
|
export default function SideNavigation({
|
||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
onCloseSideNav,
|
|
||||||
}: SideNavigationProps) {
|
}: SideNavigationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const [isInitialised, setIsInitialised] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedState = localStorage.getItem("kan_sidebar-collapsed");
|
|
||||||
if (savedState !== null) {
|
|
||||||
setIsCollapsed(JSON.parse(savedState));
|
|
||||||
}
|
|
||||||
setIsInitialised(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialised) {
|
|
||||||
localStorage.setItem(
|
|
||||||
"kan_sidebar-collapsed",
|
|
||||||
JSON.stringify(isCollapsed),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [isCollapsed, isInitialised]);
|
|
||||||
|
|
||||||
const { pathname } = router;
|
const { pathname } = router;
|
||||||
|
|
||||||
const { theme, resolvedTheme } = useTheme();
|
const { activeTheme } = useTheme();
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
const isDarkMode = activeTheme === "dark";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
@@ -71,11 +40,6 @@ export default function SideNavigation({
|
|||||||
href: "/boards",
|
href: "/boards",
|
||||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: t`Templates`,
|
|
||||||
href: "/templates",
|
|
||||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: t`Members`,
|
name: t`Members`,
|
||||||
href: "/members",
|
href: "/members",
|
||||||
@@ -88,50 +52,11 @@ export default function SideNavigation({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
|
||||||
setIsCollapsed(!isCollapsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<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">
|
||||||
className={twMerge(
|
|
||||||
"flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:border-r-0 md:py-0 md:pl-0",
|
|
||||||
isCollapsed && "md:w-auto",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
|
<WorkspaceMenu />
|
||||||
{!isCollapsed && (
|
|
||||||
<Link href="/" className="block">
|
|
||||||
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
|
||||||
kan.bn
|
|
||||||
</h1>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={toggleCollapse}
|
|
||||||
className={twMerge(
|
|
||||||
"flex h-8 items-center justify-center rounded-md hover:bg-light-200 dark:hover:bg-dark-200",
|
|
||||||
isCollapsed ? "w-full" : "w-8",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<TbLayoutSidebarLeftExpand
|
|
||||||
size={18}
|
|
||||||
className="text-light-900 dark:text-dark-900"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TbLayoutSidebarLeftCollapse
|
|
||||||
size={18}
|
|
||||||
className="text-light-900 dark:text-dark-900"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
|
|
||||||
|
|
||||||
<WorkspaceMenu isCollapsed={isCollapsed} />
|
|
||||||
<ul role="list" className="space-y-1">
|
<ul role="list" className="space-y-1">
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<li key={item.name}>
|
<li key={item.name}>
|
||||||
@@ -140,23 +65,16 @@ export default function SideNavigation({
|
|||||||
current={pathname.includes(item.href)}
|
current={pathname.includes(item.href)}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
json={item.icon}
|
json={item.icon}
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onCloseSideNav={onCloseSideNav}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<UserMenu
|
||||||
<div className="space-y-3">
|
email={user.email ?? ""}
|
||||||
<UserMenu
|
imageUrl={user.image ?? undefined}
|
||||||
email={user.email ?? ""}
|
isLoading={isLoading}
|
||||||
imageUrl={user.image ?? undefined}
|
/>
|
||||||
isLoading={isLoading}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onCloseSideNav={onCloseSideNav}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { CgDarkMode } from "react-icons/cg";
|
import { CgDarkMode } from "react-icons/cg";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
|
import { useTheme } from "~/providers/theme";
|
||||||
|
|
||||||
const ThemeToggle = () => {
|
const ThemeToggle = () => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { themePreference, switchTheme } = useTheme();
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(theme === "light" ? "dark" : "light");
|
switchTheme(themePreference === "light" ? "dark" : "light");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||||
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} theme`}
|
aria-label={`Switch to ${themePreference === "light" ? "dark" : "light"} theme`}
|
||||||
>
|
>
|
||||||
<CgDarkMode
|
<CgDarkMode
|
||||||
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
|
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
|
||||||
theme === "dark" ? "rotate-180" : "rotate-0"
|
themePreference === "dark" ? "rotate-180" : "rotate-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ const Toggle = ({
|
|||||||
isChecked,
|
isChecked,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled,
|
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mr-4 flex items-center justify-end">
|
<div className="mr-4 flex items-center justify-end">
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
@@ -19,7 +17,6 @@ const Toggle = ({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
|
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
|
||||||
isChecked && "bg-indigo-600 dark:bg-indigo-600",
|
isChecked && "bg-indigo-600 dark:bg-indigo-600",
|
||||||
|
|||||||
@@ -1,79 +1,50 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useTheme } from "~/providers/theme";
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
email: string;
|
email: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isCollapsed?: boolean;
|
}
|
||||||
onCloseSideNav?: () => void;
|
|
||||||
|
function classNames(...classes: string[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserMenu({
|
export default function UserMenu({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
email,
|
email,
|
||||||
isLoading,
|
isLoading,
|
||||||
isCollapsed = false,
|
|
||||||
onCloseSideNav,
|
|
||||||
}: UserMenuProps) {
|
}: UserMenuProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, setTheme } = useTheme();
|
const { themePreference, switchTheme } = useTheme();
|
||||||
const { openModal } = useModal();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
|
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLinkClick = () => {
|
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModalOpen = (modalType: string) => {
|
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
openModal(modalType);
|
|
||||||
};
|
|
||||||
|
|
||||||
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
|
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu as="div" className="relative inline-block w-full text-left">
|
<Menu as="div" className="relative inline-block w-full text-left">
|
||||||
<div>
|
<div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className={twMerge(!isCollapsed && "flex")}>
|
<div className="flex">
|
||||||
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||||
<div
|
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||||
className={twMerge(
|
|
||||||
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Menu.Button
|
<Menu.Button className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000">
|
||||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
|
||||||
title={isCollapsed ? email : undefined}
|
|
||||||
>
|
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
@@ -93,14 +64,7 @@ export default function UserMenu({
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="mx-2 truncate text-sm">{email}</span>
|
||||||
className={twMerge(
|
|
||||||
"mx-2 truncate text-sm",
|
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{email}
|
|
||||||
</span>
|
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -114,12 +78,7 @@ export default function UserMenu({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items
|
<Menu.Items className="absolute bottom-[40px] left-0 z-10 mt-2 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
|
||||||
className={twMerge(
|
|
||||||
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
|
||||||
isCollapsed ? "left-0 w-48" : "left-0 w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
||||||
@@ -127,12 +86,12 @@ export default function UserMenu({
|
|||||||
</div>
|
</div>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme("system")}
|
onClick={() => switchTheme("system")}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={twMerge(
|
className={classNames(
|
||||||
theme === "system" ? "visible" : "invisible",
|
themePreference === "system" ? "visible" : "invisible",
|
||||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -141,12 +100,12 @@ export default function UserMenu({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme("dark")}
|
onClick={() => switchTheme("dark")}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={twMerge(
|
className={classNames(
|
||||||
theme === "dark" ? "visible" : "invisible",
|
themePreference === "dark" ? "visible" : "invisible",
|
||||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -155,12 +114,12 @@ export default function UserMenu({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme("light")}
|
onClick={() => switchTheme("light")}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={twMerge(
|
className={classNames(
|
||||||
theme === "light" ? "visible" : "invisible",
|
themePreference === "light" ? "visible" : "invisible",
|
||||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -168,38 +127,6 @@ export default function UserMenu({
|
|||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
|
||||||
<Menu.Item>
|
|
||||||
<Link
|
|
||||||
href="mailto:support@kan.bn"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
onClick={handleLinkClick}
|
|
||||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
{t`Support`}
|
|
||||||
</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item>
|
|
||||||
<Link
|
|
||||||
href="https://docs.kan.bn"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
onClick={handleLinkClick}
|
|
||||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
{t`Documentation`}
|
|
||||||
</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item>
|
|
||||||
<button
|
|
||||||
onClick={() => handleModalOpen("NEW_FEEDBACK")}
|
|
||||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
{t`Feedback`}
|
|
||||||
</button>
|
|
||||||
</Menu.Item>
|
|
||||||
</div>
|
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,158 +1,93 @@
|
|||||||
import { Button, Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment } from "react";
|
||||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
import { HiCheck } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import CommandPallette from "./CommandPallette";
|
|
||||||
|
|
||||||
export default function WorkspaceMenu({
|
export default function WorkspaceMenu() {
|
||||||
isCollapsed = false,
|
|
||||||
}: {
|
|
||||||
isCollapsed?: boolean;
|
|
||||||
}) {
|
|
||||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||||
useWorkspace();
|
useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
|
||||||
event.preventDefault();
|
|
||||||
setIsOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||||
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
<div>
|
||||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
{isLoading ? (
|
||||||
<div>
|
<div className="mb-1 flex p-1.5">
|
||||||
{isLoading ? (
|
<div className="h-6 w-6 animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
</div>
|
||||||
<div
|
) : (
|
||||||
className={twMerge(
|
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200">
|
||||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||||
isCollapsed && "md:hidden",
|
<span className="text-xs font-bold leading-none text-white">
|
||||||
)}
|
{workspace.name.charAt(0).toUpperCase()}
|
||||||
/>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
) : (
|
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
<div
|
{workspace.name}
|
||||||
className={twMerge(
|
</span>
|
||||||
"flex items-center gap-1",
|
{workspace.plan === "pro" && (
|
||||||
isCollapsed && "md:flex-col-reverse md:items-stretch",
|
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700">
|
||||||
)}
|
Pro
|
||||||
>
|
</span>
|
||||||
<Menu.Button
|
|
||||||
className={twMerge(
|
|
||||||
"mb-1 flex h-[34px] flex-1 items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
|
||||||
isCollapsed &&
|
|
||||||
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
|
|
||||||
)}
|
|
||||||
title={isCollapsed ? workspace.name : undefined}
|
|
||||||
>
|
|
||||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
|
||||||
<span className="text-xs font-bold leading-none text-white">
|
|
||||||
{workspace.name.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={twMerge(
|
|
||||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{workspace.name}
|
|
||||||
</span>
|
|
||||||
{workspace.plan === "pro" && (
|
|
||||||
<span
|
|
||||||
className={twMerge(
|
|
||||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Pro
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Menu.Button>
|
|
||||||
<Button
|
|
||||||
className={twMerge(
|
|
||||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
|
||||||
isCollapsed && "md:mb-2 md:h-9 md:w-9 md:self-center",
|
|
||||||
)}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
>
|
|
||||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-out duration-100"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Menu.Items
|
|
||||||
className={twMerge(
|
|
||||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
|
||||||
isCollapsed ? "w-48" : "w-full",
|
|
||||||
)}
|
)}
|
||||||
>
|
</Menu.Button>
|
||||||
<div className="p-1">
|
)}
|
||||||
{availableWorkspaces.map((availableWorkspace) => (
|
</div>
|
||||||
<div key={availableWorkspace.publicId} className="flex">
|
|
||||||
<Menu.Item>
|
<Transition
|
||||||
<button
|
as={Fragment}
|
||||||
onClick={() => switchWorkspace(availableWorkspace)}
|
enter="transition ease-out duration-100"
|
||||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
enterFrom="transform opacity-0 scale-95"
|
||||||
>
|
enterTo="transform opacity-100 scale-100"
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
leave="transition ease-in duration-75"
|
||||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
<span className="text-xs font-medium leading-none text-white">
|
leaveTo="transform opacity-0 scale-95"
|
||||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
>
|
||||||
</span>
|
<Menu.Items className="absolute left-0 z-10 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
|
||||||
|
<div className="p-1">
|
||||||
|
{availableWorkspaces.map((availableWorkspace) => (
|
||||||
|
<div key={availableWorkspace.publicId} className="flex">
|
||||||
|
<Menu.Item>
|
||||||
|
<button
|
||||||
|
onClick={() => switchWorkspace(availableWorkspace)}
|
||||||
|
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||||
|
<span className="text-xs font-medium leading-none text-white">
|
||||||
|
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 truncate text-xs font-medium">
|
</span>
|
||||||
{availableWorkspace.name}
|
<span className="ml-2 text-xs font-medium">
|
||||||
</span>
|
{availableWorkspace.name}
|
||||||
</div>
|
</span>
|
||||||
{workspace.publicId === availableWorkspace.publicId && (
|
</div>
|
||||||
<span>
|
{workspace.name === availableWorkspace.name && (
|
||||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
<span>
|
||||||
</span>
|
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
)}
|
</span>
|
||||||
</button>
|
)}
|
||||||
</Menu.Item>
|
</button>
|
||||||
</div>
|
</Menu.Item>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
</div>
|
||||||
<Menu.Item>
|
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||||
<button
|
<Menu.Item>
|
||||||
onClick={() => openModal("NEW_WORKSPACE")}
|
<button
|
||||||
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"
|
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"
|
||||||
{t`Create workspace`}
|
>
|
||||||
</button>
|
{t`Create workspace`}
|
||||||
</Menu.Item>
|
</button>
|
||||||
</div>
|
</Menu.Item>
|
||||||
</Menu.Items>
|
</div>
|
||||||
</Transition>
|
</Menu.Items>
|
||||||
</Menu>
|
</Transition>
|
||||||
</>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,15 @@ interface Props {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
modalSize?: "sm" | "md" | "lg";
|
modalSize?: "sm" | "md" | "lg";
|
||||||
positionFromTop?: "sm" | "md" | "lg";
|
positionFromTop?: "sm" | "md" | "lg";
|
||||||
isVisible?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
children,
|
children,
|
||||||
modalSize = "sm",
|
modalSize = "sm",
|
||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { isOpen, closeModal } = useModal();
|
const { isOpen, closeModal } = useModal();
|
||||||
|
|
||||||
const shouldShow = isVisible !== undefined ? isVisible : isOpen;
|
|
||||||
|
|
||||||
const modalSizeMap = {
|
const modalSizeMap = {
|
||||||
sm: "max-w-[400px]",
|
sm: "max-w-[400px]",
|
||||||
md: "max-w-[550px]",
|
md: "max-w-[550px]",
|
||||||
@@ -33,8 +29,8 @@ const Modal: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={shouldShow} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@@ -47,7 +43,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
<div className="fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
|
<div className="fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const env = createEnv({
|
|||||||
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
POSTGRES_URL: z.string().url().optional().or(z.literal("")),
|
POSTGRES_URL: z.string().url(),
|
||||||
TRELLO_APP_API_KEY: z.string().optional(),
|
TRELLO_APP_API_KEY: z.string().optional(),
|
||||||
TRELLO_APP_SECRET: z.string().optional(),
|
TRELLO_APP_SECRET: z.string().optional(),
|
||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
@@ -51,10 +51,6 @@ export const env = createEnv({
|
|||||||
VK_CLIENT_SECRET: z.string().optional(),
|
VK_CLIENT_SECRET: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||||
// Generic OIDC Provider
|
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
|
||||||
OIDC_DISCOVERY_URL: z.string().optional(),
|
|
||||||
REDDIT_CLIENT_ID: z.string().optional(),
|
REDDIT_CLIENT_ID: z.string().optional(),
|
||||||
REDDIT_CLIENT_SECRET: z.string().optional(),
|
REDDIT_CLIENT_SECRET: z.string().optional(),
|
||||||
ROBLOX_CLIENT_ID: z.string().optional(),
|
ROBLOX_CLIENT_ID: z.string().optional(),
|
||||||
@@ -73,7 +69,6 @@ export const env = createEnv({
|
|||||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
|
||||||
EMAIL_FROM: z.string().optional(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -105,13 +100,6 @@ export const env = createEnv({
|
|||||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY: 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.
|
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||||
@@ -130,8 +118,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||||
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
|
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
|
||||||
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
|
|
||||||
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
|
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation:
|
||||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function useClipboard({ timeout = 500 } = {}) {
|
|
||||||
const [error, setError] = useState<string | Error | null | undefined>(null);
|
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
|
||||||
const [copyTimeout, setCopyTimeout] = useState<number | undefined>(undefined);
|
|
||||||
|
|
||||||
const handleCopyResult = (hasError: boolean) => {
|
|
||||||
clearTimeout(copyTimeout);
|
|
||||||
|
|
||||||
setCopyTimeout(
|
|
||||||
setTimeout(() => setCopied(false), timeout) as unknown as number,
|
|
||||||
);
|
|
||||||
|
|
||||||
setCopied(hasError);
|
|
||||||
};
|
|
||||||
|
|
||||||
const copy = (value: string) => {
|
|
||||||
if ("clipboard" in navigator) {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(value)
|
|
||||||
.then(() => handleCopyResult(true))
|
|
||||||
.catch((err) => setError(err));
|
|
||||||
} else {
|
|
||||||
setError(new Error("Error: navigator.clipboard is not supported"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setError(null);
|
|
||||||
setCopied(false);
|
|
||||||
clearTimeout(copyTimeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { copy, reset, error, copied };
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export function useMediaQuery(query: string): boolean {
|
|
||||||
const [matches, setMatches] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const media = window.matchMedia(query);
|
|
||||||
setMatches(media.matches);
|
|
||||||
|
|
||||||
const listener = (event: MediaQueryListEvent) => {
|
|
||||||
setMatches(event.matches);
|
|
||||||
};
|
|
||||||
|
|
||||||
media.addEventListener("change", listener);
|
|
||||||
return () => media.removeEventListener("change", listener);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIsMobile(): boolean {
|
|
||||||
return useMediaQuery("(max-width: 767px)");
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
|
|
||||||
interface UseModalFormStateOptions<T> {
|
|
||||||
modalType: string;
|
|
||||||
initialValues: T;
|
|
||||||
resetOnClose?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useModalFormState<T extends Record<string, any>>({
|
|
||||||
modalType,
|
|
||||||
initialValues,
|
|
||||||
resetOnClose = false,
|
|
||||||
}: UseModalFormStateOptions<T>) {
|
|
||||||
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
|
|
||||||
|
|
||||||
const isCurrentModal = modalContentType === modalType;
|
|
||||||
const savedState = getModalState(modalType) as T | undefined;
|
|
||||||
|
|
||||||
// get current form state (using the saved values if available, otherwise the initial values)
|
|
||||||
const formState = savedState || initialValues;
|
|
||||||
|
|
||||||
const saveFormState = (state: Partial<T>) => {
|
|
||||||
if (!isCurrentModal) return;
|
|
||||||
|
|
||||||
const currentState = getModalState(modalType) || initialValues;
|
|
||||||
const newState = { ...currentState, ...state };
|
|
||||||
setModalState(modalType, newState);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFormState = () => {
|
|
||||||
clearModalState(modalType);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (resetOnClose && !isOpen && savedState) {
|
|
||||||
clearModalState(modalType);
|
|
||||||
}
|
|
||||||
}, [isOpen, resetOnClose, savedState, modalType, clearModalState]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
formState,
|
|
||||||
saveFormState,
|
|
||||||
clearFormState,
|
|
||||||
isCurrentModal,
|
|
||||||
hasSavedState: !!savedState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const locales = ["en", "fr", "de", "es", "it", "nl", "ru"] as const;
|
export const locales = ["en", "fr", "de", "es", "it", "nl"] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
@@ -11,5 +11,4 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
es: "Español",
|
es: "Español",
|
||||||
it: "Italiano",
|
it: "Italiano",
|
||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { NextRequest } from "next/server";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
|
||||||
if (request.nextUrl.pathname === "/") {
|
|
||||||
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
|
||||||
const loginUrl = new URL("/login", request.url);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ["/"],
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
import "~/utils/i18n";
|
import "~/utils/i18n";
|
||||||
|
|
||||||
import type { NextPage, Viewport } from "next";
|
import type { Viewport } from "next";
|
||||||
import type { AppProps, AppType } from "next/app";
|
import type { AppType } from "next/app";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
|
||||||
import { Plus_Jakarta_Sans } from "next/font/google";
|
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { ThemeProvider } from "next-themes";
|
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -15,6 +13,7 @@ import { useEffect } from "react";
|
|||||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||||
import { ModalProvider } from "~/providers/modal";
|
import { ModalProvider } from "~/providers/modal";
|
||||||
import { PopupProvider } from "~/providers/popup";
|
import { PopupProvider } from "~/providers/popup";
|
||||||
|
import { ThemeProvider } from "~/providers/theme";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
const jakarta = Plus_Jakarta_Sans({
|
const jakarta = Plus_Jakarta_Sans({
|
||||||
@@ -35,15 +34,7 @@ export const viewport: Viewport = {
|
|||||||
userScalable: false,
|
userScalable: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
|
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||||
getLayout?: (page: ReactElement) => ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AppPropsWithLayout = AppProps & {
|
|
||||||
Component: NextPageWithLayout;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|
||||||
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
|
const posthogKey = env("NEXT_PUBLIC_POSTHOG_KEY");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,8 +50,6 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
}
|
}
|
||||||
}, [posthogKey]);
|
}, [posthogKey]);
|
||||||
|
|
||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
@@ -81,15 +70,15 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
<script src="/__ENV.js" />
|
<script src="/__ENV.js" />
|
||||||
<main className="font-sans">
|
<main className="font-sans">
|
||||||
<LinguiProviderWrapper>
|
<LinguiProviderWrapper>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider>
|
||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
<PopupProvider>
|
<PopupProvider>
|
||||||
{posthogKey ? (
|
{posthogKey ? (
|
||||||
<PostHogProvider client={posthog}>
|
<PostHogProvider client={posthog}>
|
||||||
{getLayout(<Component {...pageProps} />)}
|
<Component {...pageProps} />
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
) : (
|
) : (
|
||||||
getLayout(<Component {...pageProps} />)
|
<Component {...pageProps} />
|
||||||
)}
|
)}
|
||||||
</PopupProvider>
|
</PopupProvider>
|
||||||
</ModalProvider>
|
</ModalProvider>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { env } from "next-runtime-env";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
@@ -41,22 +40,17 @@ export default async function handler(
|
|||||||
const body = req.body as CheckoutSessionRequest;
|
const body = req.body as CheckoutSessionRequest;
|
||||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
||||||
|
|
||||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
if (!successUrl || !cancelUrl || !slug || !workspacePublicId) {
|
||||||
return res.status(400).json({ error: "Missing required fields" });
|
return res.status(400).json({ error: "Missing required fields" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slug) {
|
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
|
||||||
|
|
||||||
if (!slugResult.success) {
|
if (!slugResult.success) {
|
||||||
return new Response(
|
return new Response(JSON.stringify({ error: "Invalid workspace slug" }), {
|
||||||
JSON.stringify({ error: "Invalid workspace slug" }),
|
status: 400,
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
status: 400,
|
});
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
||||||
@@ -72,37 +66,20 @@ export default async function handler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await subscriptionRepo.create(db, {
|
|
||||||
plan: "pro",
|
|
||||||
referenceId: workspacePublicId,
|
|
||||||
userId: user.id,
|
|
||||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
|
||||||
status: "incomplete",
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscriptionId = subscription?.id;
|
|
||||||
|
|
||||||
if (!subscriptionId) {
|
|
||||||
return res.status(500).json({ error: "Error creating subscription" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
price: process.env.STRIPE_PRO_PLAN_PRICE_ID,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||||
client_reference_id: workspacePublicId,
|
|
||||||
customer: user.stripeCustomerId ?? undefined,
|
customer: user.stripeCustomerId ?? undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
...(slug && { workspaceSlug: slug }),
|
workspaceSlug: slug,
|
||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
userId: user.id,
|
|
||||||
subscriptionId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default async function handler(
|
|||||||
const event = stripe.webhooks.constructEvent(
|
const event = stripe.webhooks.constructEvent(
|
||||||
rawBody,
|
rawBody,
|
||||||
sig,
|
sig,
|
||||||
process.env.STRIPE_WEBHOOK_SECRET_LEGACY!,
|
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { db } = await createNextApiContext(req);
|
const { db } = await createNextApiContext(req);
|
||||||
@@ -47,9 +47,9 @@ export default async function handler(
|
|||||||
|
|
||||||
const metaData = checkoutSession.metadata;
|
const metaData = checkoutSession.metadata;
|
||||||
|
|
||||||
if (metaData?.workspacePublicId) {
|
if (metaData?.workspacePublicId && metaData.workspaceSlug) {
|
||||||
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
||||||
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
|
slug: metaData.workspaceSlug,
|
||||||
plan: "pro",
|
plan: "pro",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default async function handler(
|
|||||||
const client = new S3Client({
|
const client = new S3Client({
|
||||||
region: env.S3_REGION ?? "",
|
region: env.S3_REGION ?? "",
|
||||||
endpoint: env.S3_ENDPOINT ?? "",
|
endpoint: env.S3_ENDPOINT ?? "",
|
||||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
||||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
||||||
@@ -59,7 +58,6 @@ export default async function handler(
|
|||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||||
Key: filename,
|
Key: filename,
|
||||||
ACL: "public-read",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
import { WorkspaceProvider } from "~/providers/workspace";
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import Dashboard from "~/components/Dashboard";
|
||||||
import Popup from "~/components/Popup";
|
import Popup from "~/components/Popup";
|
||||||
import BoardView from "~/views/board";
|
import BoardView from "~/views/board";
|
||||||
|
|
||||||
const BoardPage: NextPageWithLayout = () => {
|
export default function BoardPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<WorkspaceProvider>
|
||||||
<BoardView />
|
<Dashboard>
|
||||||
|
<BoardView />
|
||||||
|
</Dashboard>
|
||||||
<Popup />
|
<Popup />
|
||||||
</>
|
</WorkspaceProvider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
BoardPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default BoardPage;
|
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { NextPageWithLayout } from "../_app";
|
import { WorkspaceProvider } from "~/providers/workspace";
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import Dashboard from "~/components/Dashboard";
|
||||||
import Popup from "~/components/Popup";
|
import Popup from "~/components/Popup";
|
||||||
import BoardsView from "~/views/boards";
|
import BoardsView from "~/views/boards";
|
||||||
|
|
||||||
const BoardsPage: NextPageWithLayout = () => {
|
export default function BoardsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<WorkspaceProvider>
|
||||||
<BoardsView />
|
<Dashboard>
|
||||||
|
<BoardsView />
|
||||||
|
</Dashboard>
|
||||||
<Popup />
|
<Popup />
|
||||||
</>
|
</WorkspaceProvider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
BoardsPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default BoardsPage;
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
import Dashboard from "~/components/Dashboard";
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import Popup from "~/components/Popup";
|
import Popup from "~/components/Popup";
|
||||||
|
import { WorkspaceProvider } from "~/providers/workspace";
|
||||||
import CardView, { CardRightPanel } from "~/views/card";
|
import CardView, { CardRightPanel } from "~/views/card";
|
||||||
|
|
||||||
const CardPage: NextPageWithLayout = () => {
|
export default function CardPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<WorkspaceProvider>
|
||||||
<CardView />
|
<Dashboard hasRightPanel rightPanel={<CardRightPanel />}>
|
||||||
|
<CardView />
|
||||||
|
</Dashboard>
|
||||||
<Popup />
|
<Popup />
|
||||||
</>
|
</WorkspaceProvider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
CardPage.getLayout = (page) =>
|
|
||||||
getDashboardLayout(page, <CardRightPanel />, true);
|
|
||||||
|
|
||||||
export default CardPage;
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import InviteView from "~/views/invite";
|
|
||||||
|
|
||||||
export default function InvitePage() {
|
|
||||||
return <InviteView />;
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
import { WorkspaceProvider } from "~/providers/workspace";
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import Dashboard from "~/components/Dashboard";
|
||||||
import Popup from "~/components/Popup";
|
import Popup from "~/components/Popup";
|
||||||
import MembersView from "~/views/members";
|
import MembersView from "~/views/members";
|
||||||
|
|
||||||
const MembersPage: NextPageWithLayout = () => {
|
export default function MembersPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<WorkspaceProvider>
|
||||||
<MembersView />
|
<Dashboard>
|
||||||
|
<MembersView />
|
||||||
|
</Dashboard>
|
||||||
<Popup />
|
<Popup />
|
||||||
</>
|
</WorkspaceProvider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
MembersPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default MembersPage;
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import PricingView from "~/views/pricing";
|
|
||||||
|
|
||||||
export default function Pricing() {
|
|
||||||
return <PricingView />;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
|
||||||
import AccountSettings from "~/views/settings/AccountSettings";
|
|
||||||
|
|
||||||
const AccountSettingsPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<SettingsLayout currentTab="account">
|
|
||||||
<AccountSettings />
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AccountSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default AccountSettingsPage;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
|
||||||
import ApiSettings from "~/views/settings/ApiSettings";
|
|
||||||
|
|
||||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<SettingsLayout currentTab="api">
|
|
||||||
<ApiSettings />
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ApiSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default ApiSettingsPage;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
|
||||||
import BillingSettings from "~/views/settings/BillingSettings";
|
|
||||||
|
|
||||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<SettingsLayout currentTab="billing">
|
|
||||||
<BillingSettings />
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
BillingSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default BillingSettingsPage;
|
|
||||||
15
apps/web/src/pages/settings/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { WorkspaceProvider } from "~/providers/workspace";
|
||||||
|
import Dashboard from "~/components/Dashboard";
|
||||||
|
import SettingsView from "~/views/settings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<WorkspaceProvider>
|
||||||
|
<Dashboard>
|
||||||
|
<SettingsView />
|
||||||
|
</Dashboard>
|
||||||
|
<Popup />
|
||||||
|
</WorkspaceProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
|
||||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
|
||||||
|
|
||||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<SettingsLayout currentTab="integrations">
|
|
||||||
<IntegrationsSettings />
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
IntegrationsSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default IntegrationsSettingsPage;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
|
||||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
|
||||||
|
|
||||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<SettingsLayout currentTab="workspace">
|
|
||||||
<WorkspaceSettings />
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
WorkspaceSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default WorkspaceSettingsPage;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
import CardView, { CardRightPanel } from "~/views/card";
|
|
||||||
|
|
||||||
const CardPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardView isTemplate />
|
|
||||||
<Popup />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CardPage.getLayout = (page) =>
|
|
||||||
getDashboardLayout(page, <CardRightPanel isTemplate />, true);
|
|
||||||
|
|
||||||
export default CardPage;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
import BoardView from "~/views/board";
|
|
||||||
|
|
||||||
const TemplatePage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BoardView isTemplate />
|
|
||||||
<Popup />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
TemplatePage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default TemplatePage;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
import BoardsView from "~/views/boards";
|
|
||||||
|
|
||||||
const TemplatesPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BoardsView isTemplate />
|
|
||||||
<Popup />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
TemplatesPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default TemplatesPage;
|
|
||||||
@@ -1,16 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useState } from "react";
|
import { createContext, useContext, useState } from "react";
|
||||||
|
|
||||||
interface ModalState {
|
type ModalContextType = {
|
||||||
contentType: string;
|
|
||||||
entityId?: string;
|
|
||||||
entityLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModalContextType {
|
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
openModal: (
|
openModal: (
|
||||||
contentType: string,
|
contentType: string,
|
||||||
@@ -18,83 +8,37 @@ interface ModalContextType {
|
|||||||
entityLabel?: string,
|
entityLabel?: string,
|
||||||
) => void;
|
) => void;
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
closeModals: (count: number) => void;
|
|
||||||
clearAllModals: () => void;
|
|
||||||
modalContentType: string;
|
modalContentType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
entityLabel: string;
|
entityLabel: string;
|
||||||
modalStates: Record<string, any>;
|
};
|
||||||
setModalState: (modalType: string, state: any) => void;
|
|
||||||
getModalState: (modalType: string) => any;
|
interface Props {
|
||||||
clearModalState: (modalType: string) => void;
|
children: React.ReactNode;
|
||||||
clearAllModalStates: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalContext = createContext<ModalContextType | undefined>(undefined);
|
const ModalContext = createContext<ModalContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const ModalProvider: React.FC<Props> = ({ children }) => {
|
export const ModalProvider: React.FC<Props> = ({ children }) => {
|
||||||
const [modalStack, setModalStack] = useState<ModalState[]>([]);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [modalStates, setModalStates] = useState<Record<string, any>>({});
|
const [entityId, setEntityId] = useState("");
|
||||||
|
const [entityLabel, setEntityLabel] = useState("");
|
||||||
|
const [modalContentType, setModalContentType] = useState("");
|
||||||
|
|
||||||
const isOpen = modalStack.length > 0;
|
const openModal = (
|
||||||
const currentModal = modalStack[modalStack.length - 1];
|
contentType: string,
|
||||||
const modalContentType = currentModal?.contentType || "";
|
entityId?: string,
|
||||||
const entityId = currentModal?.entityId || "";
|
entityLabel?: string,
|
||||||
const entityLabel = currentModal?.entityLabel || "";
|
) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setModalContentType(contentType);
|
||||||
|
if (entityId) setEntityId(entityId);
|
||||||
|
if (entityLabel) setEntityLabel(entityLabel);
|
||||||
|
};
|
||||||
|
|
||||||
const openModal = useCallback(
|
const closeModal = () => {
|
||||||
(contentType: string, entityId?: string, entityLabel?: string) => {
|
setIsOpen(false);
|
||||||
const newModal: ModalState = { contentType, entityId, entityLabel };
|
};
|
||||||
setModalStack((prev) => [...prev, newModal]);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
|
||||||
setModalStack((prev) => {
|
|
||||||
if (prev.length <= 1) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return prev.slice(0, -1);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeModals = useCallback((count: number) => {
|
|
||||||
setModalStack((prev) => {
|
|
||||||
const newLength = Math.max(0, prev.length - count);
|
|
||||||
return prev.slice(0, newLength);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearAllModals = useCallback(() => {
|
|
||||||
setModalStack([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setModalState = useCallback((modalType: string, state: any) => {
|
|
||||||
setModalStates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[modalType]: state,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getModalState = useCallback(
|
|
||||||
(modalType: string) => {
|
|
||||||
return modalStates[modalType];
|
|
||||||
},
|
|
||||||
[modalStates],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearModalState = useCallback((modalType: string) => {
|
|
||||||
setModalStates((prev) => {
|
|
||||||
const newStates = { ...prev };
|
|
||||||
delete newStates[modalType];
|
|
||||||
return newStates;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearAllModalStates = useCallback(() => {
|
|
||||||
setModalStates({});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContext.Provider
|
<ModalContext.Provider
|
||||||
@@ -102,16 +46,9 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
|
|||||||
isOpen,
|
isOpen,
|
||||||
openModal,
|
openModal,
|
||||||
closeModal,
|
closeModal,
|
||||||
closeModals,
|
|
||||||
clearAllModals,
|
|
||||||
modalContentType,
|
modalContentType,
|
||||||
entityId,
|
entityId,
|
||||||
entityLabel,
|
entityLabel,
|
||||||
modalStates,
|
|
||||||
setModalState,
|
|
||||||
getModalState,
|
|
||||||
clearModalState,
|
|
||||||
clearAllModalStates,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -46,19 +46,13 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
const workspacePublicId = useSearchParams().get("workspacePublicId");
|
|
||||||
|
|
||||||
const { data, isLoading } = api.workspace.all.useQuery();
|
const { data, isLoading } = api.workspace.all.useQuery();
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const switchWorkspace = (_workspace: Workspace) => {
|
const switchWorkspace = (_workspace: Workspace) => {
|
||||||
localStorage.setItem("workspacePublicId", _workspace.publicId);
|
localStorage.setItem("workspacePublicId", _workspace.publicId);
|
||||||
|
|
||||||
setWorkspace(_workspace);
|
setWorkspace(_workspace);
|
||||||
|
|
||||||
// Refetch workspace data to ensure availableWorkspaces is up to date
|
|
||||||
void utils.workspace.all.refetch();
|
|
||||||
|
|
||||||
router.push(`/boards`);
|
router.push(`/boards`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,18 +63,24 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storedWorkspaceId: string | null =
|
const storedWorkspaceId: string | null =
|
||||||
workspacePublicId ?? localStorage.getItem("workspacePublicId");
|
localStorage.getItem("workspacePublicId");
|
||||||
|
|
||||||
if (data.length) {
|
if (data.length) {
|
||||||
const workspaces = data.map(({ workspace, role }) => ({
|
const workspaces = data
|
||||||
role,
|
.map(({ workspace, role }) => {
|
||||||
publicId: workspace.publicId,
|
if (!workspace) return;
|
||||||
name: workspace.name,
|
|
||||||
slug: workspace.slug,
|
return {
|
||||||
description: workspace.description,
|
role,
|
||||||
plan: workspace.plan,
|
publicId: workspace.publicId,
|
||||||
hasLoaded: true,
|
name: workspace.name,
|
||||||
})) as Workspace[];
|
slug: workspace.slug,
|
||||||
|
description: workspace.description,
|
||||||
|
plan: workspace.plan,
|
||||||
|
hasLoaded: true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((workspace) => workspace !== null) as Workspace[];
|
||||||
|
|
||||||
if (workspaces.length) setAvailableWorkspaces(workspaces);
|
if (workspaces.length) setAvailableWorkspaces(workspaces);
|
||||||
}
|
}
|
||||||
@@ -101,11 +101,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
description: selectedWorkspace.workspace.description,
|
description: selectedWorkspace.workspace.description,
|
||||||
role: selectedWorkspace.role,
|
role: selectedWorkspace.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workspacePublicId) {
|
|
||||||
router.push(`/boards`);
|
|
||||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const primaryWorkspace = data[0]?.workspace;
|
const primaryWorkspace = data[0]?.workspace;
|
||||||
const primaryWorkspaceRole = data[0]?.role;
|
const primaryWorkspaceRole = data[0]?.role;
|
||||||
@@ -121,7 +116,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
role: primaryWorkspaceRole,
|
role: primaryWorkspaceRole,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, isLoading, workspacePublicId, router]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceContext.Provider
|
<WorkspaceContext.Provider
|
||||||
|
|||||||
@@ -25,17 +25,3 @@
|
|||||||
width: 200%;
|
width: 200%;
|
||||||
height: 200%;
|
height: 200%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
@supports not (overflow-wrap: anywhere) {
|
|
||||||
.break-anywhere {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (overflow-wrap: anywhere) {
|
|
||||||
.break-anywhere {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ export const formatMemberDisplayName = (
|
|||||||
return localPart.replace(/[_-]/g, ".");
|
return localPart.replace(/[_-]/g, ".");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAvatarUrl = (imageOrKey: string | null) => {
|
export const getAvatarUrl = (imageOrKey: string) => {
|
||||||
if (!imageOrKey) return "";
|
|
||||||
|
|
||||||
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
|
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
|
||||||
return imageOrKey;
|
return imageOrKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ const loadMessages = async (locale: Locale) => {
|
|||||||
return (await import("~/locales/it/messages")).messages;
|
return (await import("~/locales/it/messages")).messages;
|
||||||
case "nl":
|
case "nl":
|
||||||
return (await import("~/locales/nl/messages")).messages;
|
return (await import("~/locales/nl/messages")).messages;
|
||||||
case "ru":
|
|
||||||
return (await import("~/locales/ru/messages")).messages;
|
|
||||||
default:
|
default:
|
||||||
return enMessages;
|
return enMessages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
@@ -17,8 +17,6 @@ export default function LoginPage() {
|
|||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
const redirect = useSearchParams().get("next");
|
|
||||||
|
|
||||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||||
setIsMagicLinkSent(value);
|
setIsMagicLinkSent(value);
|
||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
@@ -63,11 +61,7 @@ export default function LoginPage() {
|
|||||||
<Trans>
|
<Trans>
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<span className="underline">
|
<span className="underline">
|
||||||
<Link
|
<Link href="/signup">Sign up</Link>
|
||||||
href={redirect ? `/signup?next=${redirect}` : "/signup"}
|
|
||||||
>
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</span>
|
</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
@@ -17,8 +17,6 @@ export default function SignUpPage() {
|
|||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||||
|
|
||||||
const redirect = useSearchParams().get("next");
|
|
||||||
|
|
||||||
const { data } = authClient.useSession();
|
const { data } = authClient.useSession();
|
||||||
|
|
||||||
if (data?.user.id) router.push("/boards");
|
if (data?.user.id) router.push("/boards");
|
||||||
@@ -88,9 +86,7 @@ export default function SignUpPage() {
|
|||||||
<Trans>
|
<Trans>
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<span className="underline">
|
<span className="underline">
|
||||||
<Link href={redirect ? `/login?next=${redirect}` : "/login"}>
|
<Link href="/login">Sign in</Link>
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</span>
|
</span>
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,51 +1,23 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import {
|
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
|
||||||
HiEllipsisHorizontal,
|
|
||||||
HiLink,
|
|
||||||
HiOutlineDocumentDuplicate,
|
|
||||||
HiOutlineTrash,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
export default function BoardDropdown({
|
export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
|
||||||
isTemplate,
|
|
||||||
isLoading,
|
|
||||||
boardPublicId,
|
|
||||||
workspacePublicId,
|
|
||||||
}: {
|
|
||||||
isTemplate: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
boardPublicId: string;
|
|
||||||
workspacePublicId: string;
|
|
||||||
}) {
|
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
items={[
|
items={[
|
||||||
...(isTemplate
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: t`Make template`,
|
|
||||||
action: () => openModal("CREATE_TEMPLATE"),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Edit board URL`,
|
|
||||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
|
||||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
|
|
||||||
{
|
{
|
||||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
label: t`Edit board URL`,
|
||||||
|
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||||
|
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Delete board`,
|
||||||
action: () => openModal("DELETE_BOARD"),
|
action: () => openModal("DELETE_BOARD"),
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Badge from "~/components/Badge";
|
import Badge from "~/components/Badge";
|
||||||
import CircularProgress from "~/components/CircularProgress";
|
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
@@ -8,7 +7,6 @@ const Card = ({
|
|||||||
title,
|
title,
|
||||||
labels,
|
labels,
|
||||||
members,
|
members,
|
||||||
checklists,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
labels: { name: string; colourCode: string | null }[];
|
labels: { name: string; colourCode: string | null }[];
|
||||||
@@ -17,32 +15,11 @@ const Card = ({
|
|||||||
email: string;
|
email: string;
|
||||||
user: { name: string | null; email: string; image: string | null } | null;
|
user: { name: string | null; email: string; image: string | null } | null;
|
||||||
}[];
|
}[];
|
||||||
checklists: {
|
|
||||||
publicId: string;
|
|
||||||
name: string;
|
|
||||||
items: {
|
|
||||||
publicId: string;
|
|
||||||
title: string;
|
|
||||||
completed: boolean;
|
|
||||||
index: number;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
}) => {
|
}) => {
|
||||||
const completedItems = checklists.reduce((acc, checklist) => {
|
|
||||||
return acc + checklist.items.filter((item) => item.completed).length;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const totalItems = checklists.reduce((acc, checklist) => {
|
|
||||||
return acc + checklist.items.length;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const progress =
|
|
||||||
totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
{labels.length || members.length || checklists.length > 0 ? (
|
{labels.length || members.length ? (
|
||||||
<div className="mt-2 flex flex-col justify-end">
|
<div className="mt-2 flex flex-col justify-end">
|
||||||
<div className="space-x-0.5">
|
<div className="space-x-0.5">
|
||||||
{labels.map((label) => (
|
{labels.map((label) => (
|
||||||
@@ -52,37 +29,21 @@ const Card = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||||
{checklists.length > 0 && (
|
{members.map(({ user, email }) => {
|
||||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-600">
|
const avatarUrl = user?.image
|
||||||
<CircularProgress
|
? getAvatarUrl(user.image)
|
||||||
progress={progress || 2}
|
: undefined;
|
||||||
size="sm"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-light-900 dark:text-dark-950">
|
|
||||||
{completedItems}/{totalItems}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{members.length > 0 && (
|
|
||||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
|
||||||
{members.map(({ user, email }) => {
|
|
||||||
const avatarUrl = user?.image
|
|
||||||
? getAvatarUrl(user.image)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={user?.name ?? ""}
|
name={user?.name ?? ""}
|
||||||
email={user?.email ?? email}
|
email={user?.email ?? email}
|
||||||
imageUrl={avatarUrl}
|
imageUrl={avatarUrl}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
@@ -7,10 +6,8 @@ import { api } from "~/utils/api";
|
|||||||
|
|
||||||
export function DeleteBoardConfirmation({
|
export function DeleteBoardConfirmation({
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
isTemplate,
|
|
||||||
}: {
|
}: {
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
isTemplate: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
@@ -18,7 +15,7 @@ export function DeleteBoardConfirmation({
|
|||||||
const deleteBoard = api.board.delete.useMutation({
|
const deleteBoard = api.board.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
router.push(isTemplate ? `/templates` : `/boards`);
|
router.push(`/boards`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,18 +30,18 @@ export function DeleteBoardConfirmation({
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex w-full flex-col justify-between pb-4">
|
<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">
|
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||||
{t`Are you sure you want to delete this ${isTemplate ? "template" : "board"}?`}
|
Are you sure you want to delete this board?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||||
{t`This action can't be undone.`}
|
{"This action can't be undone."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||||
<Button onClick={() => closeModal()} variant="secondary">
|
<Button onClick={() => closeModal()} variant="secondary">
|
||||||
{t`Cancel`}
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
||||||
{t`Delete`}
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import type { NewCardInput } from "@kan/api/types";
|
import type { NewCardInput } from "@kan/api/types";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
import type { WorkspaceMember } from "~/components/Editor";
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
@@ -19,7 +18,6 @@ import Editor from "~/components/Editor";
|
|||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import Toggle from "~/components/Toggle";
|
import Toggle from "~/components/Toggle";
|
||||||
import { useModalFormState } from "~/hooks/useModalFormState";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -36,41 +34,32 @@ interface QueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface NewCardFormProps {
|
interface NewCardFormProps {
|
||||||
isTemplate: boolean;
|
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
listPublicId: string;
|
listPublicId: string;
|
||||||
queryParams: QueryParams;
|
queryParams: QueryParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewCardForm({
|
export function NewCardForm({
|
||||||
isTemplate,
|
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
listPublicId,
|
listPublicId,
|
||||||
queryParams,
|
queryParams,
|
||||||
}: NewCardFormProps) {
|
}: NewCardFormProps) {
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { closeModal, openModal, modalStates, clearModalState } = useModal();
|
const { closeModal, openModal } = useModal();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// persists the form values
|
|
||||||
const { formState, saveFormState } = useModalFormState<NewCardFormInput>({
|
|
||||||
modalType: "NEW_CARD",
|
|
||||||
initialValues: {
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
listPublicId,
|
|
||||||
labelPublicIds: [],
|
|
||||||
memberPublicIds: [],
|
|
||||||
isCreateAnotherEnabled: false,
|
|
||||||
position: "start",
|
|
||||||
},
|
|
||||||
resetOnClose: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { register, handleSubmit, reset, setValue, watch } =
|
const { register, handleSubmit, reset, setValue, watch } =
|
||||||
useForm<NewCardFormInput>({
|
useForm<NewCardFormInput>({
|
||||||
values: formState,
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
listPublicId,
|
||||||
|
labelPublicIds: [],
|
||||||
|
memberPublicIds: [],
|
||||||
|
isCreateAnotherEnabled: false,
|
||||||
|
position: "start",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const labelPublicIds = watch("labelPublicIds") || [];
|
const labelPublicIds = watch("labelPublicIds") || [];
|
||||||
@@ -78,48 +67,11 @@ export function NewCardForm({
|
|||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
const position = watch("position");
|
const position = watch("position");
|
||||||
const title = watch("title");
|
const title = watch("title");
|
||||||
const description = watch("description");
|
|
||||||
|
|
||||||
// saving form state whenever form values change
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = watch((data) => {
|
|
||||||
saveFormState(data as NewCardFormInput);
|
|
||||||
});
|
|
||||||
return () => subscription.unsubscribe();
|
|
||||||
}, [watch, saveFormState]);
|
|
||||||
|
|
||||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||||
enabled: !!boardPublicId,
|
enabled: !!boardPublicId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// this adds the new created label to selected labels
|
|
||||||
useEffect(() => {
|
|
||||||
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
|
||||||
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
|
||||||
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
|
||||||
}
|
|
||||||
}, [modalStates, labelPublicIds]);
|
|
||||||
|
|
||||||
// this removes the deleted label from selected labels if it is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (boardData?.labels) {
|
|
||||||
const availableLabelIds = boardData.labels.map((label) => label.publicId);
|
|
||||||
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
|
||||||
|
|
||||||
if (newLabelId && availableLabelIds.includes(newLabelId)) {
|
|
||||||
clearModalState("NEW_LABEL_CREATED");
|
|
||||||
}
|
|
||||||
|
|
||||||
const validLabelIds = labelPublicIds.filter(
|
|
||||||
(id) => availableLabelIds.includes(id) || id === newLabelId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validLabelIds.length !== labelPublicIds.length) {
|
|
||||||
setValue("labelPublicIds", validLabelIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [boardData?.labels, labelPublicIds, modalStates.NEW_LABEL_CREATED]);
|
|
||||||
|
|
||||||
const createCard = api.card.create.useMutation({
|
const createCard = api.card.create.useMutation({
|
||||||
onMutate: async (args) => {
|
onMutate: async (args) => {
|
||||||
await utils.board.byId.cancel();
|
await utils.board.byId.cancel();
|
||||||
@@ -179,24 +131,17 @@ export function NewCardForm({
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
if (!isCreateAnotherEnabled) {
|
if (!isCreateAnotherEnabled) closeModal();
|
||||||
// close modal (state will auto-clear due to resetOnClose: true)
|
|
||||||
closeModal();
|
|
||||||
} else {
|
|
||||||
// reset form for creating another card
|
|
||||||
const newFormState = {
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
listPublicId: watch("listPublicId"),
|
|
||||||
labelPublicIds: [],
|
|
||||||
memberPublicIds: [],
|
|
||||||
isCreateAnotherEnabled,
|
|
||||||
position,
|
|
||||||
};
|
|
||||||
reset(newFormState);
|
|
||||||
saveFormState(newFormState);
|
|
||||||
}
|
|
||||||
await utils.board.byId.invalidate(queryParams);
|
await utils.board.byId.invalidate(queryParams);
|
||||||
|
reset({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
listPublicId: watch("listPublicId"),
|
||||||
|
labelPublicIds: [],
|
||||||
|
memberPublicIds: [],
|
||||||
|
isCreateAnotherEnabled,
|
||||||
|
position,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,26 +264,8 @@ export function NewCardForm({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<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">
|
<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
|
<Editor
|
||||||
content={description}
|
content=""
|
||||||
onChange={(value) => {
|
onChange={(value) => setValue("description", value)}
|
||||||
setValue("description", value);
|
|
||||||
saveFormState({ ...formState, description: value });
|
|
||||||
}}
|
|
||||||
workspaceMembers={
|
|
||||||
boardData?.workspace.members?.map(
|
|
||||||
(member): WorkspaceMember => ({
|
|
||||||
publicId: member.publicId,
|
|
||||||
email: member.email,
|
|
||||||
user: member.user
|
|
||||||
? {
|
|
||||||
id: member.publicId,
|
|
||||||
name: member.user.name,
|
|
||||||
image: member.user.image ?? null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}),
|
|
||||||
) ?? []
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,46 +280,42 @@ export function NewCardForm({
|
|||||||
</div>
|
</div>
|
||||||
</CheckboxDropdown>
|
</CheckboxDropdown>
|
||||||
</div>
|
</div>
|
||||||
{!isTemplate && (
|
<div className="w-fit">
|
||||||
<div className="w-fit">
|
<CheckboxDropdown
|
||||||
<CheckboxDropdown
|
items={formattedMembers}
|
||||||
items={formattedMembers}
|
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
|
||||||
handleSelect={(_groupKey, item) =>
|
>
|
||||||
handleSelectMembers(item.key)
|
<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 ? (
|
||||||
>
|
t`Members`
|
||||||
<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 ? (
|
<div className="flex -space-x-1 overflow-hidden">
|
||||||
t`Members`
|
{memberPublicIds.map((memberPublicId) => {
|
||||||
) : (
|
const member = formattedMembers.find(
|
||||||
<div className="flex -space-x-1 overflow-hidden">
|
(member) => member.key === memberPublicId,
|
||||||
{memberPublicIds.map((memberPublicId) => {
|
);
|
||||||
const member = formattedMembers.find(
|
|
||||||
(member) => member.key === memberPublicId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={member?.key}
|
key={member?.key}
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
|
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
|
||||||
>
|
>
|
||||||
<span className="text-[8px] font-medium leading-none text-white">
|
<span className="text-[8px] font-medium leading-none text-white">
|
||||||
{member?.value
|
{member?.value
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((namePart) =>
|
.map((namePart) =>
|
||||||
namePart.charAt(0).toUpperCase(),
|
namePart.charAt(0).toUpperCase(),
|
||||||
)
|
)
|
||||||
.join("")}
|
.join("")}
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
</span>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</CheckboxDropdown>
|
</div>
|
||||||
</div>
|
</CheckboxDropdown>
|
||||||
)}
|
</div>
|
||||||
<div className="w-fit">
|
<div className="w-fit">
|
||||||
<CheckboxDropdown
|
<CheckboxDropdown
|
||||||
items={formattedLabels}
|
items={formattedLabels}
|
||||||
|
|||||||
@@ -145,12 +145,7 @@ export function NewListForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button type="submit">{t`Create list`}</Button>
|
||||||
type="submit"
|
|
||||||
disabled={createList.isPending || !watch("name")}
|
|
||||||
>
|
|
||||||
{t`Create list`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { HiXMark } from "react-icons/hi2";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Input from "~/components/Input";
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { usePopup } from "~/providers/popup";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: t`Template name is required` })
|
|
||||||
.max(100, { message: t`Template name cannot exceed 100 characters` }),
|
|
||||||
workspacePublicId: z.string(),
|
|
||||||
sourceBoardPublicId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface NewBoardInputWithTemplate {
|
|
||||||
name: string;
|
|
||||||
workspacePublicId: string;
|
|
||||||
sourceBoardPublicId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewTemplateForm({
|
|
||||||
sourceBoardPublicId,
|
|
||||||
workspacePublicId,
|
|
||||||
sourceBoardName,
|
|
||||||
}: {
|
|
||||||
sourceBoardPublicId: string;
|
|
||||||
workspacePublicId: string;
|
|
||||||
sourceBoardName: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { closeModal } = useModal();
|
|
||||||
const { showPopup } = usePopup();
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<NewBoardInputWithTemplate>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
name: sourceBoardName,
|
|
||||||
workspacePublicId,
|
|
||||||
sourceBoardPublicId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createBoard = api.board.create.useMutation({
|
|
||||||
onSuccess: (newTemplate) => {
|
|
||||||
if (!newTemplate) {
|
|
||||||
showPopup({
|
|
||||||
header: t`Unable to create template`,
|
|
||||||
message: t`Please try again later, or contact customer support.`,
|
|
||||||
icon: "error",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
router.push(`/templates/${newTemplate.publicId}`);
|
|
||||||
showPopup({
|
|
||||||
header: t`Template created`,
|
|
||||||
message: t`Template created successfully`,
|
|
||||||
icon: "success",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
showPopup({
|
|
||||||
header: t`Unable to create template`,
|
|
||||||
message: t`Please try again later, or contact customer support.`,
|
|
||||||
icon: "error",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
|
||||||
createBoard.mutate({
|
|
||||||
name: data.name,
|
|
||||||
workspacePublicId: data.workspacePublicId,
|
|
||||||
sourceBoardPublicId: data.sourceBoardPublicId,
|
|
||||||
lists: [],
|
|
||||||
labels: [],
|
|
||||||
type: "template",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const titleElement: HTMLElement | null =
|
|
||||||
document.querySelector<HTMLElement>("#name");
|
|
||||||
if (titleElement) titleElement.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">{t`New template`}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
closeModal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
placeholder={t`Name`}
|
|
||||||
{...register("name", { required: true })}
|
|
||||||
errorMessage={errors.name?.message}
|
|
||||||
onKeyDown={async (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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={createBoard.isPending}>
|
|
||||||
{t`Create template`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { HiLink } from "react-icons/hi";
|
|
||||||
|
|
||||||
const UpdateBoardSlugButton = ({
|
|
||||||
handleOnClick,
|
|
||||||
workspaceSlug,
|
|
||||||
boardSlug,
|
|
||||||
isLoading,
|
|
||||||
}: {
|
|
||||||
handleOnClick: () => void;
|
|
||||||
workspaceSlug: string;
|
|
||||||
boardSlug: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
}) => {
|
|
||||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="hidden h-[36px] w-[225px] animate-pulse rounded-full bg-light-200 dark:bg-dark-100 xl:flex" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleOnClick}
|
|
||||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span>
|
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
|
||||||
? "kan.bn"
|
|
||||||
: env("NEXT_PUBLIC_BASE_URL")}
|
|
||||||
</span>
|
|
||||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
|
||||||
<span>{workspaceSlug}</span>
|
|
||||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
|
||||||
<span>{boardSlug}</span>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
|
||||||
>
|
|
||||||
<HiLink className="h-[13px] w-[13px]" />
|
|
||||||
</Link>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UpdateBoardSlugButton;
|
|
||||||
@@ -135,7 +135,7 @@ export function UpdateBoardSlugForm({
|
|||||||
? t`This board URL has already been taken`
|
? t`This board URL has already been taken`
|
||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
prefix={`${env("NEXT_PUBLIC_KAN_ENV") === "cloud" ? "kan.bn" : env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/`}
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -152,14 +152,7 @@ export function UpdateBoardSlugForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
href="/settings?tab=workspace"
|
|
||||||
onClick={closeModal}
|
|
||||||
>
|
|
||||||
{t`Edit workspace URL`}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
isLoading={updateBoardSlug.isPending}
|
isLoading={updateBoardSlug.isPending}
|
||||||
@@ -167,8 +160,7 @@ export function UpdateBoardSlugForm({
|
|||||||
!isDirty ||
|
!isDirty ||
|
||||||
updateBoardSlug.isPending ||
|
updateBoardSlug.isPending ||
|
||||||
errors.slug?.message !== undefined ||
|
errors.slug?.message !== undefined ||
|
||||||
isBoardSlugAvailable?.isReserved ||
|
isBoardSlugAvailable?.isReserved
|
||||||
checkBoardSlugAvailability.isLoading
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t`Update`}
|
{t`Update`}
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import { keepPreviousData } from "@tanstack/react-query";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import { HiOutlinePlusSmall, HiOutlineSquare3Stack3D } from "react-icons/hi2";
|
||||||
HiOutlinePlusSmall,
|
|
||||||
HiOutlineRectangleStack,
|
|
||||||
HiOutlineSquare3Stack3D,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
|
|
||||||
import type { UpdateBoardInput } from "@kan/api/types";
|
import type { UpdateBoardInput } from "@kan/api/types";
|
||||||
|
|
||||||
@@ -36,29 +32,23 @@ import Filters from "./components/Filters";
|
|||||||
import List from "./components/List";
|
import List from "./components/List";
|
||||||
import { NewCardForm } from "./components/NewCardForm";
|
import { NewCardForm } from "./components/NewCardForm";
|
||||||
import { NewListForm } from "./components/NewListForm";
|
import { NewListForm } from "./components/NewListForm";
|
||||||
import { NewTemplateForm } from "./components/NewTemplateForm";
|
|
||||||
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
|
|
||||||
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||||
import VisibilityButton from "./components/VisibilityButton";
|
import VisibilityButton from "./components/VisibilityButton";
|
||||||
|
|
||||||
type PublicListId = string;
|
type PublicListId = string;
|
||||||
|
|
||||||
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
export default function BoardPage() {
|
||||||
const params = useParams() as { boardId: string | string[] } | null;
|
const params = useParams() as { boardId: string[] } | null;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { openModal, modalContentType, entityId, isOpen } = useModal();
|
const { openModal, modalContentType, entityId } = useModal();
|
||||||
const [selectedPublicListId, setSelectedPublicListId] =
|
const [selectedPublicListId, setSelectedPublicListId] =
|
||||||
useState<PublicListId>("");
|
useState<PublicListId>("");
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
|
||||||
const boardId = params?.boardId
|
const boardId = params?.boardId.length ? params.boardId[0] : null;
|
||||||
? Array.isArray(params.boardId)
|
|
||||||
? params.boardId[0]
|
|
||||||
: params.boardId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const updateBoard = api.board.update.useMutation();
|
const updateBoard = api.board.update.useMutation();
|
||||||
|
|
||||||
@@ -76,16 +66,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryParams: {
|
const queryParams = {
|
||||||
boardPublicId: string;
|
|
||||||
members: string[];
|
|
||||||
labels: string[];
|
|
||||||
type: "regular" | "template";
|
|
||||||
} = {
|
|
||||||
boardPublicId: boardId ?? "",
|
boardPublicId: boardId ?? "",
|
||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
type: isTemplate ? "template" : "regular",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -223,7 +207,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = ({
|
const onDragEnd = ({
|
||||||
source: _source,
|
source,
|
||||||
destination,
|
destination,
|
||||||
draggableId,
|
draggableId,
|
||||||
type,
|
type,
|
||||||
@@ -249,116 +233,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderModalContent = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
|
||||||
>
|
|
||||||
<DeleteBoardConfirmation
|
|
||||||
isTemplate={!!isTemplate}
|
|
||||||
boardPublicId={boardId ?? ""}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "DELETE_LIST"}
|
|
||||||
>
|
|
||||||
<DeleteListConfirmation
|
|
||||||
listPublicId={selectedPublicListId}
|
|
||||||
queryParams={queryParams}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="md"
|
|
||||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
|
||||||
>
|
|
||||||
<NewCardForm
|
|
||||||
isTemplate={!!isTemplate}
|
|
||||||
boardPublicId={boardId ?? ""}
|
|
||||||
listPublicId={selectedPublicListId}
|
|
||||||
queryParams={queryParams}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "NEW_LIST"}
|
|
||||||
>
|
|
||||||
<NewListForm
|
|
||||||
boardPublicId={boardId ?? ""}
|
|
||||||
queryParams={queryParams}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
|
||||||
>
|
|
||||||
<NewWorkspaceForm />
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "NEW_LABEL"}
|
|
||||||
>
|
|
||||||
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
|
|
||||||
>
|
|
||||||
<LabelForm
|
|
||||||
boardPublicId={boardId ?? ""}
|
|
||||||
refetch={refetchBoard}
|
|
||||||
isEdit
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
|
|
||||||
>
|
|
||||||
<DeleteLabelConfirmation
|
|
||||||
refetch={refetchBoard}
|
|
||||||
labelPublicId={entityId}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "UPDATE_BOARD_SLUG"}
|
|
||||||
>
|
|
||||||
<UpdateBoardSlugForm
|
|
||||||
boardPublicId={boardId ?? ""}
|
|
||||||
workspaceSlug={workspace.slug ?? ""}
|
|
||||||
boardSlug={boardData?.slug ?? ""}
|
|
||||||
queryParams={queryParams}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
|
|
||||||
>
|
|
||||||
<NewTemplateForm
|
|
||||||
workspacePublicId={workspace.publicId ?? ""}
|
|
||||||
sourceBoardPublicId={boardId ?? ""}
|
|
||||||
sourceBoardName={boardData?.name ?? ""}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title={`${(boardData?.name ?? isTemplate) ? t`Board` : t`Template`} | ${workspace.name}`}
|
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
@@ -384,46 +262,25 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
{!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">
|
<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`${isTemplate ? "Template" : "Board"} not found`}
|
{t`Board not found`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
||||||
{isTemplate && (
|
<VisibilityButton
|
||||||
<div className="inline-flex cursor-default items-center justify-center whitespace-nowrap rounded-md border-[1px] border-light-300 bg-light-50 px-3 py-2 text-sm font-semibold text-light-950 shadow-sm dark:border-dark-300 dark:bg-dark-50 dark:text-dark-950">
|
visibility={boardData?.visibility ?? "private"}
|
||||||
<span className="mr-2">
|
boardPublicId={boardId ?? ""}
|
||||||
<HiOutlineRectangleStack />
|
boardSlug={boardData?.slug ?? ""}
|
||||||
</span>
|
queryParams={queryParams}
|
||||||
{t`Template`}
|
isLoading={!boardData}
|
||||||
</div>
|
isAdmin={workspace.role === "admin"}
|
||||||
)}
|
/>
|
||||||
{!isTemplate && (
|
<Filters
|
||||||
<>
|
labels={boardData?.labels ?? []}
|
||||||
<UpdateBoardSlugButton
|
members={boardData?.workspace.members ?? []}
|
||||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
position="left"
|
||||||
isLoading={isLoading}
|
isLoading={!boardData}
|
||||||
workspaceSlug={workspace.slug ?? ""}
|
/>
|
||||||
boardSlug={boardData?.slug ?? ""}
|
|
||||||
/>
|
|
||||||
<VisibilityButton
|
|
||||||
visibility={boardData?.visibility ?? "private"}
|
|
||||||
boardPublicId={boardId ?? ""}
|
|
||||||
boardSlug={boardData?.slug ?? ""}
|
|
||||||
queryParams={queryParams}
|
|
||||||
isLoading={!boardData}
|
|
||||||
isAdmin={workspace.role === "admin"}
|
|
||||||
/>
|
|
||||||
{boardData && (
|
|
||||||
<Filters
|
|
||||||
labels={boardData.labels}
|
|
||||||
members={boardData.workspace.members.filter(
|
|
||||||
(member) => member.user !== null,
|
|
||||||
)}
|
|
||||||
position="left"
|
|
||||||
isLoading={!boardData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall
|
<HiOutlinePlusSmall
|
||||||
@@ -438,12 +295,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
>
|
>
|
||||||
{t`New list`}
|
{t`New list`}
|
||||||
</Button>
|
</Button>
|
||||||
<BoardDropdown
|
<BoardDropdown isLoading={!boardData} />
|
||||||
isTemplate={!!isTemplate}
|
|
||||||
isLoading={!boardData}
|
|
||||||
boardPublicId={boardId ?? ""}
|
|
||||||
workspacePublicId={workspace.publicId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -506,7 +358,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<div
|
<div
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-225px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark: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) => (
|
{list.cards.map((card, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
@@ -525,11 +377,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
key={card.publicId}
|
key={card.publicId}
|
||||||
href={
|
href={`/cards/${card.publicId}`}
|
||||||
isTemplate
|
|
||||||
? `/templates/${boardId}/cards/${card.publicId}`
|
|
||||||
: `/cards/${card.publicId}`
|
|
||||||
}
|
|
||||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||||
card.publicId.startsWith(
|
card.publicId.startsWith(
|
||||||
"PLACEHOLDER",
|
"PLACEHOLDER",
|
||||||
@@ -545,7 +393,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
title={card.title}
|
title={card.title}
|
||||||
labels={card.labels}
|
labels={card.labels}
|
||||||
members={card.members}
|
members={card.members}
|
||||||
checklists={card.checklists ?? []}
|
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -567,7 +414,55 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{renderModalContent()}
|
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||||
|
{modalContentType === "DELETE_BOARD" && (
|
||||||
|
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||||
|
)}
|
||||||
|
{modalContentType === "DELETE_LIST" && (
|
||||||
|
<DeleteListConfirmation
|
||||||
|
listPublicId={selectedPublicListId}
|
||||||
|
queryParams={queryParams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modalContentType === "NEW_CARD" && (
|
||||||
|
<NewCardForm
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
listPublicId={selectedPublicListId}
|
||||||
|
queryParams={queryParams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modalContentType === "NEW_LIST" && (
|
||||||
|
<NewListForm
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
queryParams={queryParams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||||
|
{modalContentType === "NEW_LABEL" && (
|
||||||
|
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
|
||||||
|
)}
|
||||||
|
{modalContentType === "EDIT_LABEL" && (
|
||||||
|
<LabelForm
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
refetch={refetchBoard}
|
||||||
|
isEdit
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modalContentType === "DELETE_LABEL" && (
|
||||||
|
<DeleteLabelConfirmation
|
||||||
|
refetch={refetchBoard}
|
||||||
|
labelPublicId={entityId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modalContentType === "UPDATE_BOARD_SLUG" && (
|
||||||
|
<UpdateBoardSlugForm
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
workspaceSlug={workspace.slug ?? ""}
|
||||||
|
boardSlug={boardData?.slug ?? ""}
|
||||||
|
queryParams={queryParams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,21 +8,18 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
export function BoardsList() {
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data, isLoading } = api.board.all.useQuery(
|
const { data, isLoading } = api.board.all.useQuery(
|
||||||
{
|
{ workspacePublicId: workspace.publicId },
|
||||||
workspacePublicId: workspace.publicId,
|
|
||||||
type: isTemplate ? "template" : "regular",
|
|
||||||
},
|
|
||||||
{ enabled: workspace.publicId ? true : false },
|
{ enabled: workspace.publicId ? true : false },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
<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" />
|
<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" />
|
||||||
@@ -35,28 +32,25 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
<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">
|
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||||
{t`No ${isTemplate ? "templates" : "boards"}`}
|
{t`No boards`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
{t`Get started by creating a new board`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
{t`Create new board`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
<div className="grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-7">
|
||||||
{data?.map((board) => (
|
{data?.map((board) => (
|
||||||
<Link
|
<Link key={board.publicId} href={`boards/${board.publicId}`}>
|
||||||
key={board.publicId}
|
|
||||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
|
||||||
>
|
|
||||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
<p className="text-md px-4 font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
{board.name}
|
{board.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||