Compare commits

..

9 Commits

Author SHA1 Message Date
Henry
f797af64e7 feat: update comments to use tiptap editor 2026-02-08 22:35:42 +00:00
Henry
1f0c780763 feat: skip pending members in sendMentionEmails 2026-02-08 22:15:26 +00:00
Henry
149277ef39 fix: update the editor suggestion to show all members 2026-02-08 22:12:23 +00:00
Henry
c8dcb17510 feat: update card router to send emails on mention 2026-02-07 22:04:31 +00:00
Henry
2f4aea52eb feat: add repo funcs 2026-02-07 22:00:55 +00:00
Henry
7973d8de2f feat: add sendMentionEmail func 2026-02-07 21:58:52 +00:00
Henry
7a0f89c4c0 feat: add mention email template 2026-02-07 21:42:24 +00:00
Henry
105ba45412 chore: gen migration 2026-02-07 21:41:29 +00:00
Henry
3c46d82b7a feat: add notifications schema 2026-02-07 21:41:13 +00:00
168 changed files with 27521 additions and 89003 deletions

View File

@@ -1,62 +1,18 @@
# Environment
.env .env
.env.*
!.env.example
# Docker docker-compose.override.yml
docker-compose*.yml
Dockerfile
./**/*/Dockerfile
.dockerignore .dockerignore
# Dependencies (rebuilt in Docker)
node_modules 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 ./**/*/pnpm-debug.log
# Cloud compose (separate deployment) README.md
cloud/ .next
# .git

View File

@@ -2,7 +2,7 @@
# Required environment variables # Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 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 # Fill if you want to use an external database
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
@@ -28,14 +28,12 @@ S3_ENDPOINT=
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE= S3_FORCE_PATH_STYLE=
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
NEXT_PUBLIC_STORAGE_URL= NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME= NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME= NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN= NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS= NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
# Auth config (optional) # Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS= NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP= NEXT_PUBLIC_DISABLE_SIGN_UP=
@@ -51,9 +49,6 @@ TRELLO_APP_SECRET=
# If not provided, rate limiting will use in-memory storage # If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379 REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
LOG_LEVEL=
# OAuth providers (optional) # OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS= BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated) # Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)

View File

@@ -6,31 +6,23 @@ name: Docker
# documentation. # documentation.
on: on:
schedule:
- cron: "21 21 * * *"
push: push:
# Only trigger on tags (releases), not main branch pushes branches: ["main"]
# Main branch builds are handled by workflow_run after Translate completes # Publish semver tags as releases.
tags: ["v*.*.*"] tags: ["v*.*.*"]
pull_request: pull_request:
branches: ["main"] 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: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io REGISTRY: ghcr.io
# github.repository as <account>/<repo> # github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs: jobs:
build: build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -82,22 +74,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }} type=raw,value=latest,enable={{is_default_branch}}
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}}
# Extract version from git tag or ref # Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234 # Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
@@ -152,22 +129,6 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # 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 # 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 # This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance. # against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 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}

View File

@@ -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

2
.gitignore vendored
View File

@@ -54,5 +54,3 @@ i18n.cache
# pgdata # pgdata
/apps/web/pgdata /apps/web/pgdata
.claude

View File

@@ -185,17 +185,9 @@ Use `assertUserInWorkspace` helper for workspace checks.
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.) - Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
- Provide user-friendly error messages - Provide user-friendly error messages
- Log errors appropriately using the `@kan/logger` package - Log errors appropriately
- Show popup notifications for user-facing errors - Show popup notifications for user-facing errors
### Logging
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
- Create a module-scoped logger: `const logger = createLogger("module-name")`
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
- Defaults to `debug` in development, `info` in production
- Never use `console.log` — always use the logger
## Common Patterns ## Common Patterns
### Creating a Card ### Creating a Card
@@ -227,16 +219,6 @@ Use `assertUserInWorkspace` helper for workspace checks.
5. **Frontend**: Add UI components in `apps/web/src/` 5. **Frontend**: Add UI components in `apps/web/src/`
6. **i18n**: Add translations for new strings 6. **i18n**: Add translations for new strings
## Adding a New Environment Variable
Update all of the following:
1. `.env.example` — add the variable with an empty value and a comment explaining it
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
3. `docker-compose.yml` — add to the `web` service `environment` section
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
5. `README.md` — add a row to the Environment Variables table
## Database Changes ## Database Changes
- Always create migrations (never modify existing migrations) - Always create migrations (never modify existing migrations)

View File

@@ -57,61 +57,39 @@ The easiest way to deploy Kan is through Railway. We've partnered with Railway t
### Docker Compose ### 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) 1. Create a new file called `docker-compose.yml` and paste the following configuration:
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
```yaml ```yaml
services: 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: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: kan-web container_name: kan-web
ports: ports:
- "${WEB_PORT:-3000}:3000" - "3000:3000"
networks: networks:
- kan-network - kan-network
env_file:
- .env
environment: environment:
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL} NEXT_PUBLIC_BASE_URL: http://localhost:3000
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} BETTER_AUTH_SECRET: your_auth_secret
- POSTGRES_URL=${POSTGRES_URL} POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on: depends_on:
migrate: - postgres
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: kan-db container_name: kan-db
environment: environment:
- POSTGRES_DB=kan_db POSTGRES_DB: kan_db
- POSTGRES_USER=kan POSTGRES_USER: kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} POSTGRES_PASSWORD: your_postgres_password
ports: ports:
- 5432:5432 - 5432:5432
volumes: volumes:
- kan_postgres_data:/var/lib/postgresql/data - 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 restart: unless-stopped
networks: networks:
- kan-network - kan-network
@@ -123,23 +101,23 @@ volumes:
kan_postgres_data: kan_postgres_data:
``` ```
3. Start the containers in detached mode: 2. Start the containers in detached mode:
```bash ```bash
docker compose up -d 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 stop the containers: `docker compose down`
- To view logs: `docker compose logs -f` - 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 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 🧑‍💻 ## Local Development 🧑‍💻
@@ -203,7 +181,6 @@ pnpm dev
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` | | `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_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_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_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_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` | | `NEXT_PUBLIC_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_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` | | `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` | | `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. See `.env.example` for a complete list of supported environment variables.

View File

@@ -11,10 +11,10 @@ https://kan.bn/api/v1
## Authentication ## Authentication
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request. Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
``` ```
'Authorization': 'Bearer kan_123456789' 'x-api-key': kan_123456789
``` ```
## Response codes ## Response codes

View File

