Compare commits

..

1 Commits

Author SHA1 Message Date
Henry
ac9bdaf316 feat: improve pricing tiers and add comparison table 2026-02-18 21:58:58 +00:00
156 changed files with 27689 additions and 84888 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

@@ -27,7 +27,6 @@
"@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 +79,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

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

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

@@ -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";
@@ -125,21 +124,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 +141,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();
@@ -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}
/> />
@@ -611,12 +598,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}` ? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}` : `/cards/${card.publicId}`
} }
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith( className={`mb-2 flex !cursor-pointer flex-col ${
"PLACEHOLDER", card.publicId.startsWith(
) "PLACEHOLDER",
? "pointer-events-none" )
: "" ? "pointer-events-none"
}`} : ""
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}

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

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

@@ -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,8 +3,8 @@ 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 { HiXMark } from "react-icons/hi2";
import { authClient } from "@kan/auth/client"; import { authClient } from "@kan/auth/client";
@@ -185,20 +185,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 +302,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
<> <>
<PageHead <PageHead
@@ -353,7 +345,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
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" 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`} aria-label={t`Close`}
> >
<HiXMark className="h-4 w-4" /> <HiXMark className="h-5 w-5" />
</Link> </Link>
</div> </div>
</> </>

View File

@@ -8,10 +8,10 @@ type FrequencyValue = "monthly" | "annually";
interface PlanFeature { interface PlanFeature {
key: string; key: string;
label: string; label: string;
free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean }; free: string | boolean | { text: string; highlight?: string };
teams: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean }; teams: string | boolean | { text: string; highlight?: string };
pro: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean }; pro: string | boolean | { text: string; highlight?: string };
enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean }; enterprise: string | boolean | { text: string; highlight?: string };
} }
interface FeatureSection { interface FeatureSection {
@@ -71,34 +71,34 @@ const FeatureComparisonTable = ({
{ {
key: "boards", key: "boards",
label: t`Boards`, label: t`Boards`,
free: { text: t`Unlimited boards`, highlightFirstWord: true }, free: { text: t`Unlimited boards`, highlight: "Unlimited" },
teams: { text: t`Unlimited boards`, highlightFirstWord: true }, teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
pro: { text: t`Unlimited boards`, highlightFirstWord: true }, pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited boards`, highlightFirstWord: true }, enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
}, },
{ {
key: "members", key: "members",
label: t`Members`, label: t`Members`,
free: { text: t`1 user`, highlightFirstWord: true }, free: { text: t`1 user`, highlight: "1" },
teams: { text: t`Per-seat pricing`, highlightFirstWord: true }, teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
pro: { text: t`Unlimited members`, highlightFirstWord: true }, pro: { text: t`Unlimited members`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited members`, highlightFirstWord: true }, enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
}, },
{ {
key: "file-uploads", key: "file-uploads",
label: t`File uploads`, label: t`File uploads`,
free: { text: t`10mb file uploads`, highlightFirstWord: true }, free: { text: t`10mb file uploads`, highlight: "10mb" },
teams: { text: t`Unlimited file uploads`, highlightFirstWord: true }, teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
pro: { text: t`Unlimited file uploads`, highlightFirstWord: true }, pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true }, enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
}, },
{ {
key: "workspace-username", key: "workspace-username",
label: t`Workspace username`, label: t`Workspace username`,
free: { text: t`Default username`, highlightFirstWord: true }, free: { text: t`Default username`, highlight: "Default" },
teams: { text: t`Default username`, highlightFirstWord: true }, teams: { text: t`Default username`, highlight: "Default" },
pro: { text: t`Custom username`, highlightFirstWord: true }, pro: { text: t`Custom username`, highlight: "Custom" },
enterprise: { text: t`Custom username`, highlightFirstWord: true }, enterprise: { text: t`Custom username`, highlight: "Custom" },
}, },
]; ];
@@ -236,9 +236,9 @@ const FeatureComparisonTable = ({
key: "rest-api", key: "rest-api",
label: t`API`, label: t`API`,
free: { text: t`Limited API access` }, free: { text: t`Limited API access` },
teams: { text: t`Full API access`, highlight: true }, teams: { text: t`Full API access` },
pro: { text: t`Full API access`, highlight: true }, pro: { text: t`Full API access` },
enterprise: { text: t`Full API access`, highlight: true }, enterprise: { text: t`Full API access` },
}, },
{ {
key: "self-hostable", key: "self-hostable",
@@ -254,18 +254,18 @@ const FeatureComparisonTable = ({
{ {
key: "sso", key: "sso",
label: t`SSO`, label: t`SSO`,
free: { text: t`Google SSO`, highlight: true }, free: { text: t`Google SSO` },
teams: { text: t`Google SSO`, highlight: true }, teams: { text: t`Google SSO` },
pro: { text: t`Google SSO`, highlight: true }, pro: { text: t`Google SSO` },
enterprise: { text: t`Google SSO + SAML`, highlight: true }, enterprise: { text: t`Google SSO + SAML` },
}, },
{ {
key: "admin-roles", key: "admin-roles",
label: t`Admin roles`, label: t`Admin roles`,
free: { text: t`Admin roles`, highlight: true }, free: { text: t`Admin roles` },
teams: { text: t`Admin roles`, highlight: true }, teams: { text: t`Admin roles` },
pro: { text: t`Admin roles`, highlight: true }, pro: { text: t`Admin roles` },
enterprise: { text: t`Advanced admin roles`, highlight: true }, enterprise: { text: t`Advanced admin roles` },
}, },
]; ];
@@ -334,37 +334,59 @@ const FeatureComparisonTable = ({
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" }, { id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
]; ];
const isHighlightValue = (
value: string | boolean | { text: string; highlight?: string },
): value is { text: string; highlight?: string } => {
return typeof value === "object" && value !== null && "text" in value;
};
const shouldHighlight = (text: string): boolean => {
const lowerText = text.toLowerCase();
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
(keyword) => lowerText.includes(keyword),
);
};
const CellValue = ({ const CellValue = ({
value, value,
label, label,
}: { }: {
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean }; value: string | boolean | { text: string; highlight?: string };
label: string; label: string;
}) => { }) => {
if (typeof value === "object" && value !== null) { // Handle object with text and highlight
const { text, highlight, highlightFirstWord } = value; if (isHighlightValue(value)) {
const { text, highlight } = value;
if (highlightFirstWord) { const isUnlimited = shouldHighlight(text);
const spaceIndex = text.indexOf(" ");
const firstWord = spaceIndex === -1 ? text : text.slice(0, spaceIndex); let displayText;
const rest = spaceIndex === -1 ? "" : text.slice(spaceIndex); if (highlight) {
return ( const parts = text.split(new RegExp(`(${highlight})`, "gi"));
<div className="flex items-center gap-2.5"> displayText = (
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" /> <>
<span className="text-sm font-medium"> {parts.map((part, index) =>
<span className="text-light-1000 dark:text-dark-1000">{firstWord}</span> part.toLowerCase() === highlight.toLowerCase() ? (
<span className="text-light-950 dark:text-dark-800">{rest}</span> <span key={index} className="text-light-1000 dark:text-dark-1000">
</span> {part}
</div> </span>
) : (
<span key={index} className="text-light-950 dark:text-dark-800">
{part}
</span>
),
)}
</>
); );
} else {
displayText = text;
} }
return ( return (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<HiCheckCircle <HiCheckCircle
className={twMerge( className={twMerge(
"mt-0.5 h-4 w-4 shrink-0", "mt-0.5 h-4 w-4 shrink-0",
highlight isUnlimited
? "text-light-1000 dark:text-dark-1000" ? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600", : "text-light-400 dark:text-dark-600",
)} )}
@@ -372,18 +394,44 @@ const FeatureComparisonTable = ({
<span <span
className={twMerge( className={twMerge(
"text-sm font-medium", "text-sm font-medium",
highlight isUnlimited
? "text-light-1000 dark:text-dark-950" ? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800", : "text-light-950 dark:text-dark-800",
)} )}
> >
{text} {displayText}
</span>
</div>
);
}
if (typeof value === "string") {
const isUnlimited = shouldHighlight(value);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{value}
</span> </span>
</div> </div>
); );
} }
if (value) { if (value) {
// Boolean true - show checkmark icon
return ( return (
<div className="flex items-center gap-2.5"> <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" /> <HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
@@ -393,7 +441,7 @@ const FeatureComparisonTable = ({
</div> </div>
); );
} }
// Boolean false - show cross icon
return ( return (
<div className="flex items-center gap-2.5"> <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" /> <HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
@@ -407,7 +455,7 @@ const FeatureComparisonTable = ({
const getFeatureValue = ( const getFeatureValue = (
feature: PlanFeature, feature: PlanFeature,
planId: string, planId: string,
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => { ): string | boolean | { text: string; highlight?: string } => {
switch (planId) { switch (planId) {
case "free": case "free":
return feature.free; return feature.free;

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

View File

@@ -1,17 +1,4 @@
services: services:
migrator:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
build:
context: ..
dockerfile: apps/web/Dockerfile
target: migrate
networks:
- dokploy-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
restart: "no"
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -22,7 +9,6 @@ services:
build: build:
context: .. context: ..
dockerfile: apps/web/Dockerfile dockerfile: apps/web/Dockerfile
target: web
args: args:
APP_VERSION: ${APP_VERSION:-} APP_VERSION: ${APP_VERSION:-}
env_file: env_file:
@@ -37,9 +23,6 @@ services:
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT} - NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Stripe # Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
@@ -69,7 +52,6 @@ services:
- S3_REGION=${S3_REGION} - S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT} - S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE} - S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL} - NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME} - NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
@@ -97,9 +79,6 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID} - NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
depends_on:
migrator:
condition: service_completed_successfully
networks: networks:
dokploy-network: dokploy-network:

View File

@@ -1,20 +1,4 @@
services: services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${CONTAINER_NAME:-kan-migrate}
networks:
- kan-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: migrate
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -22,10 +6,10 @@ services:
- "${WEB_PORT:-3000}:3000" - "${WEB_PORT:-3000}:3000"
networks: networks:
- kan-network - kan-network
- dokploy-network
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile.new
target: web
env_file: env_file:
- .env - .env
environment: environment:
@@ -37,9 +21,6 @@ services:
# Redis (optional - for rate limiting) # Redis (optional - for rate limiting)
- REDIS_URL=${REDIS_URL} - REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Admin API key (optional) # Admin API key (optional)
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY} - KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
@@ -60,7 +41,6 @@ services:
- S3_REGION=${S3_REGION} - S3_REGION=${S3_REGION}
- S3_ENDPOINT=${S3_ENDPOINT} - S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE} - S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL} - NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME} - NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
@@ -127,8 +107,7 @@ services:
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET} - APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER} - APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on: depends_on:
migrate: - postgres
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
postgres: postgres:
@@ -142,17 +121,14 @@ services:
- 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
networks: networks:
kan-network: kan-network:
dokploy-network:
external: true
volumes: volumes:
kan_postgres_data: kan_postgres_data:

View File

@@ -1,75 +0,0 @@
import { PGlite } from "@electric-sql/pglite";
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
import { drizzle } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import * as schema from "@kan/db/schema";
export type TestDbClient = NodePgDatabase<typeof schema> & {
$client: Pool;
};
/**
* Creates a fresh in-memory PGlite database for testing.
* Each call returns an isolated database instance with migrations applied.
*/
export async function createTestDb(): Promise<TestDbClient> {
const client = new PGlite({
extensions: { uuid_ossp, pg_trgm },
});
const db = drizzle(client, { schema });
// Run migrations
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
return db as unknown as TestDbClient;
}
/**
* Seeds a test database with a workspace and user for testing.
* Returns the created entities for use in tests.
*/
export async function seedTestData(db: TestDbClient) {
// Create a test user
const [user] = await db
.insert(schema.users)
.values({
id: crypto.randomUUID(),
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
// Create a test workspace (publicId must be exactly 12 chars)
const [workspace] = await db
.insert(schema.workspaces)
.values({
publicId: "wstest123456",
name: "Test Workspace",
slug: "test-workspace",
ownerId: user!.id,
createdAt: new Date(),
})
.returning();
// Add user as admin member of workspace
await db.insert(schema.workspaceMembers).values({
publicId: "wm1234567890",
email: user!.email,
workspaceId: workspace!.id,
userId: user!.id,
createdBy: user!.id,
role: "admin",
status: "active",
createdAt: new Date(),
});
return { user: user!, workspace: workspace! };
}

View File

@@ -1,242 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
describe("webhook repository integration tests", () => {
let db: TestDbClient;
let testUser: { id: string; name: string | null };
let testWorkspace: { id: number; publicId: string };
beforeEach(async () => {
db = await createTestDb();
const seeded = await seedTestData(db);
testUser = seeded.user;
testWorkspace = seeded.workspace;
});
describe("create", () => {
it("creates a webhook with all fields", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "My Webhook",
url: "https://example.com/webhook",
secret: "my-secret",
events: ["card.created", "card.updated"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("My Webhook");
expect(webhook!.url).toBe("https://example.com/webhook");
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
expect(webhook!.active).toBe(true);
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
});
it("creates a webhook without secret", async () => {
const webhook = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "No Secret Webhook",
url: "https://example.com/webhook",
events: ["card.deleted"],
createdBy: testUser.id,
});
expect(webhook).not.toBeNull();
expect(webhook!.name).toBe("No Secret Webhook");
});
});
describe("getByPublicId", () => {
it("retrieves a webhook by public ID", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).not.toBeNull();
expect(retrieved!.publicId).toBe(created!.publicId);
expect(retrieved!.name).toBe("Test Webhook");
});
it("returns null for non-existent public ID", async () => {
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
expect(retrieved).toBeNull();
});
});
describe("getAllByWorkspaceId", () => {
it("returns all webhooks for a workspace", async () => {
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 1",
url: "https://example.com/webhook1",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Webhook 2",
url: "https://example.com/webhook2",
events: ["card.updated"],
createdBy: testUser.id,
});
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toHaveLength(2);
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
});
it("returns empty array for workspace with no webhooks", async () => {
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
expect(webhooks).toEqual([]);
});
});
describe("getActiveByWorkspaceId", () => {
it("returns only active webhooks", async () => {
const active = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Active Webhook",
url: "https://example.com/active",
events: ["card.created"],
createdBy: testUser.id,
});
const inactive = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Inactive Webhook",
url: "https://example.com/inactive",
events: ["card.created"],
createdBy: testUser.id,
});
// Deactivate one webhook
await webhookRepo.update(db, inactive!.publicId, { active: false });
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
expect(activeWebhooks).toHaveLength(1);
// getActiveByWorkspaceId returns only publicId, url, secret, events
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
});
});
describe("update", () => {
it("updates webhook name", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Original Name",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated Name",
});
expect(updated).not.toBeNull();
expect(updated!.name).toBe("Updated Name");
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
});
it("updates webhook events", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
const updated = await webhookRepo.update(db, created!.publicId, {
events: ["card.created", "card.updated", "card.deleted"],
});
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
});
it("updates webhook active status", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
expect(created!.active).toBe(true);
const updated = await webhookRepo.update(db, created!.publicId, {
active: false,
});
expect(updated!.active).toBe(false);
});
it("sets updatedAt timestamp on update", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "Test Webhook",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
// create() doesn't return updatedAt, verify via getByPublicId
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
expect(initial!.updatedAt).toBeNull();
const updated = await webhookRepo.update(db, created!.publicId, {
name: "Updated",
});
expect(updated!.updatedAt).not.toBeNull();
expect(updated!.updatedAt).toBeInstanceOf(Date);
});
it("returns null for non-existent webhook", async () => {
const updated = await webhookRepo.update(db, "nonexistent12", {
name: "Updated",
});
expect(updated).toBeNull();
});
});
describe("hardDelete", () => {
it("deletes a webhook permanently", async () => {
const created = await webhookRepo.create(db, {
workspaceId: testWorkspace.id,
name: "To Be Deleted",
url: "https://example.com/webhook",
events: ["card.created"],
createdBy: testUser.id,
});
await webhookRepo.hardDelete(db, created!.publicId);
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
expect(retrieved).toBeNull();
});
it("does not throw for non-existent webhook", async () => {
await expect(
webhookRepo.hardDelete(db, "nonexistent12"),
).resolves.not.toThrow();
});
});
});

