Compare commits
1 Commits
fix/openap
...
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,18 +16,13 @@ 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_ATTACHMENTS_BUCKET_NAME=
|
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
|
|
||||||
# Auth config (optional)
|
# Auth config (optional)
|
||||||
|
|||||||
1
.github/funding.yml
vendored
@@ -1 +0,0 @@
|
|||||||
github: [kanbn]
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
67
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,42 +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_ATTATCHMENTS_BUCKET_NAME` | S3 bucket name for attatchments | For file uploads | `attatchments` |
|
|
||||||
| `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", "pl"]
|
"targets": ["fr", "de", "es", "it", "nl"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"po": {
|
||||||
|
|||||||
@@ -3,78 +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
|
||||||
14%20day%20free%20trial/singular: b2f7a80b5184d79ddd56b78d57d28225
|
|
||||||
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
|
|
||||||
Attachment%20uploaded/singular: c0233f8c2560f95ac0834f35eb07b9e7
|
|
||||||
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
|
||||||
@@ -82,34 +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/singular: bba0beaced7ea954ceb980f2b022ffee
|
|
||||||
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%20out%20some%20of%20our%20favorite%20open%20source%20projects./singular: b0d0be4f63c16552b3aa795af29240d1
|
|
||||||
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
|
||||||
@@ -119,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
|
||||||
@@ -172,73 +116,51 @@ checksums:
|
|||||||
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||||
Download%20failed/singular: 142b391cca3e05f2af9617092f7358c5
|
|
||||||
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
|
||||||
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
|
||||||
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
|
||||||
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
|
||||||
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
|
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
|
||||||
Filter/singular: 626325a05e4c8800f7ede7012b0cadaf
|
Filter/singular: 626325a05e4c8800f7ede7012b0cadaf
|
||||||
Find%20answers%20to%20common%20questions%20about%20Kan.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: b4c05c9174637f4da2a41ead0d65bc01
|
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%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: 9fa1c942feb40d6350322b71561df789
|
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
|
||||||
@@ -255,42 +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
|
|
||||||
Learn%20more/singular: e598091d132f890c37a6d4ed94f6d794
|
|
||||||
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
||||||
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
||||||
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
||||||
@@ -298,85 +201,55 @@ checksums:
|
|||||||
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
|
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
|
||||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
|
||||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||||
Loved%20by%20teams%20worldwide/singular: 41de5089fef56bb0bfcf7f8649a0cb93
|
|
||||||
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%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
|
|
||||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
|
||||||
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
|
|
||||||
Open%20Source%20Friends/singular: ffe3d75e5d03b8469c9dcb019dceb164
|
|
||||||
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
|
||||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
|
||||||
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
|
||||||
@@ -390,40 +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
|
|
||||||
See%20what%20our%20users%20are%20saying%20about%20Kan/singular: e3bf6f95419169f460fb6918a0afc512
|
|
||||||
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
|
||||||
@@ -433,72 +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
|
||||||
Start%2014%20day%20free%20trial/singular: 4be2811a8f3455618032ccb59e40658b
|
|
||||||
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
|
||||||
Testimonials/singular: e958aa3d3743031e2cbfbfd8f1e381f6
|
|
||||||
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
|
||||||
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
|
||||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
|
||||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
Unable%20to%20add%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%20attachment/singular: 99d0b17b78d44e15f13d876d155f0efc
|
|
||||||
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
|
||||||
@@ -506,42 +340,32 @@ 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%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
|
||||||
Upgrade%20to%20Pro%20(%2429%2Fmonth)/singular: 9a91ea70f5eb55aac96640bf0fd7feff
|
|
||||||
Upload%20failed/singular: 5b7b755f05330164dbfb00096c500ee3
|
|
||||||
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
|
||||||
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
|
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
|
||||||
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%20the%20years%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: 2c0790e6654e5460453ad2e523bbd377
|
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
|
||||||
@@ -549,30 +373,22 @@ checksums:
|
|||||||
Workspace%20description%20updated/singular: 19ddb6ee9ead26982a90dee426f3ee1f
|
Workspace%20description%20updated/singular: 19ddb6ee9ead26982a90dee426f3ee1f
|
||||||
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
||||||
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
||||||
Workspace%20name%20cannot%20exceed%2064%20characters/singular: 681f86c9ca31699fc5a3ca3790e5972c
|
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%20file%20has%20been%20uploaded%20successfully./singular: 19d84e934877efc0fef285ff3d6e7849
|
|
||||||
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", "pl"],
|
locales: ["en", "fr", "de", "es", "it", "nl"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,83 +30,25 @@ const config = {
|
|||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: (() => {
|
remotePatterns: [
|
||||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
{
|
||||||
const patterns = [
|
protocol: "https",
|
||||||
{
|
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||||
protocol: "https",
|
},
|
||||||
hostname:
|
{
|
||||||
env("S3_FORCE_PATH_STYLE") === "true"
|
protocol: "http",
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
hostname: "localhost",
|
||||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
},
|
||||||
},
|
{
|
||||||
{
|
protocol: "https",
|
||||||
protocol: "http",
|
hostname: "*.googleusercontent.com",
|
||||||
hostname: "localhost",
|
},
|
||||||
},
|
],
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "*.googleusercontent.com",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
|
||||||
const s3Endpoint = env("S3_ENDPOINT");
|
|
||||||
if (s3Endpoint) {
|
|
||||||
try {
|
|
||||||
const url = new URL(s3Endpoint);
|
|
||||||
const hostname = url.hostname;
|
|
||||||
const protocol = url.protocol.replace(":", "");
|
|
||||||
|
|
||||||
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
|
||||||
const parts = hostname.split(".");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
const rootDomain = parts.slice(-2).join(".");
|
|
||||||
patterns.push({
|
|
||||||
protocol: protocol === "http" ? "http" : "https",
|
|
||||||
hostname: `*.${rootDomain}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If S3_ENDPOINT is not a valid URL, ignore it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return patterns;
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
webpack(config) {
|
|
||||||
config.module.rules.push({
|
|
||||||
test: /\.svg$/,
|
|
||||||
use: ["@svgr/webpack"],
|
|
||||||
});
|
|
||||||
return config;
|
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
// 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,13 +57,11 @@
|
|||||||
"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",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8",
|
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -77,7 +73,6 @@
|
|||||||
"@lingui/cli": "^5.3.2",
|
"@lingui/cli": "^5.3.2",
|
||||||
"@lingui/loader": "^5.3.2",
|
"@lingui/loader": "^5.3.2",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.5.2",
|
||||||
"@svgr/webpack": "^8.1.0",
|
|
||||||
"@types/node": "^20.17.7",
|
"@types/node": "^20.17.7",
|
||||||
"@types/react": "catalog:react18",
|
"@types/react": "catalog:react18",
|
||||||
"@types/react-beautiful-dnd": "^13.1.7",
|
"@types/react-beautiful-dnd": "^13.1.7",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 433 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><path fill="none" d="M0 0h192.756v192.756H0z"/><path fill="#313f94" d="M33.272 96.299c-.159-7.649-6.374-13.864-14.023-13.864S5.544 88.809 5.544 96.458h-2.71c0-8.446 6.693-15.298 15.139-15.458 8.446 0 15.458 6.853 15.458 15.299z" style="fill:currentColor"/><path fill="#313f94" d="M33.113 96.458c0-6.215-4.94-11.155-11.155-11.155s-10.677 4.94-10.677 11.155H8.253c0-7.012 5.737-12.589 12.749-12.749 6.853 0 12.43 6.374 12.589 12.589z" style="fill:currentColor"/><path fill="#313f94" d="M33.272 96.299c0-4.462-3.824-8.127-8.446-8.127s-7.968 3.665-7.968 8.287H13.99c0-5.418 4.144-9.88 9.562-9.88s9.721 4.462 9.88 9.721h-.16z" style="fill:currentColor"/><path fill="#313f94" d="M3.153 96.458c0 7.649 6.215 13.864 13.864 13.864 7.809 0 13.864-6.375 13.864-14.023h2.709c0 8.445-6.853 15.457-15.298 15.457S2.994 104.904 2.834 96.458z" style="fill:currentColor"/><path fill="#313f94" d="M3.153 96.299c0 6.215 4.94 11.154 11.155 11.154s10.836-4.939 10.677-11.154h3.028c0 7.012-5.737 12.748-12.589 12.748-7.012 0-12.589-6.373-12.589-12.589z" style="fill:currentColor"/><path fill="#313f94" d="M2.994 96.458c.159 4.462 3.984 8.128 8.446 8.128 4.622 0 7.968-3.666 7.968-8.287h2.868c0 5.418-4.143 9.881-9.562 9.881-5.417 0-9.72-4.303-9.72-9.722m54.341 3.507h-5.1l4.144-8.925zm-8.924 8.285 1.434-3.188h7.968l.479 3.188h7.968l-4.462-24.7H53.67l-13.227 24.7zm21.513 0 4.303-24.7h8.127l-4.302 24.7zm24.064-18.645h3.985c1.273 0 2.07.16 2.549.638.318.318.479.956.318 1.593-.158.797-.479 1.435-1.115 1.753-.479.479-1.434.638-2.709.638h-3.825zM90.8 108.25l1.435-8.605h3.187c.956 0 1.754.16 2.23.32.479.318.797.637.957 1.115q.237.716 0 1.912l-.16 1.912c-.318 1.594-.318 2.709-.318 3.346h8.287v-.797c-.16 0-.479 0-.479-.158-.158-.16-.158-.479 0-1.115l.639-3.826c.158-1.752.158-3.027-.32-3.664-.477-.797-1.434-1.436-2.867-2.072.797-.159 1.434-.479 2.07-.796.797-.638 1.436-1.275 1.912-2.072.639-.957.957-1.913 1.275-3.188q.24-1.434 0-2.39a5.3 5.3 0 0 0-.797-1.912 7.5 7.5 0 0 0-1.594-1.594c-.797-.319-1.594-.638-2.709-.797s-2.548-.319-4.302-.319H87.294l-4.303 24.7zm33.147-19.123c1.115 0 1.912.159 2.23.159.479.16.797.479.957.797.318.319.318.797.318 1.275-.16.638-.479 1.116-1.115 1.594-.479.319-1.436.637-2.709.637H120.6l.797-4.462zm-1.275 19.123c1.912 0 3.188-.158 3.984-.318s1.752-.479 2.549-.797c.957-.479 1.594-.797 2.072-1.275.797-.477 1.275-1.273 1.754-2.07a7.2 7.2 0 0 0 1.115-2.869c.158-1.434 0-2.709-.479-3.666-.637-1.115-1.434-1.752-2.709-2.071 2.391-1.434 3.824-3.187 4.143-5.259.479-2.231-.158-3.825-1.912-4.94-1.275-.957-3.346-1.435-6.533-1.435h-12.111l-4.303 24.7zm.478-10.357c1.594 0 2.549.318 3.027.637.479.479.637.957.479 1.754-.16.637-.479 1.273-1.115 1.752-.639.318-1.594.639-3.188.639h-3.346l.797-4.781zm18.645-14.343h7.969l-2.709 15.298c-.16.957-.16 1.754 0 2.232.158.477.477.955 1.115 1.273.637.32 1.275.479 2.07.479 1.117 0 2.232-.318 2.869-.955.797-.639 1.275-1.754 1.594-3.029l2.709-15.298h7.969l-2.551 14.501c-.318 1.754-.637 3.188-1.115 4.303-.479.957-1.115 1.912-1.912 2.709s-1.754 1.594-2.709 2.072c-1.115.637-2.23 1.115-3.506 1.434-1.434.16-2.869.32-4.303.32-1.754 0-3.188-.16-4.463-.32-1.273-.318-2.23-.797-3.027-1.434-.955-.637-1.594-1.275-2.072-2.072-.477-.797-.797-1.912-.797-3.186-.158-.797 0-2.232.32-3.984zm48.127 7.171h-7.809c0-.638-.318-1.275-.639-1.594-.477-.319-1.115-.478-2.07-.478-.957 0-1.754.159-2.23.478-.479.319-.639.638-.797 1.116 0 .478 0 .637.318.956s1.115.637 2.549.956l3.027.797c2.709.797 4.623 1.753 5.578 3.028.957 1.274 1.275 2.709.957 4.461-.32 1.436-.797 2.551-1.594 3.666s-1.754 2.072-2.709 2.709c-.957.637-2.232 1.115-3.666 1.594-1.434.318-3.027.479-4.621.479s-3.188-.16-4.621-.639q-2.153-.717-3.348-1.912c-1.195-1.195-1.434-1.752-1.752-2.867-.16-.797-.16-1.754-.16-2.869h7.809c0 .797.318 1.434.797 1.752.479.479 1.436.639 2.709.639q1.673 0 2.391-.479c.718-.479.639-.637.797-1.115 0-.479 0-.957-.318-1.275s-1.115-.637-2.551-.955l-2.709-.639c-1.273-.318-2.709-.797-3.824-1.434-1.115-.638-2.07-1.435-2.549-2.55-.479-.956-.479-2.071-.318-3.506.318-1.434.797-2.549 1.592-3.665.797-1.115 1.754-1.912 2.869-2.55.957-.797 2.23-1.115 3.506-1.434s2.551-.478 3.824-.478c1.594 0 2.869.159 4.145.478 1.273.319 2.07.797 2.867 1.275.639.478 1.115 1.115 1.436 1.753.477.637.637 1.275.797 1.912.157.637.317 1.434.317 2.39" style="fill:currentColor"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 2000 456.5"><path d="M380.5 268.5c0 13.6-8 26-23.6 28.4-26.8 4.8-82.8 7.6-130 7.6s-103.2-2.8-130-7.6c-15.6-2.8-23.2-15.2-23.2-28.4v-89.2c0-13.6 10.4-26.4 23.2-28.4 8-1.6 26.4-2.8 40.8-2.8 5.6 0 9.6 4.4 10 10.8v62c27.6 0 51.6-1.6 79.6-1.6s51.6 1.6 79.6 1.6v-62c0-6.4 4.4-10.8 9.6-10.8 14.4 0 32.8 1.2 40.8 2.8 12.8 2.4 23.6 15.2 23.6 28.4zM226.4 0C101.6 0 0 102.4 0 228.4s101.6 228 226.4 228 226.4-102.4 226.4-228S352.1 0 226.4 0" class="st0" style="fill:currentColor"/><path d="M543.6 309.9c-70.4 0-105.6-51.2-105.6-108 0-56.4 36.8-107.2 106.4-107.2 26.8 0 46 5.6 61.6 16.4l-20 33.2c-11.2-7.2-24-12-42.4-12-38 0-58 30.4-58 68.4 0 38.8 19.2 71.6 58.8 71.6 22 0 35.6-7.6 46.4-16l18.4 32c-10.8 9.6-35.6 21.6-65.6 21.6M694 309.9c-52.4 0-75.6-40-75.6-79.6s22.8-80.4 75.2-80.4 76.4 39.6 76.4 79.2c0 38.8-23.2 80.8-76 80.8m-1.2-128c-22.8 0-29.6 19.2-29.6 47.2s8.8 48.4 31.2 48.4c22.8 0 30.4-18.8 30.4-47.2s-8.8-48.4-32-48.4M836.9 153.9v97.6c0 15.2 5.2 22.4 19.6 22.4 15.2 0 26.8-14.8 29.6-18.4V153.9h42.4v107.6c0 20 2.4 35.6 4.4 44.8h-41.6c-1.2-4.4-2.8-14-3.2-20-8 10.4-23.6 24-48.4 24-33.2 0-45.2-21.6-45.2-49.6V153.9zM1032.5 309.9c-48.8 0-78.4-32-78.4-80 0-51.2 33.6-80.4 80-80.4 24.4 0 38.8 7.2 46 12l-13.6 29.2c-6.4-4.4-16.4-8.8-31.2-8.8-23.6 0-36.4 18.4-36.4 46.4s12 48 37.2 48c16.8 0 27.6-6 32-8.8l12.8 28.4c-7.2 4.8-20.8 14-48.4 14M1194.5 306.3v-97.6c0-15.2-5.2-22.4-19.2-22.4-15.6 0-27.2 14.4-30 18.4v102h-42.4V81.1h42.4v90.8c7.6-8.4 22-22 46.8-22 33.2 0 45.2 21.2 45.2 49.6v106.4zM1314.1 81.1v84.4c6.8-6.8 20-16 36.8-16 37.6 0 63.6 26 63.6 78.8 0 52.4-34.8 81.2-80.4 81.2-34.4 0-55.2-8.4-62.4-12.4v-216zm0 192c2.8.8 9.2 3.2 20 3.2 22 0 35.2-16.4 35.2-47.6 0-28-9.2-44.8-30.8-44.8-12.8 0-22 8-24.8 11.2zM1519.8 306.3c-1.2-4-2.4-11.6-2.8-16.4-6.4 8-20.8 20-42 20-25.6 0-45.6-15.6-45.6-42.8 0-39.6 40-54 80.4-54h5.2v-8.8c0-12.8-5.2-20.4-24-20.4-19.2 0-32.4 10-38.4 14.4l-18-26.4c9.2-8 28.8-22 61.2-22 41.6 0 61.2 16.4 61.2 56.8v53.6c0 20.8 2.4 36 4.4 46zm-4.8-67.2h-5.2c-23.2 0-39.2 6.8-39.2 24.4 0 10.8 8.4 15.6 17.6 15.6 14.4 0 22.4-8 26.8-12.8zM1633.8 309.9c-27.2 0-44.4-8-53.6-14.4l14.4-30c5.6 4 20.4 12.4 37.2 12.4 15.6 0 24.4-4.4 24.4-13.2 0-10-16.8-12.8-38.4-23.2-20.8-10-32.8-22-32.8-44.8 0-28.8 22.4-47.2 56.8-47.2 26 0 41.6 8 48.8 12l-14.8 29.2c-5.6-4-17.6-9.6-32-9.6s-20.4 5.2-20.4 12.8c0 10 14 12.4 30.8 19.2 23.6 9.6 40.4 20.8 40.4 46.8-.8 33.2-23.6 50-60.8 50M1792.2 277.9c20.4 0 31.6-6.4 38.8-10.4l13.6 27.6c-10 5.6-25.6 14.8-56.4 14.8-50.4 0-79.2-32-79.2-80.8 0-48.4 33.2-79.2 74.8-79.2 47.6 0 70.4 33.2 65.2 90.4h-94c1.6 22.8 12.8 37.6 37.2 37.6m16-66.8c-.4-16.8-6.8-30.4-25.2-30.4-16.8 0-26.4 10.8-28.8 30.4z" class="st1" style="fill:currentColor" transform="translate(150.22 27.826)"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="892.4" height="170.507" viewBox="-86.2 14.747 1092.4 170.507"><path d="M3054.827 974.386a4.32 4.32 0 1 1 8.637 0 4.32 4.32 0 0 1-8.637 0M2917.055 962.946q0-4.106-1.587-6.09-1.586-1.98-4.818-1.98h-2.294v16.732h1.755q3.592.001 5.267-2.135 1.676-2.134 1.677-6.527m8.18-.285q0 7.478-4.02 11.522-4.02 4.047-11.304 4.046h-9.436v-29.87h10.095q7.023 0 10.845 3.677 3.82 3.677 3.82 10.625M2951.373 978.229h7.509v-29.996h-7.509zM2969.43 967.04q0 2.87.749 4.374.749 1.505 2.515 1.505 1.748 0 2.469-1.505.719-1.505.718-4.374 0-2.853-.728-4.306-.731-1.457-2.498-1.458-1.727.001-2.476 1.447-.749 1.444-.749 4.317m14.094 0q.001 5.466-2.87 8.524-2.871 3.059-8.036 3.059-4.955 0-7.883-3.128-2.93-3.126-2.93-8.455 0-5.448 2.871-8.467t8.058-3.02q3.206 0 5.664 1.398t3.792 4.003q1.334 2.603 1.334 6.086M2986.506 978.23h7.51v-22.282h-7.51zM2986.506 953.253h7.51v-5.022h-7.51zM3009.23 972.546c1.015 0 2.222-.258 3.63-.769v5.606q-1.517.669-2.89.953-1.372.286-3.217.286-3.781 0-5.455-1.899-1.667-1.897-1.668-5.828v-9.185H2997v-5.76h2.63v-5.69l7.567-1.317v7.007h4.791v5.76h-4.791v8.672q0 2.164 2.034 2.164M3026.612 972.546c1.013 0 2.22-.258 3.63-.769v5.606q-1.518.669-2.89.953-1.374.286-3.218.286-3.782 0-5.452-1.899-1.671-1.897-1.672-5.828v-9.185h-2.63v-5.76h2.63v-5.777l7.567-1.23v7.007h4.793v5.76h-4.793v8.672q0 2.164 2.035 2.164M3039.717 964.326q.155-1.83 1.047-2.685.895-.855 2.217-.855 1.445-.001 2.295.965.857.962.893 2.575zm10.59-6.145q-2.678-2.626-7.593-2.626-5.166 0-7.952 3.018-2.784 3.019-2.785 8.663.001 5.467 3.007 8.426 3.005 2.96 8.44 2.96 2.61 0 4.493-.355a13 13 0 0 0 3.627-1.266l-1.154-5.021c-.851.347-1.661.615-2.426.79q-1.658.386-3.638.386-2.171 0-3.427-1.061-1.258-1.065-1.335-2.932h13.422v-3.42q0-4.938-2.679-7.562M2935.2 964.326q.155-1.83 1.047-2.685.894-.855 2.219-.855 1.44-.001 2.294.965.855.962.893 2.575zm10.592-6.145q-2.68-2.626-7.595-2.626-5.167 0-7.95 3.018-2.785 3.019-2.786 8.663 0 5.467 3.007 8.426 3.004 2.96 8.44 2.96 2.61 0 4.493-.355a13 13 0 0 0 3.627-1.266l-1.155-5.021c-.851.347-1.661.615-2.425.79q-1.66.386-3.639.386-2.17 0-3.427-1.061-1.258-1.065-1.335-2.932h13.422v-3.42q0-4.938-2.677-7.562" style="fill:currentColor" transform="translate(-15746.429 -5136.262)scale(5.4348)"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 295 42"><rect width="100%" height="100%" fill="none"/><path d="M259.5 5.5c0-2.9.9-3.9 2.4-3.9s2.7.6 4.1 3.9l8.5 19.3V37c0 2.9-1.2 3.7-4.5 4.1v.8h15.7v-.8c-3.4-.4-4.5-1.3-4.5-4.1V24.8l8.2-18.6c1.6-3.6 3.2-4.4 5.6-4.8V.6h-11.5v.8c3.9.4 5.3 1.5 4.2 4.1L280.5 22l-7.4-16.9c-1-2.3-.4-3 3.9-3.6V.7h-24.7v.8c3.9.6 5.1 1.3 5.1 4.1v22.6L239.3.7h-8.9v.8c2.9.4 3.8 1.3 3.8 4.1v31.5c0 2.9-.8 3.9-1.8 3.9-.9 0-1.4-.4-2.7-3.9L216.3.5h-.8l-12.9 35c-1.4 3.9-3.6 5.2-5.8 5.7v.8h11.8v-.8c-4.1-.4-5.4-2-4.5-4.6l2.9-8h12.2l2.8 7.5c1.2 3.4.5 4.4-3 5v.8h22.4v-.8c-3.9-.6-5.1-1.3-5.1-4.1V8.8L258.2 42h1.3zM101.6 21.2c0-12.8 6.1-19.5 13.2-19.5 4.2 0 6.9.9 8.5 2.6 1.2 1.2 2.1 2.9 3.1 5.6h.8V1.3C123.1.5 119.4 0 115.7 0 105 0 93.9 6.9 93.9 21.2S105 42.4 115 42.4c4.4 0 8.3-.7 12-1.8l1.2-8.1-.7-.2c-.9 1.7-2 3.2-3.2 4.5-2.6 2.6-6.1 4-9.6 4-6.8-.1-13.1-6.6-13.1-19.6m88.7 1.2c-.9 0-2.1-.1-3.3-.3v-20h3.3c5.5 0 7.9 2.9 7.9 10.1 0 7.3-2.5 10.2-7.9 10.2M61.8.6v9.9h.8c1.2-6.3 3.7-8.3 7.9-8.3h2V37c0 2.9-1.2 3.7-4.5 4.1v.8h15.7v-.8c-3.4-.4-4.5-1.3-4.5-4.1V2.1h2c4.2 0 6.7 2 7.9 8.3h.8V.5H61.8zM49.3 42.4c6.8 0 14-3.9 14-11.9 0-4.7-2.5-8.6-6.9-11l-7.8-4.3c-4.1-2.3-5.3-4.4-5.3-7.4 0-3.4 2.5-6.1 7.2-6.1 2.3 0 4 .6 5.4 1.9 1.2 1.2 2.4 3.2 3.5 6.8h.7V1.3C57.3.5 54.4 0 51 0c-6.7 0-11.7 4.2-11.7 11.3 0 4.5 2.5 8.6 6.6 11l7.2 4c4.1 2.3 5.4 4.5 5.4 7.8 0 3.9-4 6.7-8.4 6.7-2.9 0-5.3-1-7-2.7-1.1-1.1-2.3-2.8-3.1-5h4.7v-.8c-1.3-.3-2.1-.5-3.2-3.1L32.3 7.3h-.8l-9 21.4c-1.2 2.9-2 3.4-3.4 3.6v.8h8v-.8c-2.9-.3-3.7-1.7-3-3.5l1.4-3.4h8.1l1.8 4.2c.8 1.9-.4 2.4-3.3 2.6v.8h6.4v7.9c3.2.9 7.3 1.5 10.8 1.5m101.6-.4 11.9-33.1.9 28.2c.1 2.8-1 3.6-4.4 4v.8h32.8v-.8c-3.9-.6-5.1-1.3-5.1-4.1V23.7c1.3.2 2.6.3 3.6.3 7.9 0 14.7-2.9 14.7-11.7S198.5.6 190.6.6h-14.1v.8c2.9.4 3.8 1.3 3.8 4.1V37c0 2.9-1.2 4.1-4.5 4.1h-.1c-3.5 0-5-1.3-5.1-4l-1.2-31.5c-.1-2.9.9-3.8 4-4.2V.6h-9.7l-10.6 29.7L142.5.6h-10.4v.8c3.1.4 4.1 1.4 4 4.2L135 35.8c-.1 3-1.7 4.8-4.6 5.3v.8h11v-.8c-2.8-.4-4.4-2.2-4.3-5.3l.9-27.5 12.1 33.6zM119 33.3c5.3 0 12.1-4.7 12.1-12.1 0-7.3-6.7-12.1-12.1-12.1-5.3 0-12.1 4.7-12.1 12.1 0 7.3 6.7 12.1 12.1 12.1m0-22.5c3.4 0 5.7 2.8 5.7 10.4 0 7.7-2.4 10.4-5.7 10.4-3.4 0-5.7-2.8-5.7-10.4 0-7.7 2.3-10.4 5.7-10.4m99.7 16.1h-11.1l5.5-15.2zM33.1 23.8h-6.8l3.4-8.3zm-13 2.1h.8V13.5h-.8c-1.3 4.4-2.6 5.4-5 5.4h-4.6V2.1h7.3c4.2 0 6.7 2 7.9 8.3h.8V.5H0v.8c2.9.4 3.8 1.3 3.8 4.1v31.5c0 2.9-.9 3.7-3.8 4.1v.8h15.6V41c-3.9-.6-5.1-1.3-5.1-4.1V20.4h4.6c2.3.1 3.7 1.1 5 5.5" class="currentLayer" style="fill:currentColor"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="-5 0 24 14"><path d="M11.48 4.37c-.714 0-1.207.284-1.54.678a.9.9 0 0 0-.16-.238c-.273-.308-.712-.452-1.3-.452-.576 0-1.056.18-1.39.462-.172-.28-.585-.467-1.082-.467-.65 0-1.358.092-1.778.769-.01-.244-.032-.373-.169-.518-.166-.176-.436-.222-.698-.222-.478 0-.838.209-1.097.634C1.89 5.616.98 7.59 1 8.546c.014.618.428 1.063 1.17 1.08.579.015 1.034-.14 1.323-.432.26.282.655.447 1.128.447.528 0 1.059-.158 1.372-.504.264.324.68.513 1.214.51a2.19 2.19 0 0 0 1.713-.842c.22.598.777.836 1.386.836 1.335 0 1.92-.918 2.41-2.369.11-.327.256-.873.28-1.33.041-.764-.282-1.571-1.517-1.571M2.598 8.02c.69-.12.864.129.842.36-.065.692-.7.847-1.251.834-.4-.009-.76-.193-.77-.657-.017-.812.811-2.69 1.201-3.314.18-.295.4-.441.75-.441.331 0 .413.17.408.374-.013.544-.892 2.197-1.18 2.843m2.249-.639a7 7 0 0 0-.203.657c.227-.056.397-.096.688-.089.332.008.544.146.544.42 0 .666-.735.86-1.245.86-.56 0-1.052-.318-1.052-.93 0-.718.39-1.807.755-2.532.448-.89.906-1.008 1.695-1.008.347 0 .747.148.747.475 0 .454-.385.627-.766.65a5 5 0 0 1-.563.006s-.127.194-.262.538c.706-.1 1.005.06.886.47-.161.551-.639.587-1.224.483zM8.32 5.665c-.19 0-.316.12-.409.251-.204.288-.648 1.44-.717 1.912-.047.324.093.39.246.39.245 0 .52-.26.614-.688 0 0-.467-.01-.338-.425.125-.402.362-.489.756-.505.775-.032.698.54.636.846-.201.99-.907 1.787-1.904 1.787-.683 0-1.107-.378-1.107-1.075 0-.496.246-1.275.44-1.731.416-.97.852-1.65 1.962-1.65.666 0 1.191.24 1.114.864-.056.457-.286.726-.702.759-.116.009-.593-.003-.435-.454.056-.157.078-.281-.156-.281m4.192.819a6.7 6.7 0 0 1-.668 1.79c-.467.84-1.035.959-1.53.953s-1.055-.19-1.06-.954c-.002-.549.235-1.324.436-1.832.35-.92.708-1.671 1.84-1.658 1.32.016 1.085 1.19.982 1.7m-1.12-.8c-.113-.001-.212.02-.3.158-.2.258-.784 1.854-.774 2.172.004.114.067.214.208.214.161 0 .25-.111.33-.246.184-.311.7-1.721.715-2.074.004-.102-.013-.222-.179-.224" style="fill:currentColor"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-48.214 -18.217 387.865 116.248"><path d="M1.785 65.652h31.62V55.27H13.23V15.665H1.785zm47.629 0v-34.43H37.97v34.43zm-5.721-39.13c3.99 0 6.474-2.644 6.474-5.95-.074-3.378-2.484-5.947-6.398-5.947-3.915 0-6.475 2.57-6.475 5.947 0 3.306 2.484 5.95 6.324 5.95zm11.034 39.13h11.444V46.424c0-1.029.074-2.058.377-2.791.826-2.056 2.709-4.186 5.871-4.186 4.142 0 5.799 3.158 5.799 7.784v18.42H89.66V45.91c0-10.576-5.646-15.497-13.176-15.497-6.173 0-8.884 3.451-10.39 5.802h.077v-4.993H54.727c.151 3.231 0 34.43 0 34.43m51.078-49.987H94.361v49.987h11.444V54.489l2.86-3.601 8.96 14.764h14.078l-15.056-21.373 13.174-14.54h-13.776s-9.411 13.008-10.24 14.552zm56.501 35.625c.151-.884.377-2.58.377-4.498 0-8.9-4.518-17.936-16.413-17.936-12.724 0-18.597 10.063-18.597 19.19 0 11.288 7.153 18.337 19.65 18.337 4.97 0 9.561-.732 13.327-2.275l-1.506-7.558c-3.088 1.024-6.25 1.537-10.164 1.537-5.345 0-10.012-2.195-10.389-6.871l23.715.072zm-23.79-7.742c.301-2.938 2.26-7.273 7.153-7.273 5.194 0 6.4 4.628 6.4 7.273h-13.552zm52.414-27.883v17.304h-.151c-1.657-2.422-5.12-4.038-9.71-4.038-8.81 0-16.564 7.05-16.49 19.094 0 11.164 7.003 18.435 15.735 18.435 4.744 0 9.26-2.058 11.52-6.024h.225l.453 5.216h10.163c-.15-2.424-.302-6.61-.302-10.723V15.664h-11.444zm0 34.05c0 .88-.075 1.763-.227 2.495-.675 3.16-3.386 5.361-6.699 5.361-4.742 0-7.83-3.818-7.83-9.84 0-5.654 2.637-10.208 7.906-10.208 3.538 0 6.022 2.423 6.7 5.433.15.663.15 1.398.15 2.058v4.7z" style="fill:currentColor"/><mask id="a"><rect width="100%" height="100%" fill="#fff"/><path d="M236.85 65.61V31.18h-11.444v34.43h11.445zm-5.72-39.13c3.99 0 6.474-2.644 6.474-5.948-.075-3.379-2.484-5.949-6.398-5.949-3.917 0-6.475 2.57-6.475 5.949 0 3.304 2.483 5.948 6.324 5.948zm12.054 39.13h11.443V46.385c0-1.028.075-2.058.377-2.792.827-2.057 2.71-4.186 5.872-4.186 4.14 0 5.797 3.157 5.797 7.786V65.61h11.443V45.869c0-10.575-5.645-15.496-13.174-15.496-6.173 0-8.884 3.45-10.39 5.8h.076v-4.992h-11.443c.149 3.23-.001 34.43-.001 34.43z"/></mask><path d="M213.882 7.245c0-3.015 2.508-5.462 5.6-5.462h64.568c3.093 0 5.6 2.447 5.6 5.462V72.57c0 3.016-2.507 5.461-5.6 5.461h-64.568c-3.092 0-5.6-2.445-5.6-5.46V7.244z" mask="url(#a)" style="fill:currentColor"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="66" height="24" class="wordmark" viewBox="0 0 66 24"><path d="M0 17.198h3.045c.356 3.077 3.045 4.664 5.927 4.664 3.045 0 5.409-1.814 5.409-4.632 0-2.883-2.332-3.595-6.186-4.47C2.559 11.53.777 9.587.777 6.283.777 2.623 3.984 0 8.486 0c4.178 0 7.806 2.332 8.032 6.057h-3.012c-.227-2.462-2.429-3.919-5.15-3.919-2.947 0-4.761 1.879-4.761 3.951 0 2.462 2.073 3.206 6.121 4.049 5.441 1.134 7.514 3.271 7.514 6.931 0 4.243-3.498 6.931-8.356 6.931C4.081 24 .291 21.636 0 17.198M18.267 19.304c0-3.336 2.818-5.344 10.3-5.538v-.972c0-2.494-1.36-3.725-3.563-3.725-2.267 0-3.401 1.49-3.563 3.077h-2.785c.324-2.721 2.559-4.988 6.348-4.988 4.049 0 6.316 2.202 6.316 5.636v10.721h-2.623v-2.073C27.757 23.093 26.008 24 23.709 24c-2.915 0-5.442-1.717-5.442-4.696m10.3-1.458v-2.3c-5.538.162-7.547 1.458-7.547 3.66 0 1.684 1.36 2.883 3.271 2.883 2.43 0 4.276-1.652 4.276-4.243M33.684 7.676h2.656v2.332c.907-1.684 2.623-2.785 4.923-2.785 3.595 0 5.506 2.364 5.506 5.862v10.429h-2.85v-9.911c0-2.915-1.296-4.275-3.498-4.275s-3.887 1.879-3.887 4.308v9.879h-2.85zM48.389 19.304c0-3.336 2.818-5.344 10.3-5.538v-.972c0-2.494-1.36-3.725-3.563-3.725-2.267 0-3.401 1.49-3.563 3.077h-2.785c.324-2.721 2.559-4.988 6.348-4.988 4.049 0 6.316 2.202 6.316 5.636v10.721h-2.623v-2.073C57.879 23.093 56.13 24 53.83 24c-2.915 0-5.441-1.717-5.441-4.696m10.299-1.458v-2.3c-5.538.162-7.547 1.458-7.547 3.66 0 1.684 1.36 2.883 3.271 2.883 2.43 0 4.276-1.652 4.276-4.243M61.043 1.018h-1.017v3.014h-.602V1.018h-1.017v-.5h2.636zM63.251 3.263l.873-2.745h.917V4.03h-.596V1.137l-.908 2.893h-.581l-.928-2.889V4.03h-.587V.518h.933z" style="fill:currentColor"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-50 -20 288.624 78.268"><g data-name="Calque 2"><g data-name="Groupe 1909" opacity=".35"><path fill="#0540ff" d="M68.234 103.2a4.226 4.226 0 0 1-3.767-6.125L73.8 78.55a4.226 4.226 0 0 1 7.548 3.8l-9.335 18.522a4.23 4.23 0 0 1-3.781 2.323Z" data-name="Tracé 10091" style="fill:currentColor" transform="translate(-54.53 -64.931)"/></g><g data-name="Groupe 1910" opacity=".35"><path fill="#0540ff" d="M1093.234 103.213a4.226 4.226 0 0 1-3.766-6.125l9.335-18.522a4.223 4.223 0 0 1 7.542 3.8l-9.335 18.522a4.23 4.23 0 0 1-3.776 2.325" data-name="Tracé 10092" style="fill:currentColor" transform="translate(-927.653 -64.946)"/></g><path fill="#0540ff" d="M140.7 103.184a4.22 4.22 0 0 1-3.759-2.3l-9.483-18.521a4.223 4.223 0 0 1 7.517-3.853l5.7 11.122 5.594-11.1a4.226 4.226 0 0 1 7.548 3.805l-9.335 18.522a4.22 4.22 0 0 1-3.758 2.322Z" data-name="Tracé 10093" style="fill:currentColor" transform="translate(-108.179 -64.918)"/><path fill="#0540ff" d="M13.708 103.2a4.22 4.22 0 0 1-3.762-2.3L.462 82.382a4.223 4.223 0 0 1 7.517-3.852l9.483 18.522a4.226 4.226 0 0 1-3.755 6.149Z" data-name="Tracé 10094" style="fill:currentColor" transform="translate(.003 -64.936)"/><path fill="#0540ff" d="M966.234 103.219a4.226 4.226 0 0 1-3.766-6.125l9.332-18.522a4.22 4.22 0 0 1 3.758-2.322h.013a4.22 4.22 0 0 1 3.758 2.3l9.483 18.522a4.223 4.223 0 0 1-7.517 3.852L975.6 89.8l-5.588 11.1a4.23 4.23 0 0 1-3.778 2.319" data-name="Tracé 10095" style="fill:currentColor" transform="translate(-819.471 -64.952)"/><path fill="#0540ff" d="M1165.708 103.2a4.22 4.22 0 0 1-3.762-2.3l-9.483-18.522a4.223 4.223 0 1 1 7.517-3.852l9.483 18.522a4.226 4.226 0 0 1-3.755 6.149Z" data-name="Tracé 10096" style="fill:currentColor" transform="translate(-981.302 -64.936)"/><path fill="#0540ff" d="m578.369 30.891-8.541-6.677 8.446-5.666a4 4 0 0 0-4.456-6.647l-11.737 7.869V4.186a4.186 4.186 0 1 0-8.372 0v29.783a4.186 4.186 0 0 0 8.372 0V28.32l11.36 8.871a4 4 0 1 0 4.927-6.3Z" data-name="Tracé 10097" style="fill:currentColor" transform="translate(-471.665)"/><path fill="#0540ff" d="M356.4 90.163a13.4 13.4 0 1 0-7 11.854 3.852 3.852 0 0 0 7-2.223zM343 96.09a5.853 5.853 0 1 1 5.853-5.853A5.853 5.853 0 0 1 343 96.09" data-name="Tracé 10098" style="fill:currentColor" transform="translate(-280.762 -65.455)"/><path fill="#0540ff" d="M782.4 90.163a13.4 13.4 0 1 0-7 11.854 3.853 3.853 0 0 0 7-2.223zM769 96.09a5.853 5.853 0 1 1 5.853-5.853A5.853 5.853 0 0 1 769 96.09" data-name="Tracé 10099" style="fill:currentColor" transform="translate(-643.64 -65.455)"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 18 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,47 +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");
|
||||||
|
|
||||||
const isMagicLinkAvailable = useMemo(() => {
|
|
||||||
return isCloudEnv || (isEmailSendingEnabled && !isSignUp);
|
|
||||||
}, [isCloudEnv, isEmailSendingEnabled, isSignUp]);
|
|
||||||
|
|
||||||
// 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 && (
|
||||||
@@ -345,33 +259,20 @@ 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
{socialProviders?.length !== 0 && (
|
||||||
<div className="flex w-full items-center gap-4">
|
|
||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
|
||||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
|
||||||
{t`No authentication methods are currently available`}
|
|
||||||
</span>
|
|
||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
@@ -394,54 +295,48 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
<div>
|
||||||
<>
|
<Input
|
||||||
<div>
|
{...register("email", { required: true })}
|
||||||
<Input
|
placeholder={t`Enter your email address`}
|
||||||
{...register("email", { required: true })}
|
/>
|
||||||
placeholder={t`Enter your email address`}
|
{errors.email && (
|
||||||
/>
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{errors.email && (
|
{t`Please enter a valid email address`}
|
||||||
<p className="mt-2 text-xs text-red-400">
|
</p>
|
||||||
{t`Please enter a valid email address`}
|
)}
|
||||||
</p>
|
</div>
|
||||||
)}
|
{isCredentialsEnabled && (
|
||||||
</div>
|
<div>
|
||||||
|
<Input
|
||||||
{isCredentialsEnabled && (
|
type="password"
|
||||||
<div>
|
{...register("password", { required: true })}
|
||||||
<Input
|
placeholder={t`Enter your password`}
|
||||||
type="password"
|
/>
|
||||||
{...register("password", { required: true })}
|
{errors.password && (
|
||||||
placeholder={t`Enter your password`}
|
<p className="mt-2 text-xs text-red-400">
|
||||||
/>
|
{t`Please enter a valid password`}
|
||||||
{errors.password && (
|
</p>
|
||||||
<p className="mt-2 text-xs text-red-400">
|
|
||||||
{errors.password.message ??
|
|
||||||
t`Please enter a valid password`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loginError && (
|
{loginError && (
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
<Button
|
||||||
<Button
|
isLoading={isLoginWithEmailPending}
|
||||||
isLoading={isLoginWithEmailPending}
|
fullWidth
|
||||||
fullWidth
|
size="lg"
|
||||||
size="lg"
|
variant="secondary"
|
||||||
variant="secondary"
|
>
|
||||||
>
|
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
? t`email`
|
||||||
</Button>
|
: t`magic link`}
|
||||||
</div>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ 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;
|
||||||
href?: string;
|
href?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
openInNewTab?: boolean;
|
openInNewTab?: boolean;
|
||||||
iconOnly?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = ({
|
const Button = ({
|
||||||
@@ -25,24 +24,13 @@ const Button = ({
|
|||||||
href,
|
href,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
openInNewTab,
|
openInNewTab,
|
||||||
iconOnly,
|
|
||||||
...props
|
...props
|
||||||
}: 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",
|
||||||
iconOnly && "p-0",
|
|
||||||
iconOnly &&
|
|
||||||
(size === "xs"
|
|
||||||
? "h-6 w-6"
|
|
||||||
: size === "sm"
|
|
||||||
? "h-8 w-8"
|
|
||||||
: size === "lg"
|
|
||||||
? "h-10 w-10"
|
|
||||||
: "h-9 w-9"),
|
|
||||||
variant === "primary" &&
|
variant === "primary" &&
|
||||||
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
||||||
variant === "secondary" &&
|
variant === "secondary" &&
|
||||||
@@ -58,60 +46,19 @@ const Button = ({
|
|||||||
<span className="relative flex items-center justify-center">
|
<span className="relative flex items-center justify-center">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="absolute">
|
<span className="absolute">
|
||||||
<LoadingSpinner size={size === "xs" ? "sm" : size} />
|
<LoadingSpinner size={size} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{iconOnly ? (
|
<div
|
||||||
<div
|
className={twMerge(
|
||||||
className={twMerge(
|
"flex items-center",
|
||||||
"flex items-center",
|
isLoading ? "invisible" : "visible",
|
||||||
isLoading ? "invisible" : "visible",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{iconLeft && <span className="mr-2">{iconLeft}</span>}
|
||||||
{iconLeft ?? iconRight}
|
{children}
|
||||||
</div>
|
{iconRight && <span className="ml-1">{iconRight}</span>}
|
||||||
) : (
|
</div>
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
fullWidth
|
|
||||||
? "grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2"
|
|
||||||
: "flex items-center",
|
|
||||||
isLoading ? "invisible" : "visible",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fullWidth && !iconLeft && iconRight && (
|
|
||||||
<span className="col-start-1 opacity-0">{iconRight}</span>
|
|
||||||
)}
|
|
||||||
{iconLeft && (
|
|
||||||
<span
|
|
||||||
className={twMerge(
|
|
||||||
fullWidth ? "col-start-1 justify-self-start" : "mr-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{iconLeft}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={twMerge(
|
|
||||||
fullWidth ? "col-start-2 justify-self-center text-center" : "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
{iconRight && (
|
|
||||||
<span
|
|
||||||
className={twMerge(
|
|
||||||
fullWidth ? "col-start-3 justify-self-end" : "ml-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{iconRight}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{fullWidth && !iconRight && iconLeft && (
|
|
||||||
<span className="col-start-3 opacity-0">{iconLeft}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { useTheme } from "next-themes";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
TbLayoutSidebarLeftCollapse,
|
TbLayoutSidebarLeftCollapse,
|
||||||
TbLayoutSidebarLeftExpand,
|
TbLayoutSidebarLeftExpand,
|
||||||
@@ -10,8 +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 { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -20,31 +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 { resolvedTheme } = useTheme();
|
|
||||||
const { openModal } = useModal();
|
|
||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -60,10 +40,6 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeSideNav = () => {
|
|
||||||
setIsSideNavOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRightPanel = () => {
|
const toggleRightPanel = () => {
|
||||||
setIsRightPanelOpen(!isRightPanelOpen);
|
setIsRightPanelOpen(!isRightPanelOpen);
|
||||||
if (!isRightPanelOpen) {
|
if (!isRightPanelOpen) {
|
||||||
@@ -89,99 +65,98 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
|
||||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
|
||||||
}
|
|
||||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "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}>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { Instance as TippyInstance } from "tippy.js";
|
|||||||
import { Button } from "@headlessui/react";
|
import { Button } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import Mention from "@tiptap/extension-mention";
|
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import {
|
import {
|
||||||
BubbleMenu,
|
BubbleMenu,
|
||||||
@@ -18,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,
|
||||||
@@ -40,10 +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 { Markdown } from "tiptap-markdown";
|
|
||||||
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
|
||||||
import Avatar from "./Avatar";
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -79,16 +68,6 @@ export interface RenderSuggestionsProps {
|
|||||||
command: (item: SlashCommandItem) => void;
|
command: (item: SlashCommandItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface 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 },
|
||||||
{
|
{
|
||||||
@@ -202,131 +181,6 @@ const RenderSuggestions = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface 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-[12px] text-dark-900 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() {
|
||||||
@@ -430,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);
|
||||||
|
|
||||||
@@ -444,11 +296,10 @@ export default function Editor({
|
|||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
Markdown,
|
|
||||||
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,
|
||||||
@@ -469,40 +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()),
|
||||||
@@ -526,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;
|
||||||
@@ -551,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,154 +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
|
name: string;
|
||||||
.string()
|
}
|
||||||
.min(1, { message: t`Workspace name is required` })
|
|
||||||
.max(64, { message: t`Workspace name cannot exceed 64 characters` }),
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -167,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">
|
||||||
@@ -244,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();
|
||||||
@@ -261,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();
|
||||||
@@ -269,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,19 +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 { env } from "next-runtime-env";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { HiBolt } from "react-icons/hi2";
|
|
||||||
import {
|
|
||||||
TbLayoutSidebarLeftCollapse,
|
|
||||||
TbLayoutSidebarLeftExpand,
|
|
||||||
} from "react-icons/tb";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import type { Subscription } from "@kan/shared/utils";
|
|
||||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
|
||||||
|
|
||||||
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";
|
||||||
@@ -21,20 +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 ButtonComponent from "~/components/Button";
|
|
||||||
import ReactiveButton from "~/components/ReactiveButton";
|
import ReactiveButton from "~/components/ReactiveButton";
|
||||||
import UserMenu from "~/components/UserMenu";
|
import UserMenu from "~/components/UserMenu";
|
||||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useTheme } from "~/providers/theme";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
interface SideNavigationProps {
|
interface SideNavigationProps {
|
||||||
user: UserType;
|
user: UserType;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onCloseSideNav?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
@@ -45,46 +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 { workspace } = useWorkspace();
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const [isInitialised, setIsInitialised] = useState(false);
|
|
||||||
const { openModal } = useModal();
|
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
|
||||||
workspacePublicId: workspace.publicId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
|
||||||
| Subscription[]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedState = localStorage.getItem("kan_sidebar-collapsed");
|
|
||||||
if (savedState !== null) {
|
|
||||||
setIsCollapsed(Boolean(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 { resolvedTheme } = useTheme();
|
const { activeTheme } = useTheme();
|
||||||
|
|
||||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
const isDarkMode = activeTheme === "dark";
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
@@ -92,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",
|
||||||
@@ -109,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-[45px] items-center justify-between pb-3 md:flex">
|
<WorkspaceMenu />
|
||||||
{!isCollapsed && (
|
|
||||||
<Link href="/" className="block">
|
|
||||||
<h1 className="pl-2 text-[16px] 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}>
|
||||||
@@ -161,50 +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-2">
|
email={user.email ?? ""}
|
||||||
<UserMenu
|
imageUrl={user.image ?? undefined}
|
||||||
email={user.email ?? ""}
|
isLoading={isLoading}
|
||||||
imageUrl={user.image ?? undefined}
|
/>
|
||||||
isLoading={isLoading}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onCloseSideNav={onCloseSideNav}
|
|
||||||
/>
|
|
||||||
{isCloudEnv &&
|
|
||||||
!hasActiveSubscription(subscriptions, "pro") &&
|
|
||||||
!hasActiveSubscription(subscriptions, "team") && (
|
|
||||||
<div className={twMerge(isCollapsed && "flex justify-center")}>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ButtonComponent
|
|
||||||
iconLeft={<HiBolt />}
|
|
||||||
variant="secondary"
|
|
||||||
href="/settings/workspace?upgrade=pro"
|
|
||||||
aria-label="Upgrade to Pro"
|
|
||||||
title="Upgrade to Pro"
|
|
||||||
iconOnly
|
|
||||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ButtonComponent
|
|
||||||
iconLeft={<HiBolt />}
|
|
||||||
fullWidth
|
|
||||||
variant="secondary"
|
|
||||||
href="/settings/workspace?upgrade=pro"
|
|
||||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
|
||||||
>
|
|
||||||
{t`Upgrade to Pro`}
|
|
||||||
</ButtonComponent>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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, resolvedTheme, 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 ${resolvedTheme === "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 ${
|
||||||
resolvedTheme === "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 { useTheme } from "next-themes";
|
|
||||||
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 { 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 justify-start gap-1",
|
{workspace.plan === "pro" && (
|
||||||
isCollapsed && "md:flex-col-reverse md:items-center",
|
<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] min-w-0 flex-1 items-center justify-start 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 min-w-0 flex-1 truncate text-left 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",
|
|
||||||
)}
|
|
||||||
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,26 +7,14 @@ 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;
|
|
||||||
closeOnClickOutside?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
children,
|
children,
|
||||||
modalSize = "sm",
|
modalSize = "sm",
|
||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
|
||||||
closeOnClickOutside,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const { isOpen, closeModal } = useModal();
|
||||||
isOpen,
|
|
||||||
closeModal,
|
|
||||||
closeOnClickOutside: modalCloseOnClickOutside,
|
|
||||||
} = useModal();
|
|
||||||
|
|
||||||
const shouldShow = isVisible ?? isOpen;
|
|
||||||
const shouldCloseOnClickOutside =
|
|
||||||
closeOnClickOutside ?? modalCloseOnClickOutside;
|
|
||||||
|
|
||||||
const modalSizeMap = {
|
const modalSizeMap = {
|
||||||
sm: "max-w-[400px]",
|
sm: "max-w-[400px]",
|
||||||
@@ -41,12 +29,8 @@ const Modal: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={shouldShow} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
<Dialog
|
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||||
as="div"
|
|
||||||
className="relative z-50"
|
|
||||||
onClose={shouldCloseOnClickOutside ? closeModal : () => null}
|
|
||||||
>
|
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@@ -59,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(),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -90,7 +85,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
.string()
|
.string()
|
||||||
@@ -106,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.
|
||||||
@@ -126,15 +113,11 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
|
||||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
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,13 +1,4 @@
|
|||||||
export const locales = [
|
export const locales = ["en", "fr", "de", "es", "it", "nl"] as const;
|
||||||
"en",
|
|
||||||
"fr",
|
|
||||||
"de",
|
|
||||||
"es",
|
|
||||||
"it",
|
|
||||||
"nl",
|
|
||||||
"ru",
|
|
||||||
"pl",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
@@ -20,6 +11,4 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
es: "Español",
|
es: "Español",
|
||||||
it: "Italiano",
|
it: "Italiano",
|
||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
|
||||||
pl: "Polski",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
|
|
||||||
export default async function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse,
|
|
||||||
) {
|
|
||||||
if (req.method !== "GET") {
|
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { url, filename } = req.query;
|
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: "url parameter is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const downloadUrl = decodeURIComponent(url);
|
|
||||||
const downloadFilename =
|
|
||||||
(typeof filename === "string" ? decodeURIComponent(filename) : null) ??
|
|
||||||
"attachment";
|
|
||||||
|
|
||||||
const upstream = await fetch(downloadUrl);
|
|
||||||
|
|
||||||
if (!upstream.ok) {
|
|
||||||
return res.status(upstream.status).json({
|
|
||||||
message: "Failed to fetch attachment",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", contentType);
|
|
||||||
res.setHeader(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${downloadFilename}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const buffer = await upstream.arrayBuffer();
|
|
||||||
return res.send(Buffer.from(buffer));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error downloading attachment:", error);
|
|
||||||
return res.status(500).json({ message: "Failed to download attachment" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
|
|
||||||
export default async function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse,
|
|
||||||
) {
|
|
||||||
if (req.method !== "GET") {
|
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch from Formbricks");
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
return res.status(200).json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching OSS friends:", error);
|
|
||||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,40 +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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
subscription_data: {
|
|
||||||
trial_period_days: 14,
|
|
||||||
},
|
|
||||||
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 OSSFriendsView from "~/views/oss-friends";
|
|
||||||
|
|
||||||
export default function OSSFriends() {
|
|
||||||
return <OSSFriendsView />;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||