@@ -1,23 +1,25 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=20 ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12 ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# ============================================ # 1. Alpine image
# Stage 1: Alpine base with pnpm and turbo
# ============================================
FROM node:${NODE_VERSION}-alpine AS alpine FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \ RUN apk update && \
apk add --no-cache libc6-compat && \ apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true && \ rm -rf /var/cache/apk/* /tmp/* || true
corepack enable && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
# ============================================ # Setup pnpm and turbo on the alpine base
# Stage 2: Prune the monorepo FROM alpine AS base
# ============================================ RUN corepack enable
FROM alpine AS pruner # 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 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 "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION 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 WORKDIR /app
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml 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/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ . 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 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 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')}" && \ 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}
# ============================================ # # Copy static files to standalone directory
# Stage 5: Migration image (run-once container) # RUN mkdir -p apps/web/.next/standalone/.next && \
# ============================================ # mv apps/web/public apps/web/.next/standalone/ && \
FROM node:${NODE_VERSION}-alpine AS migrate # mv apps/web/.next/static apps/web/.next/standalone/.next/
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts # 4. Final image - runner stage to run the application
COPY packages/db/migrations/ ./migrations/ 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 WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Copy the standalone Next.js server COPY --chown=nextjs:nodejs --from=builder /app/ ./
COPY --from=builder /app/apps/web/.next/standalone/ ./ WORKDIR /app/apps/${APP_DIRNAME}
# 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 bootstrap script for runtime env var injection ARG PORT=3000
COPY apps/web/bootstrap.cjs ./bootstrap.cjs ENV PORT=${PORT}
EXPOSE ${PORT}
EXPOSE 3000 CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
CMD ["bootstrap.cjs"]

View File

@@ -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");

View File

@@ -1,12 +1,12 @@
{ {
"version": "1.10", "version": 0,
"locale": { "locale": {
"source": "en", "source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"] "targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
}, },
"buckets": { "buckets": {
"json": { "po": {
"include": ["src/locales/[locale]/messages.json"] "include": ["src/locales/[locale]/messages.po"]
} }
}, },
"$schema": "https://lingo.dev/schema/i18n.json" "$schema": "https://lingo.dev/schema/i18n.json"

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
import { defineConfig } from "@lingui/cli"; import type { LinguiConfig } from "@lingui/conf";
import { formatter } from "@lingui/format-json";
const config: LinguiConfig = {
export default defineConfig({ locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
sourceLocale: "en", sourceLocale: "en",
catalogs: [ catalogs: [
{ {
@@ -12,5 +10,6 @@ export default defineConfig({
exclude: ["**/node_modules/**"], exclude: ["**/node_modules/**"],
}, },
], ],
format: formatter({ style: "lingui" }), };
});
export default config;

View File

@@ -16,18 +16,6 @@ const config = {
: undefined, : undefined,
reactStrictMode: true, 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 */ /** Enables hot reloading for local packages without a build step */
transpilePackages: [ transpilePackages: [
"@kan/api", "@kan/api",
@@ -47,13 +35,49 @@ const config = {
remotePatterns: (() => { remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */ /** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [ 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", protocol: "http",
hostname: "localhost", 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; return patterns;
})(), })(),
}, },

View File

@@ -20,14 +20,12 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0", "@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0", "@aws-sdk/s3-request-presigner": "^3.812.0",
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@kan/api": "workspace:*", "@kan/api": "workspace:*",
"@kan/auth": "workspace:*", "@kan/auth": "workspace:*",
"@kan/db": "workspace:^", "@kan/db": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^", "@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2", "@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2", "@lingui/conf": "^5.3.2",
@@ -80,7 +78,6 @@
"@kan/tailwind-config": "workspace:*", "@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2", "@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2", "@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2", "@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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

View File

@@ -145,6 +145,7 @@ export default function CommandPallette({
value={result} value={result}
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200" className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
onClick={() => { onClick={() => {
console.log("clicked", url);
void router.push(url); void router.push(url);
onClose(); onClose();
setQuery(""); setQuery("");

View File

@@ -17,14 +17,9 @@ import { twMerge } from "tailwind-merge";
interface DateSelectorProps { interface DateSelectorProps {
selectedDate?: Date | null; selectedDate?: Date | null;
onDateSelect?: (date: Date | undefined) => void; onDateSelect?: (date: Date | undefined) => void;
weekStartsOn?: 0 | 1 | 6;
} }
const DateSelector = ({ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
selectedDate,
onDateSelect,
weekStartsOn = 1,
}: DateSelectorProps) => {
const [currentMonth, setCurrentMonth] = useState(() => { const [currentMonth, setCurrentMonth] = useState(() => {
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date()); return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
}); });
@@ -33,18 +28,18 @@ const DateSelector = ({
const year = format(currentMonth, "yyyy"); const year = format(currentMonth, "yyyy");
const dayHeaders = useMemo(() => { const dayHeaders = useMemo(() => {
const weekStart = startOfWeek(new Date(), { weekStartsOn }); const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
return eachDayOfInterval({ return eachDayOfInterval({
start: weekStart, start: weekStart,
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000), end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name }).map((date) => format(date, "EEEEEE")); // Shortest localized day name
}, [weekStartsOn]); }, []);
const days = useMemo(() => { const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth); const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth); const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn }); const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn }); const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map( return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
(date) => { (date) => {
@@ -58,7 +53,7 @@ const DateSelector = ({
}; };
}, },
); );
}, [currentMonth, selectedDate, weekStartsOn]); }, [currentMonth, selectedDate]);
const handlePreviousMonth = () => { const handlePreviousMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1)); setCurrentMonth(subMonths(currentMonth, 1));

View File

@@ -463,6 +463,13 @@ export default function Editor({
StarterKit.configure({ StarterKit.configure({
heading: disableHeadings ? false : undefined, heading: disableHeadings ? false : undefined,
}), }),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,
HTMLAttributes: { HTMLAttributes: {
@@ -472,14 +479,6 @@ export default function Editor({
}, },
validate: (href) => /^https?:\/\//.test(href), validate: (href) => /^https?:\/\//.test(href),
autolink: true, autolink: true,
linkOnPaste: true,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: getCommandItems(disableHeadings), commandItems: getCommandItems(disableHeadings),

View File

@@ -1,32 +0,0 @@
import { t } from "@lingui/core/macro";
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
import { useFontSize, type FontSize } from "~/providers/font-size";
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
{ value: "small", label: () => t`Small` },
{ value: "medium", label: () => t`Medium` },
{ value: "large", label: () => t`Large` },
];
export function FontSizeSelector() {
const { fontSize, setFontSize } = useFontSize();
return (
<div className="relative">
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="font-size-select"
value={fontSize}
onChange={(e) => setFontSize(e.target.value as FontSize)}
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
>
{fontSizeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label()}
</option>
))}
</select>
</div>
);
}

View File

@@ -75,7 +75,7 @@ export function LabelForm({
reset(newFormState); reset(newFormState);
} }
} catch (e) { } catch (e) {
console.error(e); console.log(e);
} }
}, },
}); });
@@ -231,7 +231,9 @@ export function LabelForm({
<Button <Button
type="submit" type="submit"
isLoading={updateLabel.isPending || createLabel.isPending} isLoading={updateLabel.isPending || createLabel.isPending}
disabled={!watch("name")} disabled={
!watch("name")
}
> >
{isEdit ? t`Update label` : t`Create label`} {isEdit ? t`Update label` : t`Create label`}
</Button> </Button>

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, 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-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}) })
@@ -111,7 +111,6 @@ export function NewWorkspaceForm() {
slug: values.slug, slug: values.slug,
plan: values.plan, plan: values.plan,
role: "admin", role: "admin",
weekStartDay: 1,
}); });
// If in cloud and Pro toggle is enabled, create checkout session for pro // If in cloud and Pro toggle is enabled, create checkout session for pro

View File

@@ -8,7 +8,6 @@ export const PageHead = ({ title }: { title: string }) => {
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1" content="width=device-width, initial-scale=1, maximum-scale=1"
/> />
<link rel="manifest" href="/manifest.json" />
</Head> </Head>
); );
}; };

View File

@@ -1,130 +0,0 @@
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
interface PlainTextEditorProps {
content: string;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
onEnter?: (value: string) => void;
onEscape?: () => void;
readOnly?: boolean;
placeholder?: string;
className?: string;
}
export default function PlainTextEditor({
content,
onChange,
onBlur,
onEnter,
onEscape,
readOnly = false,
placeholder,
className,
}: PlainTextEditorProps) {
const onEnterRef = useRef(onEnter);
const onEscapeRef = useRef(onEscape);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
const contentRef = useRef(content);
useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);
useEffect(() => {
onEscapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
contentRef.current = content;
}, [content]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
hardBreak: false,
}),
Placeholder.configure({ placeholder }),
],
content,
editable: !readOnly,
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === "Enter") {
event.preventDefault();
onEnterRef.current?.(view.state.doc.textContent.trim());
return true;
}
if (event.key === "Escape") {
event.preventDefault();
// Reset to original content before calling the callback
editor?.commands.setContent(contentRef.current, false);
editor?.commands.blur();
onEscapeRef.current?.();
return true;
}
return false;
},
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
},
[],
);
useEffect(() => {
if (!editor) return;
if (content !== editor.getText()) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
return (
<>
<style jsx global>{`
.plain-text-editor p.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--placeholder-color, #9ca3af);
}
.plain-text-editor .tiptap p {
margin: 0 !important;
}
`}</style>
<EditorContent
editor={editor}
className={twMerge("plain-text-editor", className)}
/>
</>
);
}

View File

@@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
import { import {
HiChevronDown, HiChevronDown,
HiOutlineBanknotes, HiOutlineBanknotes,
HiOutlineBolt,
HiOutlineCodeBracketSquare, HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup, HiOutlineRectangleGroup,
HiOutlineShieldCheck, HiOutlineShieldCheck,
@@ -65,12 +64,6 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
label: t`API`, label: t`API`,
condition: true, condition: true,
}, },
{
key: "webhooks",
icon: <HiOutlineBolt />,
label: t`Webhooks`,
condition: isAdmin,
},
{ {
key: "integrations", key: "integrations",
icon: <HiOutlineCodeBracketSquare />, icon: <HiOutlineCodeBracketSquare />,

View File

@@ -19,7 +19,6 @@ interface UsePermissionsResult {
canCreateBoard: boolean; canCreateBoard: boolean;
canEditBoard: boolean; canEditBoard: boolean;
canDeleteBoard: boolean; canDeleteBoard: boolean;
canArchiveBoard: boolean;
canViewComment: boolean; canViewComment: boolean;
canCreateComment: boolean; canCreateComment: boolean;
canEditComment: boolean; canEditComment: boolean;
@@ -34,7 +33,7 @@ interface UsePermissionsResult {
export function usePermissions(): UsePermissionsResult { export function usePermissions(): UsePermissionsResult {
// Check if WorkspaceProvider is available (for public board views, it may not be) // Check if WorkspaceProvider is available (for public board views, it may not be)
const workspaceContext = useContext(WorkspaceContext); const workspaceContext = useContext(WorkspaceContext);
// If WorkspaceProvider is not available, return safe defaults // If WorkspaceProvider is not available, return safe defaults
if (!workspaceContext) { if (!workspaceContext) {
const emptyPermissions: UsePermissionsResult = { const emptyPermissions: UsePermissionsResult = {
@@ -52,7 +51,6 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: false, canCreateBoard: false,
canEditBoard: false, canEditBoard: false,
canDeleteBoard: false, canDeleteBoard: false,
canArchiveBoard: false,
canViewComment: false, canViewComment: false,
canCreateComment: false, canCreateComment: false,
canEditComment: false, canEditComment: false,
@@ -97,7 +95,6 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: hasPermission("board:create"), canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"), canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"), canDeleteBoard: hasPermission("board:delete"),
canArchiveBoard: hasPermission("board:edit"),
canViewComment: hasPermission("comment:view"), canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"), canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"), canEditComment: hasPermission("comment:edit"),

View File

@@ -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

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 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 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

View File

@@ -7,7 +7,7 @@ export const locales = [
"nl", "nl",
"ru", "ru",
"pl", "pl",
"ptbr" "pt-BR"
] as const; ] as const;
export type Locale = (typeof locales)[number]; export type Locale = (typeof locales)[number];
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
nl: "Nederlands", nl: "Nederlands",
ru: "Русский", ru: "Русский",
pl: "Polski", pl: "Polski",
ptbr: "Português", "pt-BR": "Português",
}; };

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 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 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 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -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>
</>
);
}

View File

@@ -12,7 +12,6 @@ import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react"; import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react"; import { useEffect } from "react";
import { FontSizeProvider } from "~/providers/font-size";
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts"; import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
import { LinguiProviderWrapper } from "~/providers/lingui"; import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal"; import { ModalProvider } from "~/providers/modal";
@@ -84,21 +83,19 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
<main className="font-sans"> <main className="font-sans">
<KeyboardShortcutProvider> <KeyboardShortcutProvider>
<LinguiProviderWrapper> <LinguiProviderWrapper>
<FontSizeProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ModalProvider>
<ModalProvider> <PopupProvider>
<PopupProvider> {posthogKey ? (
{posthogKey ? ( <PostHogProvider client={posthog}>
<PostHogProvider client={posthog}> {getLayout(<Component {...pageProps} />)}
{getLayout(<Component {...pageProps} />)} </PostHogProvider>
</PostHogProvider> ) : (
) : ( getLayout(<Component {...pageProps} />)
getLayout(<Component {...pageProps} />) )}
)} </PopupProvider>
</PopupProvider> </ModalProvider>
</ModalProvider> </ThemeProvider>
</ThemeProvider>
</FontSizeProvider>
</LinguiProviderWrapper> </LinguiProviderWrapper>
</KeyboardShortcutProvider> </KeyboardShortcutProvider>
</main> </main>

View File

@@ -13,23 +13,6 @@ const authHandler = toNodeHandler(auth.handler);
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req, res) => { 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); return await authHandler(req, res);
}, },
); );

View File

@@ -2,74 +2,48 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") { if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" }); return res.status(405).json({ message: "Method not allowed" });
}
const { url, filename } = req.query;
if (!url || typeof url !== "string") {
return res.status(400).json({
message: "url parameter is required",
});
}
try {
const downloadFilename = typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
if (!upstream.ok) {
return res.status(upstream.status).json({
message: "Failed to fetch attachment",
});
} }
const { url, filename } = req.query; const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
if (!url || typeof url !== "string") { res.setHeader("Content-Type", contentType);
return res.status(400).json({ message: "url parameter is required" }); res.setHeader(
} "Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
const s3Endpoint = env.S3_ENDPOINT; const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
if (s3Endpoint) { } catch (error) {
let parsed: URL; console.error("Error downloading attachment:", error);
try { return res.status(500).json({ message: "Failed to download attachment" });
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" });
}
}, },
); );

View File

@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z const workspaceSlugSchema = z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/); .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest { interface CheckoutSessionRequest {

View File

@@ -3,11 +3,8 @@ import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
const log = createLogger("stripe-webhook");
async function buffer(readable: Readable) { async function buffer(readable: Readable) {
const chunks = []; const chunks = [];
for await (const chunk of readable) { for await (const chunk of readable) {
@@ -44,8 +41,6 @@ export default async function handler(
const { db } = await createNextApiContext(req); const { db } = await createNextApiContext(req);
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
switch (event.type) { switch (event.type) {
case "checkout.session.completed": { case "checkout.session.completed": {
const checkoutSession = event.data.object; const checkoutSession = event.data.object;
@@ -61,12 +56,12 @@ export default async function handler(
break; break;
} }
default: default:
log.warn({ eventType: event.type }, "Unhandled Stripe event type"); console.log(`Unhandled event type: ${event.type}`);
} }
return res.status(200).json({ received: true }); return res.status(200).json({ received: true });
} catch (err) { } catch (err) {
log.error({ err }, "Stripe webhook handler failed"); console.error("Webhook error:", err);
return res.status(400).json({ message: "Webhook handler failed" }); return res.status(400).json({ message: "Webhook handler failed" });
} }
} }

View File

@@ -1,17 +1,17 @@
import type { NextApiRequest, NextApiResponse } from "next"; 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 { 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 cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.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 { 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 const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
export const config = { export const config = {
@@ -90,19 +90,16 @@ export default withRateLimit(
const client = createS3Client(); const client = createS3Client();
const upload = new Upload({ // Upload the file to S3
client, await client.send(
params: { new PutObjectCommand({
Bucket: bucket, Bucket: bucket,
Key: s3Key, Key: s3Key,
Body: req, Body: req,
ContentType: contentType, ContentType: contentType,
ContentLength: contentLength, ContentLength: contentLength,
}, }),
leavePartsOnError: false, );
});
await upload.done();
// Create attachment record and log activity // Create attachment record and log activity
const attachment = await cardAttachmentRepo.create(db, { const attachment = await cardAttachmentRepo.create(db, {
@@ -115,15 +112,9 @@ export default withRateLimit(
createdBy: user.id, createdBy: user.id,
}); });
if (!attachment) {
return res.status(500).json({ error: "Failed to create attachment" });
}
await cardActivityRepo.create(db, { await cardActivityRepo.create(db, {
type: "card.updated.attachment.added", type: "card.updated.attachment.added",
cardId: card.id, cardId: card.id,
attachmentId: attachment.id,
toTitle: originalFilenameHeader,
createdBy: user.id, createdBy: user.id,
}); });
@@ -134,3 +125,4 @@ export default withRateLimit(
} }
}, },
); );

View File

@@ -8,7 +8,7 @@ import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils"; 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"]; const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = { export const config = {

View File

@@ -1,25 +1,24 @@
import { useEffect } from "react"; import { useEffect } from "react";
export default function TrelloAuthorize() { export default function TrelloAuthorize() {
useEffect(() => { useEffect(() => {
const hash = window.location.hash; const hash = window.location.hash;
const token = hash.split("=")[1]; const token = hash.split("=")[1];
if (token) { if (token) {
fetch("/api/trello/authenticate", { console.log("Posting token to /api/trello/authenticate", token);
method: "POST", fetch("/api/trello/authenticate", {
headers: { method: "POST",
"Content-Type": "application/json", headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ token }), },
}).then(() => { body: JSON.stringify({ token }),
window.close(); }).then(() => {
}); window.close();
} });
}, []); }
}, []);
return ( return <div className="flex h-[200px] items-center justify-center">
<div className="flex h-[200px] items-center justify-center"> <p className="text-center">Connecting to Trello...</p>
<p className="text-center">Connecting to Trello...</p> </div>;
</div> }
);
}

View File

@@ -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;

View File

@@ -1,57 +0,0 @@
import React, {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
export type FontSize = "small" | "medium" | "large";
const fontSizeMap: Record<FontSize, string> = {
small: "14px",
medium: "16px",
large: "18px",
};
interface FontSizeContextProps {
fontSize: FontSize;
setFontSize: (size: FontSize) => void;
}
const FontSizeContext = createContext<FontSizeContextProps | undefined>(
undefined,
);
export const FontSizeProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [fontSize, setFontSizeState] = useState<FontSize>("medium");
const setFontSize = (size: FontSize) => {
document.documentElement.style.fontSize = fontSizeMap[size];
localStorage.setItem("fontSize", size);
setFontSizeState(size);
};
useEffect(() => {
const stored = localStorage.getItem("fontSize") as FontSize | null;
const size = stored && fontSizeMap[stored] ? stored : "medium";
document.documentElement.style.fontSize = fontSizeMap[size];
setFontSizeState(size);
}, []);
return (
<FontSizeContext.Provider value={{ fontSize, setFontSize }}>
{children}
</FontSizeContext.Provider>
);
};
export const useFontSize = (): FontSizeContextProps => {
const context = useContext(FontSizeContext);
if (!context) {
throw new Error("useFontSize must be used within a FontSizeProvider");
}
return context;
};

View File

@@ -19,7 +19,6 @@ interface Workspace {
slug: string | undefined; slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined; plan: "free" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest"; role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
} }
const initialWorkspace: Workspace = { const initialWorkspace: Workspace = {
@@ -29,14 +28,13 @@ const initialWorkspace: Workspace = {
slug: "", slug: "",
plan: "free", plan: "free",
role: "member", role: "member",
weekStartDay: 1,
}; };
const initialAvailableWorkspaces: Workspace[] = []; const initialAvailableWorkspaces: Workspace[] = [];
export const WorkspaceContext = createContext< export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
WorkspaceContextProps | undefined undefined,
>(undefined); );
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
children, children,
@@ -81,7 +79,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: workspace.slug, slug: workspace.slug,
description: workspace.description, description: workspace.description,
plan: workspace.plan, plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
hasLoaded: true, hasLoaded: true,
})) as Workspace[]; })) as Workspace[];
@@ -103,7 +100,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
plan: selectedWorkspace.workspace.plan, plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description, description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role, role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
}); });
if (workspacePublicId) { if (workspacePublicId) {
@@ -123,7 +119,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
plan: primaryWorkspace.plan, plan: primaryWorkspace.plan,
description: primaryWorkspace.description, description: primaryWorkspace.description,
role: primaryWorkspaceRole, role: primaryWorkspaceRole,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
}); });
} }
}, [data, isLoading, workspacePublicId, router]); }, [data, isLoading, workspacePublicId, router]);

View File

@@ -2,10 +2,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html {
font-size: 14px;
}
[contenteditable="true"]:empty:before { [contenteditable="true"]:empty:before {
content: attr(placeholder); content: attr(placeholder);
display: block; display: block;

View File

@@ -58,7 +58,7 @@ export default function LoginPage() {
</div> </div>
</div> </div>
)} )}
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && ( {!isSignUpDisabled && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000"> <p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans> <Trans>
Don't have an account?{" "} Don't have an account?{" "}

View File

@@ -28,9 +28,7 @@ export default function SignUpPage() {
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}; };
const isInviteFlow = redirect?.startsWith("/invite/"); if (isSignUpDisabled) {
if (isSignUpDisabled && !isInviteFlow) {
return ( return (
<> <>
<PageHead title={t`Sign up | kan.bn`} /> <PageHead title={t`Sign up | kan.bn`} />

View File

@@ -7,7 +7,7 @@ import {
HiOutlineStar, HiOutlineStar,
HiStar, HiStar,
} from "react-icons/hi2"; } from "react-icons/hi2";
import { IoArchiveOutline } from "react-icons/io5";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -17,7 +17,6 @@ import { api } from "~/utils/api";
export default function BoardDropdown({ export default function BoardDropdown({
isTemplate, isTemplate,
isLoading, isLoading,
isArchived,
boardPublicId, boardPublicId,
isFavorite, isFavorite,
boardName, boardName,
@@ -25,29 +24,28 @@ export default function BoardDropdown({
isTemplate: boolean; isTemplate: boolean;
isLoading: boolean; isLoading: boolean;
boardPublicId: string; boardPublicId: string;
isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
boardName?: string; boardName?: string;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
usePermissions();
const utils = api.useUtils(); const utils = api.useUtils();
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const updateBoard = api.board.update.useMutation({ const updateBoard = api.board.update.useMutation({
onSuccess: (_data, variables) => { onSuccess: (data, variables) => {
void utils.board.all.invalidate(); void utils.board.all.invalidate();
void utils.board.byId.invalidate(); void utils.board.byId.invalidate();
if (variables.isArchived !== undefined) {
showPopup({ // Show popup notification
header: variables.isArchived ? t`Board archived` : t`Board unarchived`, if (variables.favorite !== undefined) {
message: variables.isArchived
? t`The board has been archived.`
: t`The board has been unarchived.`,
icon: "success",
});
} else if (variables.favorite !== undefined) {
showPopup({ showPopup({
header: variables.favorite header: variables.favorite
? t`Added to favorites` ? 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 = [ const items = [
...(isTemplate && canCreateBoard ...(isTemplate && canCreateBoard
? [ ? [
{ {
label: t`Make template`, label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"), action: () => openModal("CREATE_TEMPLATE"),
icon: ( icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" /> <HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
), ),
}, },
] ]
: []), : []),
...(!isTemplate && canEditBoard ...(!isTemplate && canEditBoard
? [ ? [
{ {
label: t`Edit board URL`, label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"), action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />, 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: isFavorite label: isFavorite
@@ -129,26 +100,22 @@ export default function BoardDropdown({
}, },
...(canDeleteBoard ...(canDeleteBoard
? [ ? [
{ {
label: isTemplate ? t`Delete template` : t`Delete board`, label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"), action: () => openModal("DELETE_BOARD"),
icon: ( icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" /> },
), ]
},
]
: []), : []),
]; ];
if (items.length === 0) { if (items.length === 0) {
return null; return null;
} }
return ( return (
<Dropdown <Dropdown disabled={isLoading} items={items}>
disabled={isLoading || isArchiveActionPending}
items={items}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" /> <HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown> </Dropdown>
); );

View File

@@ -66,8 +66,8 @@ const Card = ({
const hasDueDate = !!dueDate; const hasDueDate = !!dueDate;
return ( return (
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"> <div className="flex flex-col 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> <span>{title}</span>
{labels.length || {labels.length ||
members.length || members.length ||
checklists.length > 0 || checklists.length > 0 ||

View File

@@ -24,7 +24,6 @@ import Toggle from "~/components/Toggle";
import { useModalFormState } from "~/hooks/useModalFormState"; import { useModalFormState } from "~/hooks/useModalFormState";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers"; import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
@@ -54,7 +53,6 @@ export function NewCardForm({
queryParams, queryParams,
}: NewCardFormProps) { }: NewCardFormProps) {
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { closeModal, openModal, modalStates, clearModalState } = useModal(); const { closeModal, openModal, modalStates, clearModalState } = useModal();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -493,7 +491,6 @@ export function NewCardForm({
setValue("dueDate", date ?? null); setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false); setIsDateSelectorOpen(false);
}} }}
weekStartsOn={workspace.weekStartDay}
/> />
</div> </div>
</> </>

View File

@@ -26,7 +26,6 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
import { Tooltip } from "~/components/Tooltip"; import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll"; import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useScrollRestore } from "~/hooks/useScrollRestore";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts"; import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -59,14 +58,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const [selectedPublicListId, setSelectedPublicListId] = const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>(""); useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
const { ref: scrollRef, onMouseDown } = useDragToScroll({ const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true, enabled: true,
direction: "horizontal", direction: "horizontal",
}); });
const { canCreateList, canEditList, canEditCard, canEditBoard } = const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
usePermissions();
const { tooltipContent: createListShortcutTooltipContent } = const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({ useKeyboardShortcut({
@@ -125,21 +123,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
data: boardData, data: boardData,
isSuccess, isSuccess,
isLoading: isQueryLoading, isLoading: isQueryLoading,
error,
} = api.board.byId.useQuery(queryParams, { } = api.board.byId.useQuery(queryParams, {
enabled: !!boardId, enabled: !!boardId,
placeholderData: keepPreviousData, 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 () => { const refetchBoard = async () => {
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId }); if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
}; };
@@ -152,8 +140,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const isLoading = isInitialLoading || isQueryLoading; const isLoading = isInitialLoading || isQueryLoading;
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
const updateListMutation = api.list.update.useMutation({ const updateListMutation = api.list.update.useMutation({
onMutate: async (args) => { onMutate: async (args) => {
await utils.board.byId.cancel(); await utils.board.byId.cancel();
@@ -431,9 +417,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
{...register("name")} {...register("name")}
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined} onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
readOnly={!canEditBoard} 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> </form>
)} )}
{!boardData && !isLoading && ( {!boardData && !isLoading && (
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1"> <p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
@@ -507,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate} isTemplate={!!isTemplate}
isLoading={!boardData} isLoading={!boardData}
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
isArchived={boardData?.isArchived ?? false} workspacePublicId={workspace.publicId}
isFavorite={boardData?.favorite} isFavorite={boardData?.favorite}
boardName={boardData?.name} boardName={boardData?.name}
/> />

View File

@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) { export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { openModal } = useModal(); const { openModal } = useModal();
const { canCreateBoard } = usePermissions(); const { canCreateBoard } = usePermissions();
@@ -26,7 +26,6 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
{ {
workspacePublicId: workspace.publicId, workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular", type: isTemplate ? "template" : "regular",
archived: archived,
}, },
{ enabled: workspace.publicId ? true : false }, { enabled: workspace.publicId ? true : false },
); );
@@ -60,10 +59,10 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" /> <HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950"> <p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`} {t`No ${isTemplate ? "templates" : "boards"}`}
</p> </p>
<p className="text-[14px] text-light-900 dark:text-dark-900"> <p className="text-[14px] text-light-900 dark:text-dark-900">
{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> </p>
</div> </div>
<Tooltip <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"> <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 /> <PatternedBackground />
<button <button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)} 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" 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"} aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
> >
{board.favorite ? ( {board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" /> <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" /> <HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)} )}
</button> </button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000"> <p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name} {board.name}
</p> </p>

View File

@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { FaGithub, FaTrello } from "react-icons/fa"; import { FaTrello } from "react-icons/fa";
import { import {
HiChevronUpDown, HiChevronUpDown,
HiMiniArrowTopRightOnSquare, HiMiniArrowTopRightOnSquare,
@@ -27,23 +27,12 @@ const integrationProviders: Record<
name: "Trello", name: "Trello",
icon: <FaTrello />, icon: <FaTrello />,
}, },
github: {
name: "GitHub",
icon: <FaGithub />,
},
}; };
const SelectSource = ({ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
handleNextStep,
}: {
handleNextStep: (provider: string) => void;
}) => {
const { data: integrations, refetch: refetchIntegrations } = const { data: integrations, refetch: refetchIntegrations } =
api.integration.providers.useQuery(); api.integration.providers.useQuery();
const { data: githubStatus, refetch: refetchGithubStatus } = const { control, handleSubmit } = useForm({
api.integration.getGitHubStatus.useQuery();
const { control, handleSubmit, watch } = useForm({
defaultValues: { defaultValues: {
source: integrations?.[0]?.provider ?? "trello", source: integrations?.[0]?.provider ?? "trello",
}, },
@@ -58,36 +47,23 @@ const SelectSource = ({
}, },
); );
const availableIntegrations = [ const hasIntegrations = integrations && integrations.length > 0;
...(integrations ?? []),
...(githubStatus?.connected ? [{ provider: "github" }] : []),
];
const hasIntegrations = availableIntegrations.length > 0;
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
void refetchIntegrations(); refetchIntegrations();
void refetchGithubStatus();
}; };
window.addEventListener("focus", handleFocus); window.addEventListener("focus", handleFocus);
return () => { return () => {
window.removeEventListener("focus", handleFocus); window.removeEventListener("focus", handleFocus);
}; };
}, [refetchIntegrations, refetchGithubStatus]); }, [refetchIntegrations]);
const onSubmit = () => { const onSubmit = () => {
const selected = watch("source"); if (!hasIntegrations && trelloUrl) {
if ( window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
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");
} else { } 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"> <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 ? ( {hasIntegrations ? (
availableIntegrations.map((integration, index) => ( integrations.map((integration, index) => (
<Listbox.Option <Listbox.Option
key={`source_${index}`} key={`source_${index}`}
className="relative cursor-default select-none px-1" className="relative cursor-default select-none px-1"
@@ -147,32 +123,18 @@ const SelectSource = ({
</Listbox.Option> </Listbox.Option>
)) ))
) : ( ) : (
<> <Listbox.Option
<Listbox.Option key="trello_placeholder"
key="trello_placeholder" className="font-sm relative cursor-default select-none px-1"
className="font-sm relative cursor-default select-none px-1" value="trello"
value="trello" >
> <div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400"> {integrationProviders.trello?.icon}
{integrationProviders.trello?.icon} <span className="ml-2 block truncate text-sm">
<span className="ml-2 block truncate text-sm"> {integrationProviders.trello?.name}
{integrationProviders.trello?.name} </span>
</span> </div>
</div> </Listbox.Option>
</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.Options> </Listbox.Options>
</Transition> </Transition>
@@ -192,159 +154,7 @@ const SelectSource = ({
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined !hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
} }
> >
{hasIntegrations ? t`Select source` : t`Connect`} {hasIntegrations ? t`Select source` : t`Connect Trello`}
</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>
</Button> </Button>
</div> </div>
</div> </div>
@@ -386,7 +196,7 @@ const ImportTrello: React.FC = () => {
await refetchBoards(); await refetchBoards();
closeModal(); closeModal();
} catch (e) { } catch (e) {
console.error(e); console.log(e);
} }
}, },
onError: () => { onError: () => {
@@ -403,7 +213,7 @@ const ImportTrello: React.FC = () => {
value: watch(board.id), 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 onSubmitBoards = (values: Record<string, boolean>) => {
const boardIds = Object.keys(values).filter((key) => values[key] === true); const boardIds = Object.keys(values).filter((key) => values[key] === true);
@@ -457,7 +267,7 @@ const ImportTrello: React.FC = () => {
return ( return (
<form onSubmit={handleSubmitBoards(onSubmitBoards)}> <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"> <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 <Toggle
@@ -467,7 +277,7 @@ const ImportTrello: React.FC = () => {
const newState = !isSelectAllEnabled; const newState = !isSelectAllEnabled;
setIsSelectAllEnabled(newState); setIsSelectAllEnabled(newState);
for (const board of boards ?? []) { for (const board of boards || []) {
setValue(board.id, newState); setValue(board.id, newState);
} }
}} }}
@@ -503,7 +313,6 @@ const ImportTrello: React.FC = () => {
export function ImportBoardsForm() { export function ImportBoardsForm() {
const { closeModal } = useModal(); const { closeModal } = useModal();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [provider, setProvider] = useState<string | null>(null);
return ( return (
<div> <div>
@@ -530,16 +339,8 @@ export function ImportBoardsForm() {
</button> </button>
</div> </div>
{step === 1 && ( {step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
<SelectSource {step === 2 && <ImportTrello />}
handleNextStep={(p) => {
setProvider(p);
setStep(step + 1);
}}
/>
)}
{step === 2 && provider === "trello" && <ImportTrello />}
{step === 2 && provider === "github" && <ImportGithub />}
</div> </div>
); );
} }

View File

@@ -23,18 +23,6 @@ export const getTemplates = (): Template[] => [
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`], 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`], 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", id: "content-creation",
name: t`Content Creation`, name: t`Content Creation`,

View File

@@ -1,12 +1,5 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2"; import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -22,15 +15,9 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm"; import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm"; 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 }) { export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal(); const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions(); const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } = const { tooltipContent: createModalShortcutTooltipContent } =
@@ -47,7 +34,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead <PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`} 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"> <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]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`${isTemplate ? "Templates" : "Boards"}`} {t`${isTemplate ? "Templates" : "Boards"}`}
@@ -128,79 +115,9 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal> </Modal>
</> </>
{!isTemplate ? ( <div className="flex h-full flex-row">
<div className="flex h-full w-full flex-col"> <BoardsList isTemplate={!!isTemplate} />
<div className="focus:outline-none"> </div>
<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> </div>
</> </>
); );

View File

@@ -8,7 +8,6 @@ import {
HiOutlineArrowRight, HiOutlineArrowRight,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineClock, HiOutlineClock,
HiOutlinePaperClip,
HiOutlinePencil, HiOutlinePencil,
HiOutlinePlus, HiOutlinePlus,
HiOutlineTag, HiOutlineTag,
@@ -32,16 +31,6 @@ import Comment from "./Comment";
type ActivityType = type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"]; 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) => { const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value; if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value; return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
@@ -68,7 +57,6 @@ const getActivityText = ({
toDueDate, toDueDate,
dateLocale, dateLocale,
mergedLabels, mergedLabels,
attachmentName,
}: { }: {
type: ActivityType; type: ActivityType;
toTitle: string | null; toTitle: string | null;
@@ -83,7 +71,6 @@ const getActivityText = ({
toDueDate?: Date | null; toDueDate?: Date | null;
dateLocale: DateFnsLocale; dateLocale: DateFnsLocale;
mergedLabels?: string[]; mergedLabels?: string[];
attachmentName?: string | null;
}) => { }) => {
const displayName = memberName ?? memberEmail ?? t`Member`; const displayName = memberName ?? memberEmail ?? t`Member`;
const TextHighlight = ({ children }: { children: React.ReactNode }) => ( 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.completed": t`completed a checklist item`,
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`, "card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
"card.updated.checklist.item.deleted": t`deleted a checklist item`, "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.added": t`set the due date`,
"card.updated.dueDate.updated": t`updated the due date`, "card.updated.dueDate.updated": t`updated the due date`,
"card.updated.dueDate.removed": t`removed 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) { if (type === "card.updated.dueDate.added" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date()); const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format( 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.completed": <HiOutlineCheckCircle />,
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />, "card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
"card.updated.checklist.item.deleted": <HiOutlineTrash />, "card.updated.checklist.item.deleted": <HiOutlineTrash />,
"card.updated.attachment.added": <HiOutlinePaperClip />,
"card.updated.attachment.removed": <HiOutlinePaperClip />,
"card.updated.dueDate.added": <HiOutlineClock />, "card.updated.dueDate.added": <HiOutlineClock />,
"card.updated.dueDate.updated": <HiOutlineClock />, "card.updated.dueDate.updated": <HiOutlineClock />,
"card.updated.dueDate.removed": <HiOutlineClock />, "card.updated.dueDate.removed": <HiOutlineClock />,
@@ -379,7 +341,7 @@ const ActivityList = ({
isAdmin?: boolean; isAdmin?: boolean;
isViewOnly?: boolean; isViewOnly?: boolean;
}) => { }) => {
const { dateLocale } = useLocalisation(); const { dateLocale, locale } = useLocalisation();
const { data: sessionData } = authClient.useSession(); const { data: sessionData } = authClient.useSession();
const utils = api.useUtils(); const utils = api.useUtils();
const [allActivities, setAllActivities] = useState< const [allActivities, setAllActivities] = useState<
@@ -429,21 +391,25 @@ const ActivityList = ({
cursor: nextCursor, cursor: nextCursor,
}); });
const existingIds = new Set( if (nextPage) {
currentActivities.map((a) => a.publicId), const existingIds = new Set(
); currentActivities.map((a) => a.publicId),
const newActivities = nextPage.activities.filter( );
(a: { publicId: string }) => !existingIds.has(a.publicId), const newActivities = nextPage.activities.filter(
); (a: { publicId: string }) => !existingIds.has(a.publicId),
currentActivities = [...currentActivities, ...newActivities]; );
currentHasMore = nextPage.hasMore; currentActivities = [...currentActivities, ...newActivities];
currentHasMore = nextPage.hasMore;
} else {
break;
}
} }
setAllActivities(currentActivities); setAllActivities(currentActivities);
setHasMore(false); setHasMore(false);
}; };
void fetchAllRemaining(); fetchAllRemaining();
} else { } else {
setAllActivities(firstPageData.activities); setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore); setHasMore(firstPageData.hasMore);
@@ -470,15 +436,17 @@ const ActivityList = ({
cursor: nextCursor, cursor: nextCursor,
}); });
const existingIds = new Set(allActivities.map((a) => a.publicId)); if (nextPage) {
const newActivities = nextPage.activities.filter( const existingIds = new Set(allActivities.map((a) => a.publicId));
(a: { publicId: string }) => !existingIds.has(a.publicId), const newActivities = nextPage.activities.filter(
); (a: { publicId: string }) => !existingIds.has(a.publicId),
setAllActivities((prev) => [...prev, ...newActivities]); );
setHasMore(nextPage.hasMore); setAllActivities((prev) => [...prev, ...newActivities]);
setHasMore(nextPage.hasMore);
if (!nextPage.hasMore) { if (!nextPage.hasMore) {
isFullyExpandedRef.current = true; isFullyExpandedRef.current = true;
}
} }
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
@@ -505,10 +473,7 @@ const ActivityList = ({
fromDueDate: activity.fromDueDate ?? null, fromDueDate: activity.fromDueDate ?? null,
toDueDate: activity.toDueDate ?? null, toDueDate: activity.toDueDate ?? null,
dateLocale: dateLocale, dateLocale: dateLocale,
mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels, mergedLabels: (activity as any).mergedLabels,
attachmentName:
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
null,
}); });
if (activity.type === "card.updated.comment.added") if (activity.type === "card.updated.comment.added")

View File

@@ -1,11 +1,11 @@
import type { DraggableProvided } from "react-beautiful-dnd"; import type { DraggableProvided } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useState } from "react"; import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import { RiDraggable } from "react-icons/ri"; import { RiDraggable } from "react-icons/ri";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import PlainTextEditor from "~/components/PlainTextEditor";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation"; import { invalidateCard } from "~/utils/cardInvalidation";
@@ -33,7 +33,9 @@ export default function ChecklistItemRow({
}: ChecklistItemRowProps) { }: ChecklistItemRowProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const [completed, setCompleted] = useState(item.completed);
const [title, setTitle] = useState("");
const [completed, setCompleted] = useState(false);
const updateItem = api.checklist.updateItem.useMutation({ const updateItem = api.checklist.updateItem.useMutation({
onMutate: async (vars) => { onMutate: async (vars) => {
@@ -101,6 +103,21 @@ export default function ChecklistItemRow({
}, },
}); });
// Only resync from props when switching items to avoid clobbering edits
useEffect(() => {
setTitle(item.title);
setCompleted(item.completed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.publicId]);
const sanitizeHtmlToPlainText = (html: string): string =>
html
.replace(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
const handleToggleCompleted = () => { const handleToggleCompleted = () => {
if (viewOnly) return; if (viewOnly) return;
setCompleted((prev) => !prev); setCompleted((prev) => !prev);
@@ -110,8 +127,14 @@ export default function ChecklistItemRow({
}); });
}; };
const commitTitle = (plain: string) => { const commitTitle = (rawHtml: string) => {
if (!plain || plain === item.title) return; if (viewOnly) return;
const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) {
setTitle(item.title);
return;
}
setTitle(plain);
updateItem.mutate({ updateItem.mutate({
checklistItemPublicId: item.publicId, checklistItemPublicId: item.publicId,
title: plain, title: plain,
@@ -160,26 +183,36 @@ export default function ChecklistItemRow({
)} )}
/> />
</label> </label>
<div className="flex-1 pr-7"> <div className="flex-1 pr-7">
<PlainTextEditor <ContentEditable
key={item.publicId} html={title}
content={item.title} disabled={viewOnly}
readOnly={viewOnly} onChange={(e) => setTitle(e.target.value)}
placeholder={t`Add details...`} // @ts-expect-error - valid event
onBlur={commitTitle} onBlur={(e: Event) => {
onEnter={(plain) => { const innerHTML = (e.target as HTMLElement).innerHTML;
commitTitle(plain); commitTitle(innerHTML);
onCreateNewItem?.();
}} }}
onEscape={() => undefined}
className={twMerge( className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 dark:text-dark-950", "m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
viewOnly && "cursor-default", viewOnly && "cursor-default",
)} )}
placeholder={t`Add details...`}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
commitTitle(innerHTML);
onCreateNewItem?.();
}
if (e.key === "Escape") {
e.preventDefault();
setTitle(item.title);
}
}}
/> />
</div> </div>
{!viewOnly && ( {!viewOnly && (
<button <button
type="button" type="button"

View File

@@ -5,7 +5,6 @@ import { HiMiniPlus } from "react-icons/hi2";
import DateSelector from "~/components/DateSelector"; import DateSelector from "~/components/DateSelector";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation"; import { invalidateCard } from "~/utils/cardInvalidation";
@@ -23,7 +22,6 @@ export function DueDateSelector({
disabled = false, disabled = false,
}: DueDateSelectorProps) { }: DueDateSelectorProps) {
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const utils = api.useUtils(); const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [pendingDate, setPendingDate] = useState<Date | null | undefined>( const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
@@ -137,7 +135,6 @@ export function DueDateSelector({
<DateSelector <DateSelector
selectedDate={pendingDate ?? undefined} selectedDate={pendingDate ?? undefined}
onDateSelect={handleDateSelect} onDateSelect={handleDateSelect}
weekStartsOn={workspace.weekStartDay}
/> />
</div> </div>
</> </>

View File

@@ -3,7 +3,6 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
@@ -185,20 +184,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0] ? router.query.cardId[0]
: router.query.cardId; : router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery( const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" }, { cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 }, { 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 isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator; const canEdit = canEditCard || isCreator;
@@ -311,6 +301,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
<> <>
<PageHead <PageHead
@@ -348,13 +339,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
boardPublicId={boardId} boardPublicId={boardId}
cardCreatedBy={card?.createdBy} 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> </div>
</> </>
)} )}

View File

@@ -1,539 +1,293 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import React, { useEffect, useRef, useState } from "react";
import { HiCheckCircle, HiXCircle } from "react-icons/hi2"; 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"; type FrequencyValue = "monthly" | "annually";
interface PlanFeature { const CellValue = ({ value }: { value: boolean | string }) => {
key: string; if (typeof value === "string") {
label: string; return (
free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean }; <span className="text-sm text-dark-900 dark:text-dark-900">{value}</span>
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 }; return value ? (
} <HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" />
) : (
interface FeatureSection { <HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" />
name: string; );
features: PlanFeature[]; };
}
const FeatureComparisonTable = ({ const FeatureComparisonTable = ({
frequencyValue: _frequencyValue, frequencyValue,
}: { }: {
frequencyValue: FrequencyValue; frequencyValue: FrequencyValue;
}) => { }) => {
// Keep pricing column headers visible as you scroll const sections: Section[] = [
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,
},
{ {
name: t`Core features`, 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`, name: t`Teams`,
features: collaborationFeatures, features: [
}, {
{ key: "pricing",
name: t`Imports`, label: t`Price per user/month`,
features: importsFeatures, 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`, name: t`Platform`,
features: platformFeatures, features: [
}, {
{ key: "api",
name: t`Security`, label: t`REST API`,
features: securityFeatures, kan: true,
}, trello: true,
{ },
name: t`Support`, {
features: supportFeatures, 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 = [ const products = [
{ id: "free", name: t`Free`, tierId: "tier-free" }, {
{ id: "teams", name: t`Teams`, tierId: "tier-teams" }, id: "kan",
{ id: "pro", name: t`Pro`, tierId: "tier-pro" }, name: t`Kan`,
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" }, 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 ( return (
<section aria-labelledby="comparison-heading" className="w-full"> <section aria-labelledby="comparison-heading" className="w-full px-4">
<div className="mx-auto max-w-7xl"> <div className="mx-auto max-w-6xl py-8 lg:py-10">
<div <div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300">
ref={containerRef} {products.map((product) => (
className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50" <div key={product.id} aria-hidden="true" className="-mt-px">
> <div
{isHeaderFixed && ( className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`}
<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" <p
style={{ className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`}
top: 64, >
left: headerRect.left, {product.name}
width: headerRect.width, </p>
}}
>
<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>
);
})}
</div> </div>
</div> </div>
)} ))}
<table className="w-full border-separate border-spacing-0"> </div>
<thead>
<tr> <div className="-mt-6 space-y-12">
{plans.map((plan) => { {sections.map((section) => (
const isMostPopular = plan.id === "pro"; <div key={section.name}>
return ( <h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
<th {section.name}
key={plan.id} </h4>
scope="col" <div className="relative -mx-8 mt-8">
className={twMerge( <div
"w-1/4 px-6 py-4 text-left text-base font-semibold", aria-hidden="true"
isMostPopular className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
? "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", {products.map((product) => (
)} <div
> key={product.id}
<span className="flex items-center"> className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`}
{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>
))} ))}
</React.Fragment> </div>
))}
</tbody> <table className="relative w-full border-separate border-spacing-x-8">
</table> <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>
</div> </div>
</section> </section>

View File

@@ -23,115 +23,81 @@ const Pricing = ({
}) => { }) => {
const tiers = [ const tiers = [
{ {
name: t`Free`, name: t`Individuals`,
id: "tier-free", id: "tier-individuals",
href: "signup", href: "signup",
buttonText: t`Get started`, buttonText: t`Get Started`,
price: { monthly: t`$0`, annually: t`$0` }, price: { monthly: t`Free`, annually: t`Free` },
description: t`Free for everyone`, description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
bestFor: t`Best for individuals and solo creators getting started`, featureHeader: t`Free, forever`,
featureHeader: undefined,
features: [ features: [
{ text: t`1 user` }, t`1 user`,
{ text: t`Unlimited boards` }, t`Unlimited boards`,
{ text: t`Unlimited cards` }, t`Unlimited cards`,
{ text: t`Custom board templates` }, t`Custom board templates`,
{ text: t`Checklists` }, t`Checklists`,
], ],
showPrice: true, showPrice: true,
ctaSubtext: undefined,
mostPopular: false,
highlighted: false,
}, },
{ {
name: t`Teams`, name: t`Teams`,
id: "tier-teams", id: "tier-teams",
href: "signup", href: "signup",
buttonText: t`Get started`, buttonText: t`Get Started`,
price: { monthly: "$10", annually: "$8" }, price: { monthly: "$10.00", annually: "$8.00" },
description: t`Perfect for small and growing teams looking to collaborate.`, description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`,
bestFor: t`Best for small and growing teams that need collaboration`, featureHeader: t`Everything in the free plan, plus:`,
featureHeader: undefined,
features: [ features: [
{ text: t`All Free features +` }, t`Workspace members`,
{ text: t`Workspace members` }, t`Admin roles`,
{ text: t`Invite links` }, t`Priority email support`,
{ text: t`Unlimited file uploads` }, t`Support the development of the project`,
{ 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` },
], ],
highlighted: true, highlighted: true,
showPrice: true, showPrice: true,
showPriceSuffix: false, showPriceSuffix: true,
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
mostPopular: true,
}, },
{ {
name: t`Enterprise`, name: t`Self Host`,
id: "tier-enterprise", id: "tier-self-host",
href: "mailto:support@kan.bn?subject=Enterprise Inquiry", href: "https://github.com/kanbn/kan",
buttonText: t`Contact Sales`, buttonText: t`View docs`,
price: { monthly: t`Contact us`, annually: t`Contact us` }, price: { monthly: "-", annually: "-" },
description: t`Advanced security, compliance, and dedicated support for large organizations.`, description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
bestFor: t`Best for large organizations with advanced needs`, featureHeader: t`Complete control and ownership:`,
featureHeader: undefined,
features: [ features: [
{ text: t`All Pro features +` }, t`Run on your own infrastructure`,
{ text: t`SAML SSO` }, t`Own your data`,
{ text: t`Advanced admin controls` }, t`Custom domain`,
{ text: t`Dedicated account manager` },
{ text: t`Custom SLA` },
{ text: t`On-premise deployment option` },
], ],
highlighted: false,
showPrice: true,
showPriceSuffix: false,
ctaSubtext: undefined,
mostPopular: false, mostPopular: false,
showPrice: false,
}, },
]; ];
return ( return (
<> <>
<div className="mx-auto max-w-7xl px-4"> <div className="flex flex-col items-center justify-center px-4 pb-10">
<div className="mb-8 flex items-center justify-center"> <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`}> <fieldset aria-label={t`Payment frequency`}>
<RadioGroup <RadioGroup
value={frequency} value={frequency}
onChange={(value) => setFrequency(value)} 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) => ( {frequencies.map((option) => (
<Radio <Radio
key={option.value} key={option.value}
value={option} value={option}
className={twMerge( 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 frequency?.value === option.value
? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000" ? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
: "text-light-600 dark:text-dark-700", : "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
)} )}
> >
{option.label} {option.label}
@@ -140,129 +106,103 @@ const Pricing = ({
</RadioGroup> </RadioGroup>
</fieldset> </fieldset>
</div> </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) => ( {tiers.map((tier) => (
<div <div
key={tier.id} key={tier.id}
className={twMerge( className={twMerge(
"relative flex h-full flex-col rounded-lg border p-6", tier.highlighted
tier.mostPopular ? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800"
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100" : "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
: "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50", "rounded-3xl p-8 xl:p-10",
)} )}
> >
{tier.id === "tier-pro" && ( <div className="flex items-center justify-between gap-x-4">
<div className="absolute right-2 top-2 z-10"> <h3
<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"> id={tier.id}
<HiBolt className="h-3 w-3 -ml-0.5" /> className={twMerge(
{t`Launch offer`} tier.highlighted
</span> ? "text-dark-1000 dark:text-dark-1000"
</div> : "text-dark-50 dark:text-dark-1000",
)} "text-lg/8 font-semibold",
<h3 )}
id={tier.id} >
{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( className={twMerge(
"mb-4 flex items-center text-base font-semibold", "mt-4 text-sm/6 text-dark-950 dark:text-dark-900",
tier.mostPopular tier.highlighted ? "text-light-100" : "text-dark-50",
? "text-light-1000 dark:text-dark-1000"
: "text-light-1000 dark:text-dark-1000",
)} )}
> >
{tier.name} {tier.description}
{tier.id === "tier-pro" && <span className="ml-1 text-xl leading-none"></span>} </p>
</h3> <p className="mt-6 flex items-baseline gap-x-1">
<div className="mb-4"> <span
<p className="flex items-baseline gap-x-1"> className={twMerge(
<span "text-3xl font-semibold tracking-tight text-light-100",
className={twMerge( tier.highlighted
"text-2xl font-semibold", ? "text-light-50 dark:text-dark-1000"
tier.mostPopular : "text-gray-900 dark:text-dark-1000",
? "text-light-1000 dark:text-dark-1000" !tier.showPrice && "opacity-0",
: "text-light-1000 dark:text-dark-1000", )}
!tier.showPrice && "opacity-0", >
)} {tier.price[frequency?.value ?? "monthly"]}
> </span>
{tier.price[frequency?.value ?? "monthly"]} {tier.showPriceSuffix && (
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
{frequency?.priceSuffix}
</span> </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> </p>
<ul role="list" className="space-y-2.5"> <Link
{tier.features.map((feature, index) => ( href={tier.href}
<li aria-describedby={tier.id}
key={`${tier.id}-feature-${index}`} className={twMerge(
className="flex items-start gap-x-2.5" tier.highlighted
> ? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
<HiCheckCircle : "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 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",
/> )}
<span className="text-sm text-dark-600 dark:text-dark-800"> >
{feature.text} {tier.buttonText}
</span> </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> </li>
))} ))}
</ul> </ul>
</div> </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> </div>
</> </>
); );

View File

@@ -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"> <p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
{t`Simple pricing`} {t`Simple pricing`}
</p> </p>
<p className="text:md lg:text-md mt-6 max-w-[300px] text-center text-light-950 dark:text-dark-900"> <p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900">
{t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`} {t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
</p> </p>
</div> </div>
<PricingTiers <PricingTiers
@@ -59,9 +59,23 @@ export default function PricingView() {
</div> </div>
<div className="pb-22 flex flex-col items-center justify-center px-4"> <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 <FeatureComparisonTable
frequencyValue={frequency?.value ?? "annually"} frequencyValue={frequency?.value ?? "annually"}
/> />

View File

@@ -47,7 +47,7 @@ export function CardModal({
} }
}; };
const { data, isLoading, error } = api.card.byId.useQuery( const { data, isLoading } = api.card.byId.useQuery(
{ {
cardPublicId: cardPublicId ?? "", 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 labels = data?.labels ?? [];
const handleScroll = () => { const handleScroll = () => {

View File

@@ -1,6 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
@@ -13,22 +12,13 @@ export default function PublicBoardsView() {
? router.query.workspaceSlug[0] ? router.query.workspaceSlug[0]
: router.query.workspaceSlug; : router.query.workspaceSlug;
const { data, isLoading, error } = api.workspace.bySlug.useQuery( const { data, isLoading } = api.workspace.bySlug.useQuery(
{ {
workspaceSlug: workspaceSlug ?? "", workspaceSlug: workspaceSlug ?? "",
}, },
{ enabled: !!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 = ({ const BoardsList = ({
isLoading, isLoading,
boards, boards,

View File

@@ -3,7 +3,6 @@ import { env } from "next-runtime-env";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
import { FontSizeSelector } from "~/components/FontSizeSelector";
import { LanguageSelector } from "~/components/LanguageSelector"; import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -55,16 +54,6 @@ export default function AccountSettings() {
<LanguageSelector /> <LanguageSelector />
</div> </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`Font size`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Change the application font size.`}
</p>
<FontSizeSelector />
</div>
<div className="mb-8 border-t border-light-300 dark:border-dark-300"> <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"> <h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Delete account`} {t`Delete account`}

View File

@@ -1,13 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2"; import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
import Input from "~/components/Input";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
@@ -15,28 +11,10 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
const githubTokenSchema = z.object({
token: z.string().min(1, { message: t`Token is required` }),
});
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
export default function IntegrationsSettings() { export default function IntegrationsSettings() {
const { modalContentType, isOpen } = useModal(); const { modalContentType, isOpen } = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
reset,
} = useForm<GitHubTokenFormValues>({
resolver: zodResolver(githubTokenSchema),
defaultValues: {
token: "",
},
});
const { const {
data: integrations, data: integrations,
refetch: refetchIntegrations, refetch: refetchIntegrations,
@@ -56,25 +34,21 @@ export default function IntegrationsSettings() {
}, },
); );
const { data: githubStatus, refetch: refetchGithubStatus } =
api.integration.getGitHubStatus.useQuery();
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
void refetchIntegrations(); refetchIntegrations();
void refetchGithubStatus();
}; };
window.addEventListener("focus", handleFocus); window.addEventListener("focus", handleFocus);
return () => { return () => {
window.removeEventListener("focus", handleFocus); window.removeEventListener("focus", handleFocus);
}; };
}, [refetchIntegrations, refetchGithubStatus]); }, [refetchIntegrations]);
const { mutateAsync: disconnectTrello } = const { mutateAsync: disconnectTrello } =
api.integration.disconnect.useMutation({ api.integration.disconnect.useMutation({
onSuccess: () => { onSuccess: () => {
void refetchIntegrations(); refetchIntegrations();
void refetchTrelloUrl(); refetchTrelloUrl();
showPopup({ showPopup({
header: t`Trello disconnected`, header: t`Trello disconnected`,
message: t`Your Trello account has been 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 ( return (
<> <>
<PageHead title={t`Settings | Integrations`} /> <PageHead title={t`Settings | Integrations`} />
@@ -181,51 +112,6 @@ export default function IntegrationsSettings() {
)} )}
</div> </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 */} {/* Global modals */}
<Modal <Modal
modalSize="md" modalSize="md"

View File

@@ -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>
</>
);
}

View File

@@ -21,7 +21,6 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm"; import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm"; import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm"; import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation"; import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() { export default function WorkspaceSettings() {
@@ -87,15 +86,6 @@ export default function WorkspaceSettings() {
disabled={!canEditWorkspace} 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"> <h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Email visibility`} {t`Email visibility`}
</h2> </h2>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, 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-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}), }),

View File

@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More