View File

@@ -40,15 +40,12 @@
"dev": "tsc", "dev": "tsc",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint", "lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit --emitDeclarationOnly false" "typecheck": "tsc --noEmit --emitDeclarationOnly false"
}, },
"dependencies": { "dependencies": {
"@kan/auth": "workspace:*", "@kan/auth": "workspace:*",
"@kan/db": "workspace:*", "@kan/db": "workspace:*",
"@kan/email": "workspace:^", "@kan/email": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^", "@kan/shared": "workspace:^",
"@kan/stripe": "workspace:^", "@kan/stripe": "workspace:^",
"@trpc/server": "catalog:", "@trpc/server": "catalog:",
@@ -63,8 +60,7 @@
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"typescript": "catalog:", "typescript": "catalog:"
"vitest": "^3.0.0"
}, },
"prettier": "@kan/prettier-config" "prettier": "@kan/prettier-config"
} }

View File

@@ -11,7 +11,6 @@ import { listRouter } from "./routers/list";
import { memberRouter } from "./routers/member"; import { memberRouter } from "./routers/member";
import { permissionRouter } from "./routers/permission"; import { permissionRouter } from "./routers/permission";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { webhookRouter } from "./routers/webhook";
import { workspaceRouter } from "./routers/workspace"; import { workspaceRouter } from "./routers/workspace";
import { createTRPCRouter } from "./trpc"; import { createTRPCRouter } from "./trpc";
@@ -28,7 +27,6 @@ export const appRouter = createTRPCRouter({
import: importRouter, import: importRouter,
permission: permissionRouter, permission: permissionRouter,
user: userRouter, user: userRouter,
webhook: webhookRouter,
workspace: workspaceRouter, workspace: workspaceRouter,
integration: integrationRouter, integration: integrationRouter,
}); });

