Compare commits
9 Commits
feat/loggi
...
feat/notif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f797af64e7 | ||
|
|
1f0c780763 | ||
|
|
149277ef39 | ||
|
|
c8dcb17510 | ||
|
|
2f4aea52eb | ||
|
|
7973d8de2f | ||
|
|
7a0f89c4c0 | ||
|
|
105ba45412 | ||
|
|
3c46d82b7a |
@@ -1,62 +1,18 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Docker
|
||||
docker-compose*.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
Dockerfile
|
||||
./**/*/Dockerfile
|
||||
|
||||
.dockerignore
|
||||
|
||||
# Dependencies (rebuilt in Docker)
|
||||
node_modules
|
||||
**/node_modules
|
||||
./**/*/node_modules
|
||||
|
||||
# Build outputs (rebuilt in Docker)
|
||||
**/.next
|
||||
**/dist
|
||||
**/out
|
||||
**/.turbo
|
||||
**/.cache
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.husky
|
||||
.changeset
|
||||
|
||||
# Documentation (not needed in build)
|
||||
*.md
|
||||
!packages/db/migrations/**
|
||||
LICENSE
|
||||
CONTRIBUTING.md
|
||||
AGENTS.md
|
||||
CHANGELOG.md
|
||||
|
||||
# Test files
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
**/__tests__
|
||||
**/coverage
|
||||
|
||||
# Logs
|
||||
pnpm-debug.log
|
||||
**/pnpm-debug.log
|
||||
./**/*/pnpm-debug.log
|
||||
|
||||
# Cloud compose (separate deployment)
|
||||
cloud/
|
||||
README.md
|
||||
.next
|
||||
# .git
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | 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
|
||||
@@ -28,14 +28,12 @@ S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||
|
||||
|
||||
# Auth config (optional)
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
@@ -51,9 +49,6 @@ TRELLO_APP_SECRET=
|
||||
# If not provided, rate limiting will use in-memory storage
|
||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
||||
|
||||
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||
LOG_LEVEL=
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||
|
||||
56
.github/workflows/docker-publish.yml
vendored
56
.github/workflows/docker-publish.yml
vendored
@@ -6,31 +6,23 @@ name: Docker
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "21 21 * * *"
|
||||
push:
|
||||
# Only trigger on tags (releases), not main branch pushes
|
||||
# Main branch builds are handled by workflow_run after Translate completes
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_run:
|
||||
workflows: ["Translate"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
# Docker builds after Translate completes (success or failure)
|
||||
# This ensures Docker always has the latest translations
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
||||
|
||||
jobs:
|
||||
build:
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -82,22 +74,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
|
||||
- name: Extract Docker metadata (migrate)
|
||||
id: meta-migrate
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
@@ -152,22 +129,6 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push migrate Docker image
|
||||
id: build-and-push-migrate
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
target: migrate
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||
labels: ${{ steps.meta-migrate.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
@@ -182,10 +143,3 @@ jobs:
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
- name: Sign the published migrate Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
56
.github/workflows/translate.yml
vendored
56
.github/workflows/translate.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Translate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter @kan/web...
|
||||
|
||||
- name: Extract translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:extract
|
||||
|
||||
- name: Lingo.dev
|
||||
uses: lingodotdev/lingo.dev@main
|
||||
with:
|
||||
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
|
||||
commit-message: "chore: update translations"
|
||||
working-directory: apps/web
|
||||
commit-author-name: "${{ github.actor }}"
|
||||
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
|
||||
parallel: true
|
||||
|
||||
- name: Compile translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:compile
|
||||
|
||||
- name: Commit compiled translations
|
||||
run: |
|
||||
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
|
||||
git add apps/web/src/locales/*/messages.ts
|
||||
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
|
||||
fi
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -185,17 +185,9 @@ Use `assertUserInWorkspace` helper for workspace checks.
|
||||
|
||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
||||
- Provide user-friendly error messages
|
||||
- Log errors appropriately using the `@kan/logger` package
|
||||
- Log errors appropriately
|
||||
- Show popup notifications for user-facing errors
|
||||
|
||||
### Logging
|
||||
|
||||
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
|
||||
- Create a module-scoped logger: `const logger = createLogger("module-name")`
|
||||
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
|
||||
- Defaults to `debug` in development, `info` in production
|
||||
- Never use `console.log` — always use the logger
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Card
|
||||
@@ -227,16 +219,6 @@ Use `assertUserInWorkspace` helper for workspace checks.
|
||||
5. **Frontend**: Add UI components in `apps/web/src/`
|
||||
6. **i18n**: Add translations for new strings
|
||||
|
||||
## Adding a New Environment Variable
|
||||
|
||||
Update all of the following:
|
||||
|
||||
1. `.env.example` — add the variable with an empty value and a comment explaining it
|
||||
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
|
||||
3. `docker-compose.yml` — add to the `web` service `environment` section
|
||||
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
|
||||
5. `README.md` — add a row to the Environment Variables table
|
||||
|
||||
## Database Changes
|
||||
|
||||
- Always create migrations (never modify existing migrations)
|
||||
|
||||
58
README.md
58
README.md
@@ -57,61 +57,39 @@ The easiest way to deploy Kan is through Railway. We've partnered with Railway t
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
|
||||
|
||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
||||
|
||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: kan-migrate
|
||||
networks:
|
||||
- kan-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: kan-web
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
||||
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:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: kan-db
|
||||
environment:
|
||||
- POSTGRES_DB=kan_db
|
||||
- POSTGRES_USER=kan
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: kan_db
|
||||
POSTGRES_USER: kan
|
||||
POSTGRES_PASSWORD: your_postgres_password
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
@@ -123,23 +101,23 @@ volumes:
|
||||
kan_postgres_data:
|
||||
```
|
||||
|
||||
3. Start the containers in detached mode:
|
||||
2. Start the containers in detached mode:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
|
||||
3. Access Kan at http://localhost:3000
|
||||
|
||||
**Managing containers:**
|
||||
The application will be running in the background. You can manage the containers using these commands:
|
||||
|
||||
- To stop the containers: `docker compose down`
|
||||
- To view logs: `docker compose logs -f`
|
||||
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
||||
- To restart the containers: `docker compose restart`
|
||||
- To rebuild after code changes: `docker compose up -d --build`
|
||||
|
||||
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
|
||||
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
@@ -203,7 +181,6 @@ pnpm dev
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
@@ -213,7 +190,6 @@ pnpm dev
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
||||
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
||||
ARG APP_DIRNAME=web
|
||||
ARG PROJECT=@kan/web
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Alpine base with pnpm and turbo
|
||||
# ============================================
|
||||
# 1. Alpine image
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update && \
|
||||
apk add --no-cache libc6-compat && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||
corepack enable && \
|
||||
npm install turbo@2.3.1 --global && \
|
||||
pnpm config set store-dir ~/.pnpm-store
|
||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Prune the monorepo
|
||||
# ============================================
|
||||
FROM alpine AS pruner
|
||||
# Setup pnpm and turbo on the alpine base
|
||||
FROM alpine AS base
|
||||
RUN corepack enable
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN npm install turbo@2.1.3 --global
|
||||
RUN npm install turbo@2.3.1 --global
|
||||
|
||||
RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
ARG PROJECT
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
@@ -33,89 +35,53 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
ARG APP_VERSION
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Install dependencies
|
||||
# ============================================
|
||||
FROM alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
|
||||
ENV CI=true
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# ============================================
|
||||
# Stage 4: Build the web application
|
||||
# ============================================
|
||||
FROM alpine AS builder
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/ ./
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
# Force standalone output for production Docker image
|
||||
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
||||
ENV CI=true
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
# ============================================
|
||||
# Stage 5: Migration image (run-once container)
|
||||
# ============================================
|
||||
FROM node:${NODE_VERSION}-alpine AS migrate
|
||||
WORKDIR /db
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||
|
||||
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY packages/db/migrations/ ./migrations/
|
||||
# 4. Final image - runner stage to run the application
|
||||
FROM base AS runner
|
||||
ARG APP_DIRNAME
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
RUN npm init -y && \
|
||||
npm install drizzle-kit drizzle-orm pg --save-exact && \
|
||||
# Strip unnecessary files from node_modules
|
||||
find node_modules -type f \( \
|
||||
-name '*.d.ts' -o \
|
||||
-name '*.d.mts' -o \
|
||||
-name '*.d.cts' -o \
|
||||
-name '*.map' -o \
|
||||
-name '*.md' -o \
|
||||
-name '*.txt' -o \
|
||||
-name 'LICENSE*' -o \
|
||||
-name 'CHANGELOG*' -o \
|
||||
-name 'README*' -o \
|
||||
-name '.eslint*' -o \
|
||||
-name '.prettier*' -o \
|
||||
-name 'tsconfig*.json' \
|
||||
\) -delete && \
|
||||
find node_modules -type d -empty -delete && \
|
||||
rm -rf /root/.npm /tmp/*
|
||||
|
||||
CMD ["npx", "drizzle-kit", "migrate"]
|
||||
|
||||
# ============================================
|
||||
# Stage 6: Production web image (distroless)
|
||||
# ============================================
|
||||
FROM ${DISTROLESS_NODE_IMAGE} AS web
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy the standalone Next.js server
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||
WORKDIR /app/apps/${APP_DIRNAME}
|
||||
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Bootstrap script for the distroless production image.
|
||||
*
|
||||
* Distroless images have no shell, so this Node.js script handles two tasks
|
||||
* that would normally be done in an entrypoint.sh:
|
||||
*
|
||||
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
||||
* environment variables. The file was originally created at build time by
|
||||
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
||||
* env vars are provided at *run* time via docker-compose / docker run.
|
||||
*
|
||||
* 2. Start the Next.js standalone server.
|
||||
*/
|
||||
|
||||
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const publicDir = path.join(__dirname, "apps", "web", "public");
|
||||
|
||||
if (!existsSync(publicDir)) {
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
|
||||
const envVars = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith("NEXT_PUBLIC_")) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(publicDir, "__ENV.js"),
|
||||
`self.__ENV = ${JSON.stringify(envVars)};`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Start the Next.js standalone server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
require("./apps/web/server.js");
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "1.10",
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"json": {
|
||||
"include": ["src/locales/[locale]/messages.json"]
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
|
||||
3504
apps/web/i18n.lock
3504
apps/web/i18n.lock
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
import { formatter } from "@lingui/format-json";
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
@@ -12,5 +10,6 @@ export default defineConfig({
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
format: formatter({ style: "lingui" }),
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -16,18 +16,6 @@ const config = {
|
||||
: undefined,
|
||||
reactStrictMode: true,
|
||||
|
||||
/** Exclude build tools and dev-only packages from the standalone output */
|
||||
outputFileTracingExcludes: {
|
||||
"**/*": [
|
||||
"@esbuild/**",
|
||||
"esbuild/**",
|
||||
"typescript/**",
|
||||
"webpack/**",
|
||||
"uglify-js/**",
|
||||
"terser/**",
|
||||
],
|
||||
},
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@kan/api",
|
||||
@@ -47,13 +35,49 @@ const config = {
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
const patterns = [
|
||||
{ protocol: "https", hostname: "**" },
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.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;
|
||||
})(),
|
||||
},
|
||||
|
||||
@@ -20,14 +20,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/lib-storage": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@kan/api": "workspace:*",
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:^",
|
||||
"@kan/logger": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@lingui/babel-preset-react": "^2.9.2",
|
||||
"@lingui/conf": "^5.3.2",
|
||||
@@ -80,7 +78,6 @@
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/format-json": "^5.9.1",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"background_color": "#000000",
|
||||
"categories": [
|
||||
"business",
|
||||
"productivity",
|
||||
"utilities"
|
||||
],
|
||||
"description": "The open source Trello alternative.",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"sizes": "512x512",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"id": "/",
|
||||
"screenshots": [
|
||||
{
|
||||
"sizes": "1624x1561",
|
||||
"src": "/screenshot-wide.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide"
|
||||
},
|
||||
{
|
||||
"sizes": "1290x2796",
|
||||
"src": "/screenshot-narrow.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
],
|
||||
"orientation": "portrait-primary",
|
||||
"name": "Kan.bn | The open source alternative to Trello",
|
||||
"scope": "/",
|
||||
"short_name": "Kan.bn",
|
||||
"start_url": "/",
|
||||
"theme_color": "#000000"
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
@@ -145,6 +145,7 @@ export default function CommandPallette({
|
||||
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("");
|
||||
|
||||
@@ -17,14 +17,9 @@ import { twMerge } from "tailwind-merge";
|
||||
interface DateSelectorProps {
|
||||
selectedDate?: Date | null;
|
||||
onDateSelect?: (date: Date | undefined) => void;
|
||||
weekStartsOn?: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
const DateSelector = ({
|
||||
selectedDate,
|
||||
onDateSelect,
|
||||
weekStartsOn = 1,
|
||||
}: DateSelectorProps) => {
|
||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||
});
|
||||
@@ -33,18 +28,18 @@ const DateSelector = ({
|
||||
const year = format(currentMonth, "yyyy");
|
||||
|
||||
const dayHeaders = useMemo(() => {
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn });
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
||||
return eachDayOfInterval({
|
||||
start: weekStart,
|
||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||
}, [weekStartsOn]);
|
||||
}, []);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
||||
|
||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||
(date) => {
|
||||
@@ -58,7 +53,7 @@ const DateSelector = ({
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [currentMonth, selectedDate, weekStartsOn]);
|
||||
}, [currentMonth, selectedDate]);
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setCurrentMonth(subMonths(currentMonth, 1));
|
||||
|
||||
@@ -463,6 +463,13 @@ export default function Editor({
|
||||
StarterKit.configure({
|
||||
heading: disableHeadings ? false : undefined,
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
HTMLAttributes: {
|
||||
@@ -472,14 +479,6 @@ export default function Editor({
|
||||
},
|
||||
validate: (href) => /^https?:\/\//.test(href),
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: getCommandItems(disableHeadings),
|
||||
|
||||
@@ -75,7 +75,7 @@ export function LabelForm({
|
||||
reset(newFormState);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -231,7 +231,9 @@ export function LabelForm({
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||
disabled={!watch("name")}
|
||||
disabled={
|
||||
!watch("name")
|
||||
}
|
||||
>
|
||||
{isEdit ? t`Update label` : t`Create label`}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ const schema = z.object({
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
})
|
||||
@@ -111,7 +111,6 @@ export function NewWorkspaceForm() {
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
role: "admin",
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
|
||||
@@ -8,7 +8,6 @@ export const PageHead = ({ title }: { title: string }) => {
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
HiChevronDown,
|
||||
HiOutlineBanknotes,
|
||||
HiOutlineBolt,
|
||||
HiOutlineCodeBracketSquare,
|
||||
HiOutlineRectangleGroup,
|
||||
HiOutlineShieldCheck,
|
||||
@@ -65,12 +64,6 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
label: t`API`,
|
||||
condition: true,
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
icon: <HiOutlineBolt />,
|
||||
label: t`Webhooks`,
|
||||
condition: isAdmin,
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
|
||||
@@ -19,7 +19,6 @@ interface UsePermissionsResult {
|
||||
canCreateBoard: boolean;
|
||||
canEditBoard: boolean;
|
||||
canDeleteBoard: boolean;
|
||||
canArchiveBoard: boolean;
|
||||
canViewComment: boolean;
|
||||
canCreateComment: boolean;
|
||||
canEditComment: boolean;
|
||||
@@ -34,7 +33,7 @@ interface UsePermissionsResult {
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||
const workspaceContext = useContext(WorkspaceContext);
|
||||
|
||||
|
||||
// If WorkspaceProvider is not available, return safe defaults
|
||||
if (!workspaceContext) {
|
||||
const emptyPermissions: UsePermissionsResult = {
|
||||
@@ -52,7 +51,6 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: false,
|
||||
canEditBoard: false,
|
||||
canDeleteBoard: false,
|
||||
canArchiveBoard: false,
|
||||
canViewComment: false,
|
||||
canCreateComment: false,
|
||||
canEditComment: false,
|
||||
@@ -97,7 +95,6 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: hasPermission("board:create"),
|
||||
canEditBoard: hasPermission("board:edit"),
|
||||
canDeleteBoard: hasPermission("board:delete"),
|
||||
canArchiveBoard: hasPermission("board:edit"),
|
||||
canViewComment: hasPermission("comment:view"),
|
||||
canCreateComment: hasPermission("comment:create"),
|
||||
canEditComment: hasPermission("comment:edit"),
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const scrollPositions = new Map<string, number>();
|
||||
|
||||
export function useScrollRestore(
|
||||
boardId: string | null | undefined,
|
||||
scrollRef: RefObject<HTMLElement | null>,
|
||||
router: NextRouter,
|
||||
isReady: boolean,
|
||||
) {
|
||||
const restored = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardId) return;
|
||||
|
||||
restored.current = false;
|
||||
|
||||
const saveScrollPosition = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", saveScrollPosition);
|
||||
return () => router.events.off("routeChangeStart", saveScrollPosition);
|
||||
}, [boardId, router.events, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restored.current || !isReady || !boardId) return;
|
||||
restored.current = true;
|
||||
|
||||
const saved = scrollPositions.get(boardId);
|
||||
if (saved === undefined) return;
|
||||
|
||||
// StrictModeDroppable delays rendering by one requestAnimationFrame
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
|
||||
});
|
||||
});
|
||||
}, [isReady, scrollRef, boardId]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/de/messages.po
Normal file
3090
apps/web/src/locales/de/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3275
apps/web/src/locales/en/messages.po
Normal file
3275
apps/web/src/locales/en/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/es/messages.po
Normal file
3090
apps/web/src/locales/es/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/fr/messages.po
Normal file
3090
apps/web/src/locales/fr/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export const locales = [
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"ptbr"
|
||||
"pt-BR"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
ptbr: "Português",
|
||||
"pt-BR": "Português",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/it/messages.po
Normal file
3090
apps/web/src/locales/it/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/nl/messages.po
Normal file
3090
apps/web/src/locales/nl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3095
apps/web/src/locales/pl/messages.po
Normal file
3095
apps/web/src/locales/pl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/ru/messages.po
Normal file
3090
apps/web/src/locales/ru/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,55 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`404 - Page Not Found | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
404
|
||||
</p>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
<Trans>Page not found</Trans>
|
||||
</p>
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
|
||||
<Trans>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
|
||||
>
|
||||
<Trans>Go to homepage</Trans>
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
<Trans>Go to boards</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,23 +13,6 @@ const authHandler = toNodeHandler(auth.handler);
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req, res) => {
|
||||
/**
|
||||
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||
*/
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (forwardedProto) {
|
||||
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
const forwardedHost = req.headers["x-forwarded-host"];
|
||||
if (forwardedHost) {
|
||||
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,74 +2,48 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
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 downloadFilename = typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res.status(upstream.status).json({
|
||||
message: "Failed to fetch attachment",
|
||||
});
|
||||
}
|
||||
|
||||
const { url, filename } = req.query;
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({ message: "url parameter is required" });
|
||||
}
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
|
||||
const s3Endpoint = env.S3_ENDPOINT;
|
||||
|
||||
if (s3Endpoint) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ message: "Invalid URL" });
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
let allowedHost: string;
|
||||
try {
|
||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||
} catch {
|
||||
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||
}
|
||||
|
||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||
return res.status(403).json({ message: "URL not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename =
|
||||
typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res
|
||||
.status(upstream.status)
|
||||
.json({ message: "Failed to fetch attachment" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to download attachment" });
|
||||
}
|
||||
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" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||
|
||||
interface CheckoutSessionRequest {
|
||||
|
||||
@@ -3,11 +3,8 @@ import type { Readable } from "node:stream";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
const log = createLogger("stripe-webhook");
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks = [];
|
||||
for await (const chunk of readable) {
|
||||
@@ -44,8 +41,6 @@ export default async function handler(
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const checkoutSession = event.data.object;
|
||||
@@ -61,12 +56,12 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
default:
|
||||
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
|
||||
return res.status(200).json({ received: true });
|
||||
} catch (err) {
|
||||
log.error({ err }, "Stripe webhook handler failed");
|
||||
console.error("Webhook error:", err);
|
||||
return res.status(400).json({ message: "Webhook handler failed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||
import { createS3Client, generateUID } from "@kan/shared/utils";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
|
||||
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
export const config = {
|
||||
@@ -90,19 +90,16 @@ export default withRateLimit(
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
// Upload the file to S3
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
},
|
||||
leavePartsOnError: false,
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
}),
|
||||
);
|
||||
|
||||
// Create attachment record and log activity
|
||||
const attachment = await cardAttachmentRepo.create(db, {
|
||||
@@ -115,15 +112,9 @@ export default withRateLimit(
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return res.status(500).json({ error: "Failed to create attachment" });
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: originalFilenameHeader,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
@@ -134,3 +125,4 @@ export default withRateLimit(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
|
||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function TrelloAuthorize() {
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const token = hash.split("=")[1];
|
||||
if (token) {
|
||||
fetch("/api/trello/authenticate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const token = hash.split("=")[1];
|
||||
if (token) {
|
||||
console.log("Posting token to /api/trello/authenticate", token);
|
||||
fetch("/api/trello/authenticate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
}).then(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[200px] items-center justify-center">
|
||||
<p className="text-center">Connecting to Trello...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="flex h-[200px] items-center justify-center">
|
||||
<p className="text-center">Connecting to Trello...</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import WebhookSettings from "~/views/settings/WebhookSettings";
|
||||
|
||||
const WebhookSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="webhooks">
|
||||
<WebhookSettings />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default WebhookSettingsPage;
|
||||
@@ -19,7 +19,6 @@ interface Workspace {
|
||||
slug: string | undefined;
|
||||
plan: "free" | "pro" | "enterprise" | undefined;
|
||||
role: "admin" | "member" | "guest";
|
||||
weekStartDay: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
const initialWorkspace: Workspace = {
|
||||
@@ -29,14 +28,13 @@ const initialWorkspace: Workspace = {
|
||||
slug: "",
|
||||
plan: "free",
|
||||
role: "member",
|
||||
weekStartDay: 1,
|
||||
};
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
|
||||
export const WorkspaceContext = createContext<
|
||||
WorkspaceContextProps | undefined
|
||||
>(undefined);
|
||||
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
@@ -81,7 +79,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
slug: workspace.slug,
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
weekStartDay: workspace.weekStartDay,
|
||||
hasLoaded: true,
|
||||
})) as Workspace[];
|
||||
|
||||
@@ -103,7 +100,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
@@ -123,7 +119,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole,
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
|
||||
{!isSignUpDisabled && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
|
||||
@@ -28,9 +28,7 @@ export default function SignUpPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
const isInviteFlow = redirect?.startsWith("/invite/");
|
||||
|
||||
if (isSignUpDisabled && !isInviteFlow) {
|
||||
if (isSignUpDisabled) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
HiOutlineStar,
|
||||
HiStar,
|
||||
} from "react-icons/hi2";
|
||||
import { IoArchiveOutline } from "react-icons/io5";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -17,7 +17,6 @@ import { api } from "~/utils/api";
|
||||
export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
isArchived,
|
||||
boardPublicId,
|
||||
isFavorite,
|
||||
boardName,
|
||||
@@ -25,29 +24,28 @@ export default function BoardDropdown({
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||
usePermissions();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
onSuccess: (data, variables) => {
|
||||
void utils.board.all.invalidate();
|
||||
void utils.board.byId.invalidate();
|
||||
if (variables.isArchived !== undefined) {
|
||||
showPopup({
|
||||
header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
|
||||
message: variables.isArchived
|
||||
? t`The board has been archived.`
|
||||
: t`The board has been unarchived.`,
|
||||
icon: "success",
|
||||
});
|
||||
} else if (variables.favorite !== undefined) {
|
||||
|
||||
// Show popup notification
|
||||
if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
? t`Added to favorites`
|
||||
@@ -67,54 +65,27 @@ export default function BoardDropdown({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveOrUnarchive = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
isArchived: !isArchived,
|
||||
});
|
||||
};
|
||||
|
||||
const isArchiveActionPending = updateBoard.isPending;
|
||||
|
||||
|
||||
const items = [
|
||||
...(isTemplate && canCreateBoard
|
||||
? [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canArchiveBoard
|
||||
? [
|
||||
{
|
||||
label: isArchived ? t`Unarchive board` : t`Archive board`,
|
||||
action: handleArchiveOrUnarchive,
|
||||
icon: (
|
||||
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
@@ -129,26 +100,22 @@ export default function BoardDropdown({
|
||||
},
|
||||
...(canDeleteBoard
|
||||
? [
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading || isArchiveActionPending}
|
||||
items={items}
|
||||
>
|
||||
<Dropdown disabled={isLoading} items={items}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -66,8 +66,8 @@ const Card = ({
|
||||
const hasDueDate = !!dueDate;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span className="break-words">{title}</span>
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span>{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
checklists.length > 0 ||
|
||||
|
||||
@@ -24,7 +24,6 @@ import Toggle from "~/components/Toggle";
|
||||
import { useModalFormState } from "~/hooks/useModalFormState";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
@@ -54,7 +53,6 @@ export function NewCardForm({
|
||||
queryParams,
|
||||
}: NewCardFormProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { closeModal, openModal, modalStates, clearModalState } = useModal();
|
||||
|
||||
const utils = api.useUtils();
|
||||
@@ -493,7 +491,6 @@ export function NewCardForm({
|
||||
setValue("dueDate", date ?? null);
|
||||
setIsDateSelectorOpen(false);
|
||||
}}
|
||||
weekStartsOn={workspace.weekStartDay}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -26,7 +26,6 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useScrollRestore } from "~/hooks/useScrollRestore";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -59,14 +58,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } =
|
||||
usePermissions();
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
@@ -125,21 +123,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
data: boardData,
|
||||
isSuccess,
|
||||
isLoading: isQueryLoading,
|
||||
error,
|
||||
} = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardId,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Redirect to 404 if board doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && boardId && !isQueryLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, boardId, isQueryLoading, error, boardData]);
|
||||
|
||||
const refetchBoard = async () => {
|
||||
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
|
||||
};
|
||||
@@ -152,8 +140,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
const isLoading = isInitialLoading || isQueryLoading;
|
||||
|
||||
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
|
||||
|
||||
const updateListMutation = api.list.update.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
@@ -431,9 +417,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{...register("name")}
|
||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||
readOnly={!canEditBoard}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none disabled:cursor-not-allowed dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||
/>
|
||||
</form>
|
||||
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
@@ -507,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
isArchived={boardData?.isArchived ?? false}
|
||||
workspacePublicId={workspace.publicId}
|
||||
isFavorite={boardData?.favorite}
|
||||
boardName={boardData?.name}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
@@ -26,7 +26,6 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
type: isTemplate ? "template" : "regular",
|
||||
archived: archived,
|
||||
},
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
@@ -60,10 +59,10 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
{t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
@@ -110,18 +109,18 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
>
|
||||
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FaGithub, FaTrello } from "react-icons/fa";
|
||||
import { FaTrello } from "react-icons/fa";
|
||||
import {
|
||||
HiChevronUpDown,
|
||||
HiMiniArrowTopRightOnSquare,
|
||||
@@ -27,23 +27,12 @@ const integrationProviders: Record<
|
||||
name: "Trello",
|
||||
icon: <FaTrello />,
|
||||
},
|
||||
github: {
|
||||
name: "GitHub",
|
||||
icon: <FaGithub />,
|
||||
},
|
||||
};
|
||||
|
||||
const SelectSource = ({
|
||||
handleNextStep,
|
||||
}: {
|
||||
handleNextStep: (provider: string) => void;
|
||||
}) => {
|
||||
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
const { data: integrations, refetch: refetchIntegrations } =
|
||||
api.integration.providers.useQuery();
|
||||
const { data: githubStatus, refetch: refetchGithubStatus } =
|
||||
api.integration.getGitHubStatus.useQuery();
|
||||
|
||||
const { control, handleSubmit, watch } = useForm({
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
source: integrations?.[0]?.provider ?? "trello",
|
||||
},
|
||||
@@ -58,36 +47,23 @@ const SelectSource = ({
|
||||
},
|
||||
);
|
||||
|
||||
const availableIntegrations = [
|
||||
...(integrations ?? []),
|
||||
...(githubStatus?.connected ? [{ provider: "github" }] : []),
|
||||
];
|
||||
|
||||
const hasIntegrations = availableIntegrations.length > 0;
|
||||
const hasIntegrations = integrations && integrations.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
void refetchIntegrations();
|
||||
void refetchGithubStatus();
|
||||
refetchIntegrations();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations, refetchGithubStatus]);
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
const onSubmit = () => {
|
||||
const selected = watch("source");
|
||||
if (
|
||||
selected === "trello" &&
|
||||
!integrations?.some((i) => i.provider === "trello")
|
||||
) {
|
||||
if (trelloUrl)
|
||||
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||
} else if (selected === "github" && !githubStatus?.connected) {
|
||||
window.open("/settings/integrations", "_blank");
|
||||
if (!hasIntegrations && trelloUrl) {
|
||||
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||
} else {
|
||||
handleNextStep(selected);
|
||||
handleNextStep();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,7 +102,7 @@ const SelectSource = ({
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-1 text-base text-neutral-900 shadow-lg ring-1 ring-light-600 ring-opacity-5 focus:outline-none dark:bg-dark-300 dark:text-dark-1000 sm:text-sm">
|
||||
{hasIntegrations ? (
|
||||
availableIntegrations.map((integration, index) => (
|
||||
integrations.map((integration, index) => (
|
||||
<Listbox.Option
|
||||
key={`source_${index}`}
|
||||
className="relative cursor-default select-none px-1"
|
||||
@@ -147,32 +123,18 @@ const SelectSource = ({
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Listbox.Option
|
||||
key="trello_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
<Listbox.Option
|
||||
key="github_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="github"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.github?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.github?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
</>
|
||||
<Listbox.Option
|
||||
key="trello_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
)}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
@@ -192,159 +154,7 @@ const SelectSource = ({
|
||||
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||
}
|
||||
>
|
||||
{hasIntegrations ? t`Select source` : t`Connect`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportGithub: React.FC = () => {
|
||||
const utils = api.useUtils();
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const { data: projects, isLoading: projectsLoading } =
|
||||
api.import.github.getProjects.useQuery();
|
||||
|
||||
const {
|
||||
register: registerProjects,
|
||||
handleSubmit: handleSubmitProjects,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues: Object.fromEntries(
|
||||
projects?.map((project) => [project.id, true]) ?? [],
|
||||
),
|
||||
});
|
||||
|
||||
const importProjects = api.import.github.importProjects.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Import complete`,
|
||||
message: t`Your projects have been imported.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
await refetchBoards();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Import failed`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const projectWatchers = projects?.map((project) => ({
|
||||
id: project.id,
|
||||
value: watch(project.id),
|
||||
}));
|
||||
|
||||
const projectCount =
|
||||
projectWatchers?.filter((w) => w.value === true).length ?? 0;
|
||||
|
||||
const onSubmitProjects = (values: Record<string, boolean>) => {
|
||||
const projectIds = Object.keys(values).filter(
|
||||
(key) => values[key] === true,
|
||||
);
|
||||
|
||||
importProjects.mutate({
|
||||
projectIds,
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (projectsLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
<div className="h-[30px] w-full animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projects?.length) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`No projects found`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return projects.map((project) => (
|
||||
<div key={project.id}>
|
||||
<label
|
||||
className="flex cursor-pointer items-center rounded-[5px] p-2 hover:bg-light-100 dark:hover:bg-dark-300"
|
||||
htmlFor={project.id}
|
||||
>
|
||||
<input
|
||||
id={project.id}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent ring-0 focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||
{...registerProjects(project.id)}
|
||||
/>
|
||||
<span className="ml-3 text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{project.name}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
|
||||
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Select all`}
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
onChange={() => {
|
||||
const newState = !isSelectAllEnabled;
|
||||
setIsSelectAllEnabled(newState);
|
||||
|
||||
for (const project of projects ?? []) {
|
||||
setValue(project.id, newState);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={importProjects.isPending}
|
||||
disabled={
|
||||
importProjects.isPending ||
|
||||
projectsLoading ||
|
||||
!projects?.length ||
|
||||
!projects.some(
|
||||
(project) =>
|
||||
projectWatchers?.find((w) => w.id === project.id)?.value ===
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans>
|
||||
<Plural
|
||||
value={projectCount}
|
||||
one={`Import project (1)`}
|
||||
other={`Import projects (${projectCount})`}
|
||||
/>
|
||||
</Trans>
|
||||
{hasIntegrations ? t`Select source` : t`Connect Trello`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,7 +196,7 @@ const ImportTrello: React.FC = () => {
|
||||
await refetchBoards();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -403,7 +213,7 @@ const ImportTrello: React.FC = () => {
|
||||
value: watch(board.id),
|
||||
}));
|
||||
|
||||
const boardCount = boardWatchers?.filter((w) => w.value === true).length ?? 0;
|
||||
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
|
||||
|
||||
const onSubmitBoards = (values: Record<string, boolean>) => {
|
||||
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
||||
@@ -457,7 +267,7 @@ const ImportTrello: React.FC = () => {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
|
||||
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
@@ -467,7 +277,7 @@ const ImportTrello: React.FC = () => {
|
||||
const newState = !isSelectAllEnabled;
|
||||
setIsSelectAllEnabled(newState);
|
||||
|
||||
for (const board of boards ?? []) {
|
||||
for (const board of boards || []) {
|
||||
setValue(board.id, newState);
|
||||
}
|
||||
}}
|
||||
@@ -503,7 +313,6 @@ const ImportTrello: React.FC = () => {
|
||||
export function ImportBoardsForm() {
|
||||
const { closeModal } = useModal();
|
||||
const [step, setStep] = useState(1);
|
||||
const [provider, setProvider] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -530,16 +339,8 @@ export function ImportBoardsForm() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<SelectSource
|
||||
handleNextStep={(p) => {
|
||||
setProvider(p);
|
||||
setStep(step + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && provider === "trello" && <ImportTrello />}
|
||||
{step === 2 && provider === "github" && <ImportGithub />}
|
||||
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
|
||||
{step === 2 && <ImportTrello />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,18 +23,6 @@ export const getTemplates = (): Template[] => [
|
||||
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
||||
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-basic",
|
||||
name: t`Basic Roadmap`,
|
||||
lists: [t`Requested`, t`Planned`, t`In Progress`, t`Done`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-extended",
|
||||
name: t`Extended Roadmap`,
|
||||
lists: [t`Requested`, t`Under Review`, t`Planned`, t`In Progress`, t`Done`, t`Rejected`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "content-creation",
|
||||
name: t`Content Creation`,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { useState } from "react";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -22,15 +15,9 @@ import { BoardsList } from "./components/BoardsList";
|
||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||
import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
const boardsTabs = [
|
||||
{ key: "boards" as const, label: t`Active` },
|
||||
{ key: "archived" as const, label: t`Archived` },
|
||||
];
|
||||
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
@@ -47,7 +34,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<PageHead
|
||||
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
|
||||
/>
|
||||
<div className="m-auto h-full max-w-[1100px] p-8 px-5 md:px-28 md:py-12">
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||
@@ -128,79 +115,9 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
{!isTemplate ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="focus:outline-none">
|
||||
<div className="sm:hidden">
|
||||
<Listbox
|
||||
value={activeTab}
|
||||
onChange={(tab) => setActiveTab(tab)}
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{boardsTabs.find((tab) => tab.key === activeTab)?.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-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
|
||||
{boardsTabs.map((tab) => (
|
||||
<ListboxOption
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={({ selected }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
|
||||
? "font-bold text-light-1000 dark:text-dark-1000"
|
||||
: "font-normal text-light-1000 dark:text-dark-1000"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div>
|
||||
<nav
|
||||
aria-label="Tabs"
|
||||
className="-mb-px flex space-x-8 focus:outline-none"
|
||||
>
|
||||
{boardsTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === 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}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full flex-row focus:outline-none">
|
||||
{activeTab === "boards" && (
|
||||
<BoardsList isTemplate={false} archived={false} />
|
||||
)}
|
||||
{activeTab === "archived" && (
|
||||
<BoardsList isTemplate={false} archived={true} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineClock,
|
||||
HiOutlinePaperClip,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
@@ -32,16 +31,6 @@ import Comment from "./Comment";
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
type ActivityWithMergedLabels =
|
||||
GetCardActivitiesOutput["activities"][number] & {
|
||||
mergedLabels?: string[];
|
||||
attachment?: {
|
||||
publicId: string;
|
||||
filename: string;
|
||||
originalFilename: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
@@ -68,7 +57,6 @@ const getActivityText = ({
|
||||
toDueDate,
|
||||
dateLocale,
|
||||
mergedLabels,
|
||||
attachmentName,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -83,7 +71,6 @@ const getActivityText = ({
|
||||
toDueDate?: Date | null;
|
||||
dateLocale: DateFnsLocale;
|
||||
mergedLabels?: string[];
|
||||
attachmentName?: string | null;
|
||||
}) => {
|
||||
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -137,8 +124,6 @@ const getActivityText = ({
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
"card.updated.attachment.added": t`added an attachment`,
|
||||
"card.updated.attachment.removed": t`removed an attachment`,
|
||||
"card.updated.dueDate.added": t`set the due date`,
|
||||
"card.updated.dueDate.updated": t`updated the due date`,
|
||||
"card.updated.dueDate.removed": t`removed the due date`,
|
||||
@@ -271,27 +256,6 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.added") {
|
||||
const filename = attachmentName ?? toTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
added an attachment <TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.removed") {
|
||||
const filename = attachmentName ?? fromTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
removed an attachment{" "}
|
||||
<TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
@@ -344,8 +308,6 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
"card.updated.attachment.added": <HiOutlinePaperClip />,
|
||||
"card.updated.attachment.removed": <HiOutlinePaperClip />,
|
||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||
@@ -379,7 +341,7 @@ const ActivityList = ({
|
||||
isAdmin?: boolean;
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { dateLocale } = useLocalisation();
|
||||
const { dateLocale, locale } = useLocalisation();
|
||||
const { data: sessionData } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const [allActivities, setAllActivities] = useState<
|
||||
@@ -429,21 +391,25 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setAllActivities(currentActivities);
|
||||
setHasMore(false);
|
||||
};
|
||||
|
||||
void fetchAllRemaining();
|
||||
fetchAllRemaining();
|
||||
} else {
|
||||
setAllActivities(firstPageData.activities);
|
||||
setHasMore(firstPageData.hasMore);
|
||||
@@ -470,15 +436,17 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
@@ -505,10 +473,7 @@ const ActivityList = ({
|
||||
fromDueDate: activity.fromDueDate ?? null,
|
||||
toDueDate: activity.toDueDate ?? null,
|
||||
dateLocale: dateLocale,
|
||||
mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels,
|
||||
attachmentName:
|
||||
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
|
||||
null,
|
||||
mergedLabels: (activity as any).mergedLabels,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
|
||||
@@ -5,7 +5,6 @@ import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import DateSelector from "~/components/DateSelector";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -23,7 +22,6 @@ export function DueDateSelector({
|
||||
disabled = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
|
||||
@@ -137,7 +135,6 @@ export function DueDateSelector({
|
||||
<DateSelector
|
||||
selectedDate={pendingDate ?? undefined}
|
||||
onDateSelect={handleDateSelect}
|
||||
weekStartsOn={workspace.weekStartDay}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
@@ -185,20 +184,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
|
||||
// Redirect to 404 if card doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && cardId && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!card && !isLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, cardId, isLoading, error, card]);
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
@@ -311,6 +301,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
@@ -348,13 +339,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
<HiXMark className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,539 +1,293 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Feature {
|
||||
key: string;
|
||||
label: string;
|
||||
kan: boolean | string;
|
||||
trello: boolean | string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
name: string;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
type FrequencyValue = "monthly" | "annually";
|
||||
|
||||
interface PlanFeature {
|
||||
key: string;
|
||||
label: string;
|
||||
free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
teams: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
pro: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
}
|
||||
|
||||
interface FeatureSection {
|
||||
name: string;
|
||||
features: PlanFeature[];
|
||||
}
|
||||
const CellValue = ({ value }: { value: boolean | string }) => {
|
||||
if (typeof value === "string") {
|
||||
return (
|
||||
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span>
|
||||
);
|
||||
}
|
||||
return value ? (
|
||||
<HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" />
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureComparisonTable = ({
|
||||
frequencyValue: _frequencyValue,
|
||||
frequencyValue,
|
||||
}: {
|
||||
frequencyValue: FrequencyValue;
|
||||
}) => {
|
||||
// Keep pricing column headers visible as you scroll
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderFixed, setIsHeaderFixed] = useState(false);
|
||||
const [headerRect, setHeaderRect] = useState<{ left: number; width: number }>({
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const HEADER_OFFSET = 64; // px; matches fixed top nav height
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Hide fixed header on mobile (screens smaller than 620px)
|
||||
if (window.innerWidth < 620) {
|
||||
setIsHeaderFixed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const pastHeader = rect.top <= HEADER_OFFSET;
|
||||
const aboveBottom = rect.bottom > HEADER_OFFSET + 40;
|
||||
|
||||
const shouldFix = pastHeader && aboveBottom;
|
||||
setIsHeaderFixed(shouldFix);
|
||||
|
||||
if (shouldFix) {
|
||||
setHeaderRect({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
const planSpecificLimits: PlanFeature[] = [
|
||||
{
|
||||
key: "boards",
|
||||
label: t`Boards`,
|
||||
free: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
teams: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
pro: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
label: t`Members`,
|
||||
free: { text: t`1 user`, highlightFirstWord: true },
|
||||
teams: { text: t`Per-seat pricing`, highlightFirstWord: true },
|
||||
pro: { text: t`Unlimited members`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Unlimited members`, highlightFirstWord: true },
|
||||
},
|
||||
{
|
||||
key: "file-uploads",
|
||||
label: t`File uploads`,
|
||||
free: { text: t`10mb file uploads`, highlightFirstWord: true },
|
||||
teams: { text: t`Unlimited file uploads`, highlightFirstWord: true },
|
||||
pro: { text: t`Unlimited file uploads`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true },
|
||||
},
|
||||
{
|
||||
key: "workspace-username",
|
||||
label: t`Workspace username`,
|
||||
free: { text: t`Default username`, highlightFirstWord: true },
|
||||
teams: { text: t`Default username`, highlightFirstWord: true },
|
||||
pro: { text: t`Custom username`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Custom username`, highlightFirstWord: true },
|
||||
},
|
||||
];
|
||||
|
||||
const coreFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "checklists",
|
||||
label: t`Checklists`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-templates",
|
||||
label: t`Board templates`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "labels-filters",
|
||||
label: t`Labels & filters`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "board-visibility",
|
||||
label: t`Board visibility`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "roles-permissions",
|
||||
label: t`Roles & permissions`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-branding",
|
||||
label: t`Custom branding`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const collaborationFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "mentions",
|
||||
label: t`Mentions`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "email-notifications",
|
||||
label: t`Email notifications`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "assignees",
|
||||
label: t`Assignees`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const importsFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "import-export",
|
||||
label: t`Trello`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const platformFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "rest-api",
|
||||
label: t`API`,
|
||||
free: { text: t`Limited API access` },
|
||||
teams: { text: t`Full API access`, highlight: true },
|
||||
pro: { text: t`Full API access`, highlight: true },
|
||||
enterprise: { text: t`Full API access`, highlight: true },
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`On-premise`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const securityFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "sso",
|
||||
label: t`SSO`,
|
||||
free: { text: t`Google SSO`, highlight: true },
|
||||
teams: { text: t`Google SSO`, highlight: true },
|
||||
pro: { text: t`Google SSO`, highlight: true },
|
||||
enterprise: { text: t`Google SSO + SAML`, highlight: true },
|
||||
},
|
||||
{
|
||||
key: "admin-roles",
|
||||
label: t`Admin roles`,
|
||||
free: { text: t`Admin roles`, highlight: true },
|
||||
teams: { text: t`Admin roles`, highlight: true },
|
||||
pro: { text: t`Admin roles`, highlight: true },
|
||||
enterprise: { text: t`Advanced admin roles`, highlight: true },
|
||||
},
|
||||
];
|
||||
|
||||
const supportFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "priority-email-support",
|
||||
label: t`Priority email support`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "account-manager",
|
||||
label: t`Account manager`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "sla",
|
||||
label: t`SLA`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const sections: FeatureSection[] = [
|
||||
{
|
||||
name: "",
|
||||
features: planSpecificLimits,
|
||||
},
|
||||
const sections: Section[] = [
|
||||
{
|
||||
name: t`Core features`,
|
||||
features: coreFeatures,
|
||||
features: [
|
||||
{
|
||||
key: "ulimited-workspaces",
|
||||
label: t`Unlimited workspaces`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-boards",
|
||||
label: t`Unlimited boards`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-lists",
|
||||
label: t`Unlimited lists`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "unlimited-cards",
|
||||
label: t`Unlimited cards`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: t`Custom templates`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
|
||||
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
|
||||
{
|
||||
key: "import",
|
||||
label: t`Import from Trello`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "visibility",
|
||||
label: t`Board visibility`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "workspace-url",
|
||||
label: t`Custom workspace link`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Collaboration`,
|
||||
features: collaborationFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Imports`,
|
||||
features: importsFeatures,
|
||||
name: t`Teams`,
|
||||
features: [
|
||||
{
|
||||
key: "pricing",
|
||||
label: t`Price per user/month`,
|
||||
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`,
|
||||
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments & mentions`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "assignments",
|
||||
label: t`Assignees`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "sharing",
|
||||
label: t`Board sharing & invites`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Platform`,
|
||||
features: platformFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Security`,
|
||||
features: securityFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Support`,
|
||||
features: supportFeatures,
|
||||
features: [
|
||||
{
|
||||
key: "api",
|
||||
label: t`REST API`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`Self-hostable`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "white-label",
|
||||
label: t`White label`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
label: t`Fast & lightweight`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "this-is-a-joke",
|
||||
label: t`Owned by Atlassian`,
|
||||
kan: false,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{ id: "free", name: t`Free`, tierId: "tier-free" },
|
||||
{ id: "teams", name: t`Teams`, tierId: "tier-teams" },
|
||||
{ id: "pro", name: t`Pro`, tierId: "tier-pro" },
|
||||
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
|
||||
const products = [
|
||||
{
|
||||
id: "kan",
|
||||
name: t`Kan`,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "trello",
|
||||
name: t`Trello`,
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
const CellValue = ({
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
label: string;
|
||||
}) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const { text, highlight, highlightFirstWord } = value;
|
||||
|
||||
if (highlightFirstWord) {
|
||||
const spaceIndex = text.indexOf(" ");
|
||||
const firstWord = spaceIndex === -1 ? text : text.slice(0, spaceIndex);
|
||||
const rest = spaceIndex === -1 ? "" : text.slice(spaceIndex);
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
|
||||
<span className="text-sm font-medium">
|
||||
<span className="text-light-1000 dark:text-dark-1000">{firstWord}</span>
|
||||
<span className="text-light-950 dark:text-dark-800">{rest}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle
|
||||
className={twMerge(
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
highlight
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-400 dark:text-dark-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
highlight
|
||||
? "text-light-1000 dark:text-dark-950"
|
||||
: "text-light-950 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
|
||||
<span className="text-sm font-medium text-light-1000 dark:text-dark-950">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
|
||||
<span className="text-sm font-medium text-light-800 dark:text-dark-800">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFeatureValue = (
|
||||
feature: PlanFeature,
|
||||
planId: string,
|
||||
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
return feature.free;
|
||||
case "teams":
|
||||
return feature.teams;
|
||||
case "pro":
|
||||
return feature.pro;
|
||||
case "enterprise":
|
||||
return feature.enterprise;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="comparison-heading" className="w-full">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
|
||||
>
|
||||
{isHeaderFixed && (
|
||||
<div
|
||||
className="hidden sm:block fixed z-40 border-b border-light-300 bg-light-50/95 dark:border-dark-400 dark:bg-dark-50/95"
|
||||
style={{
|
||||
top: 64,
|
||||
left: headerRect.left,
|
||||
width: headerRect.width,
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-4 border-x border-light-300 dark:border-dark-300">
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<div
|
||||
key={`fixed-${plan.id}`}
|
||||
className={twMerge(
|
||||
"flex items-center px-6 py-3 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<section aria-labelledby="comparison-heading" className="w-full px-4">
|
||||
<div className="mx-auto max-w-6xl py-8 lg:py-10">
|
||||
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} aria-hidden="true" className="-mt-px">
|
||||
<div
|
||||
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`}
|
||||
>
|
||||
<p
|
||||
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<th
|
||||
key={plan.id}
|
||||
scope="col"
|
||||
className={twMerge(
|
||||
"w-1/4 px-6 py-4 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<React.Fragment key={section.name || `section-${sectionIdx}`}>
|
||||
{section.name && (
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"border-t px-6 py-3 text-left text-sm font-semibold text-dark-900 dark:text-dark-900",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-400 dark:bg-dark-50",
|
||||
)}
|
||||
>
|
||||
{plan.id === "free" ? section.name : ""}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
{section.features.map((feature) => (
|
||||
<tr key={feature.key}>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"w-1/4 border-t px-6 py-3 text-left",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 dark:border-dark-400",
|
||||
)}
|
||||
>
|
||||
<CellValue
|
||||
value={getFeatureValue(feature, plan.id)}
|
||||
label={feature.label}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="-mt-6 space-y-12">
|
||||
{sections.map((section) => (
|
||||
<div key={section.name}>
|
||||
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{section.name}
|
||||
</h4>
|
||||
<div className="relative -mx-8 mt-8">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table className="relative w-full border-separate border-spacing-x-8">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th scope="col">
|
||||
<span className="sr-only">{t`Feature`}</span>
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<th key={p.id} scope="col">
|
||||
<span className="sr-only">{p.name}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.features.map((feature, featureIdx) => (
|
||||
<tr key={feature.key}>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
|
||||
>
|
||||
{feature.label}
|
||||
{featureIdx !== section.features.length - 1 ? (
|
||||
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
|
||||
) : null}
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<td
|
||||
key={p.id}
|
||||
className="relative w-1/3 px-4 py-0 text-center"
|
||||
>
|
||||
<span className="relative inline-flex w-full items-center justify-center py-3">
|
||||
<CellValue
|
||||
value={
|
||||
p.id === "kan" ? feature.kan : feature.trello
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,115 +23,81 @@ const Pricing = ({
|
||||
}) => {
|
||||
const tiers = [
|
||||
{
|
||||
name: t`Free`,
|
||||
id: "tier-free",
|
||||
name: t`Individuals`,
|
||||
id: "tier-individuals",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: t`$0`, annually: t`$0` },
|
||||
description: t`Free for everyone`,
|
||||
bestFor: t`Best for individuals and solo creators getting started`,
|
||||
featureHeader: undefined,
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: t`Free`, annually: t`Free` },
|
||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
||||
featureHeader: t`Free, forever`,
|
||||
features: [
|
||||
{ text: t`1 user` },
|
||||
{ text: t`Unlimited boards` },
|
||||
{ text: t`Unlimited cards` },
|
||||
{ text: t`Custom board templates` },
|
||||
{ text: t`Checklists` },
|
||||
t`1 user`,
|
||||
t`Unlimited boards`,
|
||||
t`Unlimited cards`,
|
||||
t`Custom board templates`,
|
||||
t`Checklists`,
|
||||
],
|
||||
showPrice: true,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$10", annually: "$8" },
|
||||
description: t`Perfect for small and growing teams looking to collaborate.`,
|
||||
bestFor: t`Best for small and growing teams that need collaboration`,
|
||||
featureHeader: undefined,
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
||||
featureHeader: t`Everything in the free plan, plus:`,
|
||||
features: [
|
||||
{ text: t`All Free features +` },
|
||||
{ text: t`Workspace members` },
|
||||
{ text: t`Invite links` },
|
||||
{ text: t`Unlimited file uploads` },
|
||||
{ text: t`Email notifications for mentions` },
|
||||
{ text: t`Priority email support` },
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: false,
|
||||
},
|
||||
{
|
||||
name: t`Pro`,
|
||||
id: "tier-pro",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$29", annually: "$24" },
|
||||
description: t`Unlimited seats and advanced features for growing teams.`,
|
||||
bestFor: t`Best for teams that want to scale without limits`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
{ text: t`All Teams features +` },
|
||||
{ text: t`Unlimited members` },
|
||||
{ text: t`Custom workspace username` },
|
||||
{ text: t`Configurable user roles and permissions` },
|
||||
{ text: t`Custom branding` },
|
||||
t`Workspace members`,
|
||||
t`Admin roles`,
|
||||
t`Priority email support`,
|
||||
t`Support the development of the project`,
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: true,
|
||||
showPriceSuffix: true,
|
||||
},
|
||||
{
|
||||
name: t`Enterprise`,
|
||||
id: "tier-enterprise",
|
||||
href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
|
||||
buttonText: t`Contact Sales`,
|
||||
price: { monthly: t`Contact us`, annually: t`Contact us` },
|
||||
description: t`Advanced security, compliance, and dedicated support for large organizations.`,
|
||||
bestFor: t`Best for large organizations with advanced needs`,
|
||||
featureHeader: undefined,
|
||||
name: t`Self Host`,
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: t`View docs`,
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
||||
featureHeader: t`Complete control and ownership:`,
|
||||
features: [
|
||||
{ text: t`All Pro features +` },
|
||||
{ text: t`SAML SSO` },
|
||||
{ text: t`Advanced admin controls` },
|
||||
{ text: t`Dedicated account manager` },
|
||||
{ text: t`Custom SLA` },
|
||||
{ text: t`On-premise deployment option` },
|
||||
t`Run on your own infrastructure`,
|
||||
t`Own your data`,
|
||||
t`Custom domain`,
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="mb-8 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="mt-14 flex flex-col items-center justify-center">
|
||||
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||
</div>
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
onChange={(value) => setFrequency(value)}
|
||||
className="flex gap-1 rounded-lg border border-light-300 bg-light-50 p-1 dark:border-dark-300 dark:bg-dark-50"
|
||||
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
|
||||
>
|
||||
{frequencies.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option}
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm",
|
||||
frequency?.value === option.value
|
||||
? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "text-light-600 dark:text-dark-700",
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -140,129 +106,103 @@ const Pricing = ({
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="isolate mx-auto mb-10 grid max-w-md grid-cols-1 gap-8 px-4 lg:mx-0 lg:max-w-none lg:grid-cols-3">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={twMerge(
|
||||
"relative flex h-full flex-col rounded-lg border p-6",
|
||||
tier.mostPopular
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
|
||||
tier.highlighted
|
||||
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800"
|
||||
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
|
||||
"rounded-3xl p-8 xl:p-10",
|
||||
)}
|
||||
>
|
||||
{tier.id === "tier-pro" && (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-light-400 bg-light-100 px-3 py-1 text-center text-xs text-dark-600 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||
<HiBolt className="h-3 w-3 -ml-0.5" />
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
id={tier.id}
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<h3
|
||||
id={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "text-dark-1000 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
"text-lg/8 font-semibold",
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
{tier.highlighted && (
|
||||
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
|
||||
{t`14 day free trial`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mb-4 flex items-center text-base font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900",
|
||||
tier.highlighted ? "text-light-100" : "text-dark-50",
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
{tier.id === "tier-pro" && <span className="ml-1 text-xl leading-none">∞</span>}
|
||||
</h3>
|
||||
<div className="mb-4">
|
||||
<p className="flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className="mt-6 flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-3xl font-semibold tracking-tight text-light-100",
|
||||
tier.highlighted
|
||||
? "text-light-50 dark:text-dark-1000"
|
||||
: "text-gray-900 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
{tier.id === "tier-pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold line-through",
|
||||
tier.mostPopular
|
||||
? "text-light-600 dark:text-dark-600"
|
||||
: "text-light-600 dark:text-dark-600",
|
||||
)}
|
||||
>
|
||||
{frequency?.value === "annually" ? "$79" : "$100"}
|
||||
</span>
|
||||
)}
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
{tier.id === "tier-pro" && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{t`/month`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{tier.bestFor && (
|
||||
<p className="mb-6 text-sm text-dark-400 dark:text-dark-700">
|
||||
{tier.bestFor}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
{tier.id === "tier-enterprise" ? (
|
||||
<a
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className="block w-full rounded-md border border-dark-200 bg-transparent px-4 py-2 text-center text-sm font-medium text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200"
|
||||
>
|
||||
{tier.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
"block w-full rounded-md px-4 py-2 text-center text-sm font-medium transition-colors",
|
||||
tier.mostPopular
|
||||
? "bg-dark-50 text-light-50 shadow-sm hover:bg-dark-100 dark:bg-dark-1000 dark:text-dark-50 dark:hover:bg-dark-900"
|
||||
: "border border-dark-200 bg-transparent text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ul role="list" className="space-y-2.5">
|
||||
{tier.features.map((feature, index) => (
|
||||
<li
|
||||
key={`${tier.id}-feature-${index}`}
|
||||
className="flex items-start gap-x-2.5"
|
||||
>
|
||||
<HiCheckCircle
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
|
||||
/>
|
||||
<span className="text-sm text-dark-600 dark:text-dark-800">
|
||||
{feature.text}
|
||||
</span>
|
||||
</p>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
|
||||
: "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
|
||||
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-8 text-sm/6 font-bold",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.featureHeader}
|
||||
</p>
|
||||
<ul
|
||||
role="list"
|
||||
className={twMerge(
|
||||
"mt-2 space-y-3 text-sm/6 text-light-600",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-x-3">
|
||||
<HiCheckCircle className="h-5 w-5" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto mt-8 max-w-3xl px-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<p>{t`14 day free trial · Switch plans or cancel anytime`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function PricingView() {
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-6 max-w-[300px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
|
||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
</div>
|
||||
<PricingTiers
|
||||
@@ -59,9 +59,23 @@ export default function PricingView() {
|
||||
</div>
|
||||
|
||||
<div className="pb-22 flex flex-col items-center justify-center px-4">
|
||||
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Features`}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full">
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Feature breakdown`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Compare our features to see why Kan is the best choice.`}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button variant="primary" href="/signup">
|
||||
{t`Get started`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full max-w-[900px]">
|
||||
<FeatureComparisonTable
|
||||
frequencyValue={frequency?.value ?? "annually"}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function CardModal({
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = api.card.byId.useQuery(
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId: cardPublicId ?? "",
|
||||
},
|
||||
@@ -56,17 +56,6 @@ export function CardModal({
|
||||
},
|
||||
);
|
||||
|
||||
// Redirect to 404 if card doesn't exist
|
||||
useEffect(() => {
|
||||
if (isOpen && cardPublicId && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading && error)) {
|
||||
// Close modal first, then redirect
|
||||
closeModal();
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [isOpen, cardPublicId, isLoading, error, data, closeModal, router]);
|
||||
|
||||
const labels = data?.labels ?? [];
|
||||
|
||||
const handleScroll = () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
@@ -13,22 +12,13 @@ export default function PublicBoardsView() {
|
||||
? router.query.workspaceSlug[0]
|
||||
: router.query.workspaceSlug;
|
||||
|
||||
const { data, isLoading, error } = api.workspace.bySlug.useQuery(
|
||||
const { data, isLoading } = api.workspace.bySlug.useQuery(
|
||||
{
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
},
|
||||
{ enabled: !!workspaceSlug },
|
||||
);
|
||||
|
||||
// Redirect to 404 if workspace doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && workspaceSlug && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, workspaceSlug, isLoading, error, data]);
|
||||
|
||||
const BoardsList = ({
|
||||
isLoading,
|
||||
boards,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Input from "~/components/Input";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -15,28 +11,10 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const githubTokenSchema = z.object({
|
||||
token: z.string().min(1, { message: t`Token is required` }),
|
||||
});
|
||||
|
||||
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
|
||||
|
||||
export default function IntegrationsSettings() {
|
||||
const { modalContentType, isOpen } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, errors },
|
||||
reset,
|
||||
} = useForm<GitHubTokenFormValues>({
|
||||
resolver: zodResolver(githubTokenSchema),
|
||||
defaultValues: {
|
||||
token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: integrations,
|
||||
refetch: refetchIntegrations,
|
||||
@@ -56,25 +34,21 @@ export default function IntegrationsSettings() {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: githubStatus, refetch: refetchGithubStatus } =
|
||||
api.integration.getGitHubStatus.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
void refetchIntegrations();
|
||||
void refetchGithubStatus();
|
||||
refetchIntegrations();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations, refetchGithubStatus]);
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
const { mutateAsync: disconnectTrello } =
|
||||
api.integration.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchIntegrations();
|
||||
void refetchTrelloUrl();
|
||||
refetchIntegrations();
|
||||
refetchTrelloUrl();
|
||||
showPopup({
|
||||
header: t`Trello disconnected`,
|
||||
message: t`Your Trello account has been disconnected.`,
|
||||
@@ -90,49 +64,6 @@ export default function IntegrationsSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: saveGithubToken, isPending: isSavingGithubToken } =
|
||||
api.integration.saveGitHubToken.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchGithubStatus();
|
||||
reset();
|
||||
showPopup({
|
||||
header: t`GitHub connected`,
|
||||
message: t`Your GitHub account has been connected.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error connecting GitHub`,
|
||||
message: t`An error occurred while connecting your GitHub account.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmitGithubToken = (data: GitHubTokenFormValues) => {
|
||||
void saveGithubToken({ token: data.token });
|
||||
};
|
||||
|
||||
const { mutateAsync: disconnectGithub } =
|
||||
api.integration.disconnectGitHub.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchGithubStatus();
|
||||
showPopup({
|
||||
header: t`GitHub disconnected`,
|
||||
message: t`Your GitHub account has been disconnected.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error disconnecting GitHub`,
|
||||
message: t`An error occurred while disconnecting your GitHub account.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Integrations`} />
|
||||
@@ -181,51 +112,6 @@ export default function IntegrationsSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`GitHub`}
|
||||
</h2>
|
||||
{!githubStatus?.connected ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Connect your GitHub account to import projects.`}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmitGithubToken)}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Personal Access Token"
|
||||
{...register("token")}
|
||||
errorMessage={errors.token?.message}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={!isDirty || isSavingGithubToken}
|
||||
isLoading={isSavingGithubToken}
|
||||
>
|
||||
{t`Connect GitHub`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Your GitHub account is connected.`}
|
||||
</p>
|
||||
<Button variant="secondary" onClick={() => disconnectGithub()}>
|
||||
{t`Disconnect GitHub`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation";
|
||||
import { NewWebhookModal } from "./components/NewWebhookModal";
|
||||
import WebhookList from "./components/WebhookList";
|
||||
|
||||
export default function WebhookSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Webhooks`} />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Webhooks`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`}
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="primary" onClick={() => openModal("NEW_WEBHOOK")}>
|
||||
{t`Add webhook`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WebhookList workspacePublicId={workspace.publicId} />
|
||||
</div>
|
||||
|
||||
{/* Webhook-specific modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_WEBHOOK"}
|
||||
>
|
||||
<NewWebhookModal workspacePublicId={workspace.publicId} />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "EDIT_WEBHOOK"}
|
||||
>
|
||||
<NewWebhookModal workspacePublicId={workspace.publicId} isEdit />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_WEBHOOK"}
|
||||
>
|
||||
<DeleteWebhookConfirmation workspacePublicId={workspace.publicId} />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
@@ -87,15 +86,6 @@ export default function WorkspaceSettings() {
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Week start day`}
|
||||
</h2>
|
||||
<UpdateWeekStartDayForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
weekStartDay={workspaceData?.weekStartDay ?? 1}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email visibility`}
|
||||
</h2>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface DeleteWebhookConfirmationProps {
|
||||
workspacePublicId: string;
|
||||
}
|
||||
|
||||
export function DeleteWebhookConfirmation({
|
||||
workspacePublicId,
|
||||
}: DeleteWebhookConfirmationProps) {
|
||||
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWebhookMutation = api.webhook.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to delete webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!webhookPublicId) return;
|
||||
deleteWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`Delete webhook`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 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>
|
||||
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-end gap-3 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
isLoading={deleteWebhookMutation.isPending}
|
||||
>
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { webhookEvents } from "@kan/db/schema";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const newWebhookSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Webhook name is required` })
|
||||
.max(255, { message: t`Webhook name cannot exceed 255 characters` }),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, { message: t`Webhook URL is required` })
|
||||
.url({ message: t`Please enter a valid URL` })
|
||||
.max(2048, { message: t`URL cannot exceed 2048 characters` }),
|
||||
secret: z
|
||||
.string()
|
||||
.max(512, { message: t`Secret cannot exceed 512 characters` })
|
||||
.optional(),
|
||||
events: z
|
||||
.array(z.enum(webhookEvents))
|
||||
.min(1, { message: t`Select at least one event` }),
|
||||
active: z.boolean(),
|
||||
});
|
||||
|
||||
type WebhookFormData = z.infer<typeof newWebhookSchema>;
|
||||
|
||||
interface NewWebhookModalProps {
|
||||
workspacePublicId: string;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export function NewWebhookModal({
|
||||
workspacePublicId,
|
||||
isEdit = false,
|
||||
}: NewWebhookModalProps) {
|
||||
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
|
||||
|
||||
const modalState = isEdit ? getModalState("EDIT_WEBHOOK") : null;
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<WebhookFormData>({
|
||||
resolver: zodResolver(newWebhookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [...webhookEvents],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && webhookPublicId && modalState) {
|
||||
reset({
|
||||
name: modalState.name ?? "",
|
||||
url: modalState.url ?? "",
|
||||
secret: "",
|
||||
events: modalState.events ?? ["card.created"],
|
||||
active: modalState.active ?? true,
|
||||
});
|
||||
} else if (!isEdit) {
|
||||
reset({
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [...webhookEvents],
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}, [isEdit, webhookPublicId, modalState, reset]);
|
||||
|
||||
// Clear modal state when closing
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isEdit) {
|
||||
clearModalState("EDIT_WEBHOOK");
|
||||
}
|
||||
};
|
||||
}, [isEdit, clearModalState]);
|
||||
|
||||
const createWebhookMutation = api.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook created successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to create webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateWebhookMutation = api.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook updated successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to update webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
});
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: WebhookFormData) => {
|
||||
if (isEdit && webhookPublicId) {
|
||||
updateWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
events: data.events,
|
||||
active: data.active,
|
||||
});
|
||||
} else {
|
||||
createWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
events: data.events,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestWebhook = () => {
|
||||
if (!webhookPublicId) return;
|
||||
setIsTestingWebhook(true);
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">
|
||||
{isEdit ? t`Edit webhook` : t`New webhook`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Name`}
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`My webhook`}
|
||||
{...register("name")}
|
||||
errorMessage={errors.name?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`URL`}
|
||||
</label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://example.com/webhook"
|
||||
{...register("url")}
|
||||
errorMessage={errors.url?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Secret (optional)`}
|
||||
</label>
|
||||
<Input
|
||||
id="secret"
|
||||
type="password"
|
||||
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
|
||||
{...register("secret")}
|
||||
errorMessage={errors.secret?.message}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-dark-800">
|
||||
{t`Used to sign webhook payloads for verification. Leave blank to keep existing secret.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Events`}
|
||||
</label>
|
||||
<Controller
|
||||
name="events"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2">
|
||||
{webhookEvents.map((event) => (
|
||||
<label
|
||||
key={event}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value.includes(event)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
field.onChange([...field.value, event]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value.filter((v) => v !== event)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{event}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.events && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("active")}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Active`}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
{isEdit && webhookPublicId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTestWebhook}
|
||||
isLoading={isTestingWebhook}
|
||||
>
|
||||
{t`Send test`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEdit ? t`Save changes` : t`Create webhook`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function UpdateWeekStartDayForm({
|
||||
workspacePublicId,
|
||||
weekStartDay,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
weekStartDay: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [value, setValue] = useState(weekStartDay);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(weekStartDay);
|
||||
}, [weekStartDay]);
|
||||
|
||||
const updateWorkspace = api.workspace.update.useMutation({
|
||||
onSuccess: () => {
|
||||
if (workspacePublicId && workspacePublicId.length >= 12) {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
void utils.workspace.all.invalidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (disabled) return;
|
||||
const newValue = Number(e.target.value);
|
||||
setValue(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
weekStartDay: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<select
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled || updateWorkspace.isPending}
|
||||
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
|
||||
>
|
||||
<option value={0}>{t`Sunday`}</option>
|
||||
<option value={1}>{t`Monday`}</option>
|
||||
<option value={6}>{t`Saturday`}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
}),
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { HiEllipsisHorizontal } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface TableRowProps {
|
||||
publicId?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
events?: string[];
|
||||
active?: boolean;
|
||||
createdAt?: Date | null;
|
||||
dateLocale?: DateFnsLocale;
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
onEdit?: () => void;
|
||||
onTest?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function formatEvents(events: string[]) {
|
||||
return events
|
||||
.map((e) => e.replace("card.", ""))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatDate(date?: Date | null, locale?: DateFnsLocale) {
|
||||
if (!date) return "Never";
|
||||
return format(date, "MMM d, yyyy", { locale });
|
||||
}
|
||||
|
||||
function TableRow({
|
||||
publicId,
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
active,
|
||||
createdAt,
|
||||
dateLocale,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
onEdit,
|
||||
onTest,
|
||||
onDelete,
|
||||
}: TableRowProps) {
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td className={twMerge("w-[25%]", isLastRow ? "rounded-bl-lg" : "")}>
|
||||
<div className="flex items-center p-4">
|
||||
<div className="ml-2 min-w-0 flex-1">
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={twMerge(
|
||||
"mr-2 text-sm font-medium text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[30%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[180px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[20%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[100px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{events && formatEvents(events)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[10%] px-3 py-4">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset",
|
||||
active
|
||||
? "bg-emerald-500/10 text-emerald-400 ring-emerald-500/20"
|
||||
: "bg-gray-500/10 text-gray-400 ring-gray-500/20",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{!showSkeleton && (active ? t`Active` : t`Inactive`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="w-[10%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{formatDate(createdAt, dateLocale)}
|
||||
</p>
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-[5%] min-w-[50px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
{!showSkeleton && (
|
||||
<div className="flex w-full items-center justify-center px-3">
|
||||
<div className="relative z-50">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit`,
|
||||
action: () => onEdit?.(),
|
||||
},
|
||||
{
|
||||
label: t`Test`,
|
||||
action: () => onTest?.(),
|
||||
},
|
||||
{
|
||||
label: t`Delete`,
|
||||
action: () => onDelete?.(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={25}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface WebhookListProps {
|
||||
workspacePublicId: string;
|
||||
}
|
||||
|
||||
export default function WebhookList({ workspacePublicId }: WebhookListProps) {
|
||||
const { openModal, setModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { dateLocale } = useLocalisation();
|
||||
|
||||
const { data: webhooks, isLoading } = api.webhook.list.useQuery({
|
||||
workspacePublicId,
|
||||
});
|
||||
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoading && (!webhooks || webhooks.length === 0)) {
|
||||
return (
|
||||
<div className="rounded-lg border border-light-300 bg-light-50 p-8 text-center dark:border-dark-300 dark:bg-dark-100">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`No webhooks configured. Add a webhook to receive notifications.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<div className="inline-block min-w-full py-2 pb-12 align-middle">
|
||||
<div className="relative h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-[700px] divide-y divide-light-600 dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[25%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
|
||||
>
|
||||
{t`Name`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[30%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`URL`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Events`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Status`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Created`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[5%] rounded-tr-lg px-3 py-3.5 text-center text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{/* Actions column */}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
webhooks?.map((webhook, index) => (
|
||||
<TableRow
|
||||
key={webhook.publicId}
|
||||
publicId={webhook.publicId}
|
||||
name={webhook.name}
|
||||
url={webhook.url}
|
||||
events={webhook.events}
|
||||
active={webhook.active}
|
||||
createdAt={webhook.createdAt}
|
||||
dateLocale={dateLocale}
|
||||
isLastRow={index === webhooks.length - 1}
|
||||
onEdit={() => {
|
||||
setModalState("EDIT_WEBHOOK", {
|
||||
publicId: webhook.publicId,
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
});
|
||||
openModal("EDIT_WEBHOOK", webhook.publicId, webhook.name);
|
||||
}}
|
||||
onTest={() => {
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhook.publicId,
|
||||
});
|
||||
}}
|
||||
onDelete={() => {
|
||||
openModal("DELETE_WEBHOOK", webhook.publicId, webhook.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton isLastRow />
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,4 @@
|
||||
services:
|
||||
migrator:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: migrate
|
||||
networks:
|
||||
- dokploy-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-web}
|
||||
@@ -22,7 +9,6 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: web
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-}
|
||||
env_file:
|
||||
@@ -37,9 +23,6 @@ services:
|
||||
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
|
||||
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||
- LOG_LEVEL=${LOG_LEVEL}
|
||||
|
||||
# Stripe
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
@@ -69,7 +52,6 @@ services:
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
@@ -97,9 +79,6 @@ services:
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||
depends_on:
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-migrate}
|
||||
networks:
|
||||
- kan-network
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
target: migrate
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-web}
|
||||
@@ -22,10 +6,10 @@ services:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
- dokploy-network
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
target: web
|
||||
dockerfile: ./apps/web/Dockerfile.new
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -37,9 +21,6 @@ services:
|
||||
# Redis (optional - for rate limiting)
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
|
||||
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||
- LOG_LEVEL=${LOG_LEVEL}
|
||||
|
||||
# Admin API key (optional)
|
||||
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
|
||||
|
||||
@@ -60,7 +41,6 @@ services:
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
@@ -127,8 +107,7 @@ services:
|
||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
@@ -142,17 +121,14 @@ services:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
|
||||
networks:
|
||||
kan-network:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
kan_postgres_data:
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
||||
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
|
||||
import { drizzle } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import * as schema from "@kan/db/schema";
|
||||
|
||||
export type TestDbClient = NodePgDatabase<typeof schema> & {
|
||||
$client: Pool;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fresh in-memory PGlite database for testing.
|
||||
* Each call returns an isolated database instance with migrations applied.
|
||||
*/
|
||||
export async function createTestDb(): Promise<TestDbClient> {
|
||||
const client = new PGlite({
|
||||
extensions: { uuid_ossp, pg_trgm },
|
||||
});
|
||||
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
// Run migrations
|
||||
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
||||
|
||||
return db as unknown as TestDbClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a test database with a workspace and user for testing.
|
||||
* Returns the created entities for use in tests.
|
||||
*/
|
||||
export async function seedTestData(db: TestDbClient) {
|
||||
// Create a test user
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create a test workspace (publicId must be exactly 12 chars)
|
||||
const [workspace] = await db
|
||||
.insert(schema.workspaces)
|
||||
.values({
|
||||
publicId: "wstest123456",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
ownerId: user!.id,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Add user as admin member of workspace
|
||||
await db.insert(schema.workspaceMembers).values({
|
||||
publicId: "wm1234567890",
|
||||
email: user!.email,
|
||||
workspaceId: workspace!.id,
|
||||
userId: user!.id,
|
||||
createdBy: user!.id,
|
||||
role: "admin",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
return { user: user!, workspace: workspace! };
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
|
||||
|
||||
describe("webhook repository integration tests", () => {
|
||||
let db: TestDbClient;
|
||||
let testUser: { id: string; name: string | null };
|
||||
let testWorkspace: { id: number; publicId: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb();
|
||||
const seeded = await seedTestData(db);
|
||||
testUser = seeded.user;
|
||||
testWorkspace = seeded.workspace;
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a webhook with all fields", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "My Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
secret: "my-secret",
|
||||
events: ["card.created", "card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("My Webhook");
|
||||
expect(webhook!.url).toBe("https://example.com/webhook");
|
||||
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
|
||||
expect(webhook!.active).toBe(true);
|
||||
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
|
||||
});
|
||||
|
||||
it("creates a webhook without secret", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "No Secret Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.deleted"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("No Secret Webhook");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getByPublicId", () => {
|
||||
it("retrieves a webhook by public ID", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.publicId).toBe(created!.publicId);
|
||||
expect(retrieved!.name).toBe("Test Webhook");
|
||||
});
|
||||
|
||||
it("returns null for non-existent public ID", async () => {
|
||||
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllByWorkspaceId", () => {
|
||||
it("returns all webhooks for a workspace", async () => {
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 1",
|
||||
url: "https://example.com/webhook1",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 2",
|
||||
url: "https://example.com/webhook2",
|
||||
events: ["card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toHaveLength(2);
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
|
||||
});
|
||||
|
||||
it("returns empty array for workspace with no webhooks", async () => {
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveByWorkspaceId", () => {
|
||||
it("returns only active webhooks", async () => {
|
||||
const active = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Active Webhook",
|
||||
url: "https://example.com/active",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const inactive = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Inactive Webhook",
|
||||
url: "https://example.com/inactive",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// Deactivate one webhook
|
||||
await webhookRepo.update(db, inactive!.publicId, { active: false });
|
||||
|
||||
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(activeWebhooks).toHaveLength(1);
|
||||
// getActiveByWorkspaceId returns only publicId, url, secret, events
|
||||
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
|
||||
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates webhook name", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Original Name",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.name).toBe("Updated Name");
|
||||
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
|
||||
});
|
||||
|
||||
it("updates webhook events", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
events: ["card.created", "card.updated", "card.deleted"],
|
||||
});
|
||||
|
||||
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
|
||||
});
|
||||
|
||||
it("updates webhook active status", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(created!.active).toBe(true);
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
active: false,
|
||||
});
|
||||
|
||||
expect(updated!.active).toBe(false);
|
||||
});
|
||||
|
||||
it("sets updatedAt timestamp on update", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// create() doesn't return updatedAt, verify via getByPublicId
|
||||
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(initial!.updatedAt).toBeNull();
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated!.updatedAt).not.toBeNull();
|
||||
expect(updated!.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns null for non-existent webhook", async () => {
|
||||
const updated = await webhookRepo.update(db, "nonexistent12", {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hardDelete", () => {
|
||||
it("deletes a webhook permanently", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "To Be Deleted",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.hardDelete(db, created!.publicId);
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw for non-existent webhook", async () => {
|
||||
await expect(
|
||||
webhookRepo.hardDelete(db, "nonexistent12"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,15 +40,12 @@
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:*",
|
||||
"@kan/email": "workspace:^",
|
||||
"@kan/logger": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@kan/stripe": "workspace:^",
|
||||
"@trpc/server": "catalog:",
|
||||
@@ -63,8 +60,7 @@
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
import { permissionRouter } from "./routers/permission";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { webhookRouter } from "./routers/webhook";
|
||||
import { workspaceRouter } from "./routers/workspace";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -28,7 +27,6 @@ export const appRouter = createTRPCRouter({
|
||||
import: importRouter,
|
||||
permission: permissionRouter,
|
||||
user: userRouter,
|
||||
webhook: webhookRouter,
|
||||
workspace: workspaceRouter,
|
||||
integration: integrationRouter,
|
||||
});
|
||||
|
||||
@@ -142,18 +142,9 @@ export const attachmentRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new TRPCError({
|
||||
message: "Failed to create attachment",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: input.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
@@ -215,8 +206,6 @@ export const attachmentRouter = createTRPCRouter({
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.removed",
|
||||
cardId: attachment.cardId,
|
||||
attachmentId: attachment.id,
|
||||
fromTitle: attachment.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ export const boardRouter = createTRPCRouter({
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
archived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
@@ -66,10 +65,7 @@ export const boardRouter = createTRPCRouter({
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
userId,
|
||||
{
|
||||
type: input.type,
|
||||
archived: input.archived ?? false,
|
||||
}
|
||||
{ type: input.type }
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -157,51 +153,28 @@ export const boardRouter = createTRPCRouter({
|
||||
// Generate presigned URLs for workspace member avatars
|
||||
const workspaceWithAvatarUrls = result.workspace
|
||||
? {
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
: result.workspace;
|
||||
|
||||
// Generate presigned URLs for card member avatars
|
||||
const listsWithAvatarUrls = await Promise.all(
|
||||
result.lists.map(async (list) => ({
|
||||
...list,
|
||||
cards: await Promise.all(
|
||||
list.cards.map(async (card) => ({
|
||||
...card,
|
||||
members: await Promise.all(
|
||||
card.members.map(async (member) => {
|
||||
if (!member.user?.image) return member;
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: { ...member.user, image: avatarUrl },
|
||||
};
|
||||
}),
|
||||
),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
lists: listsWithAvatarUrls,
|
||||
workspace: workspaceWithAvatarUrls,
|
||||
};
|
||||
}),
|
||||
@@ -222,7 +195,7 @@ export const boardRouter = createTRPCRouter({
|
||||
workspaceSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardSlug: z
|
||||
.string()
|
||||
@@ -465,8 +438,7 @@ export const boardRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
visibility: z.enum(["public", "private"]).optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
favorite: z.boolean().optional()
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||
@@ -508,7 +480,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Handle other updates (name, slug, visibility)
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined;
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
|
||||
|
||||
if (!hasOtherUpdates) {
|
||||
// Only favorite was updated, return success
|
||||
@@ -535,7 +507,6 @@ export const boardRouter = createTRPCRouter({
|
||||
slug: input.slug,
|
||||
boardPublicId: input.boardPublicId,
|
||||
visibility: input.visibility,
|
||||
isArchived: input.isArchived,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
|
||||
@@ -13,10 +13,6 @@ import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
import {
|
||||
createCardWebhookPayload,
|
||||
sendWebhooksForWorkspace,
|
||||
} from "../utils/webhook";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -169,32 +165,6 @@ export const cardRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
list.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: String(newCard.id),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate ?? null,
|
||||
listId: String(newCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: list.boardPublicId,
|
||||
boardName: list.boardName,
|
||||
listName: list.name,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
|
||||
return newCard;
|
||||
}),
|
||||
addComment: protectedProcedure
|
||||
@@ -1037,59 +1007,6 @@ export const cardRouter = createTRPCRouter({
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
// Build changes object for webhook
|
||||
const webhookChanges: Record<string, { from: unknown; to: unknown }> = {};
|
||||
if (input.title && existingCard.title !== input.title) {
|
||||
webhookChanges.title = { from: existingCard.title, to: input.title };
|
||||
}
|
||||
if (input.description && existingCard.description !== input.description) {
|
||||
webhookChanges.description = {
|
||||
from: existingCard.description,
|
||||
to: input.description,
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.dueDate !== undefined &&
|
||||
previousDueDate?.getTime() !== input.dueDate?.getTime()
|
||||
) {
|
||||
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
|
||||
}
|
||||
if (newListId && existingCard.listId !== newListId) {
|
||||
webhookChanges.listId = { from: existingCard.listId, to: newListId };
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
newListId && existingCard.listId !== newListId
|
||||
? "card.moved"
|
||||
: "card.updated",
|
||||
{
|
||||
id: String(result.id),
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
dueDate: result.dueDate,
|
||||
listId: String(newListId ?? existingCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
changes:
|
||||
Object.keys(webhookChanges).length > 0
|
||||
? webhookChanges
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
@@ -1137,9 +1054,6 @@ export const cardRouter = createTRPCRouter({
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
// Fetch full card data before delete for webhook
|
||||
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
await cardRepo.softDelete(ctx.db, {
|
||||
@@ -1154,34 +1068,6 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
if (fullCard) {
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
"card.deleted",
|
||||
{
|
||||
id: String(fullCard.id),
|
||||
title: fullCard.title,
|
||||
description: fullCard.description,
|
||||
dueDate: fullCard.dueDate,
|
||||
listId: String(fullCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user