View File

@@ -34,7 +34,6 @@ export const boardRouter = createTRPCRouter({
z.object({ z.object({
workspacePublicId: z.string().min(12), workspacePublicId: z.string().min(12),
type: z.enum(["regular", "template"]).optional(), type: z.enum(["regular", "template"]).optional(),
archived: z.boolean().optional(),
}), }),
) )
.output( .output(
@@ -66,10 +65,7 @@ export const boardRouter = createTRPCRouter({
ctx.db, ctx.db,
workspace.id, workspace.id,
userId, userId,
{ { type: input.type }
type: input.type,
archived: input.archived ?? false,
}
); );
return result; return result;
@@ -157,24 +153,24 @@ export const boardRouter = createTRPCRouter({
// Generate presigned URLs for workspace member avatars // Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace const workspaceWithAvatarUrls = result.workspace
? { ? {
...result.workspace, ...result.workspace,
members: await Promise.all( members: await Promise.all(
result.workspace.members.map(async (member) => { result.workspace.members.map(async (member) => {
if (!member.user?.image) { if (!member.user?.image) {
return member; return member;
} }
const avatarUrl = await generateAvatarUrl(member.user.image); const avatarUrl = await generateAvatarUrl(member.user.image);
return { return {
...member, ...member,
user: { user: {
...member.user, ...member.user,
image: avatarUrl, image: avatarUrl,
}, },
}; };
}), }),
), ),
} }
: result.workspace; : result.workspace;
// Generate presigned URLs for card member avatars // Generate presigned URLs for card member avatars
@@ -222,7 +218,7 @@ export const boardRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z boardSlug: z
.string() .string()
@@ -465,8 +461,7 @@ export const boardRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
visibility: z.enum(["public", "private"]).optional(), visibility: z.enum(["public", "private"]).optional(),
favorite: z.boolean().optional(), favorite: z.boolean().optional()
isArchived: z.boolean().optional(),
}), }),
) )
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())) .output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
@@ -508,7 +503,7 @@ export const boardRouter = createTRPCRouter({
} }
// Handle other updates (name, slug, visibility) // Handle other updates (name, slug, visibility)
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined; const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
if (!hasOtherUpdates) { if (!hasOtherUpdates) {
// Only favorite was updated, return success // Only favorite was updated, return success
@@ -535,7 +530,6 @@ export const boardRouter = createTRPCRouter({
slug: input.slug, slug: input.slug,
boardPublicId: input.boardPublicId, boardPublicId: input.boardPublicId,
visibility: input.visibility, visibility: input.visibility,
isArchived: input.isArchived,
}); });
if (!result) if (!result)

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