Compare commits
66 Commits
fix/hide-e
...
security/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
641237de83 | ||
|
|
a6780e3e28 | ||
|
|
2c5195a2b8 | ||
|
|
793fa30162 | ||
|
|
af63719de5 | ||
|
|
52fd624b81 | ||
|
|
b4d4810f45 | ||
|
|
239d152340 | ||
|
|
f294760747 | ||
|
|
280d8f66dd | ||
|
|
eeae23a24c | ||
|
|
d81950b8bd | ||
|
|
e530f39360 | ||
|
|
bd25fb33f7 | ||
|
|
93f2816b37 | ||
|
|
2292b972c4 | ||
|
|
af36fb133a | ||
|
|
9cb1fb5218 | ||
|
|
5f190b92de | ||
|
|
48a1f39588 | ||
|
|
c7fce10fa7 | ||
|
|
094e1d139d | ||
|
|
fae169c691 | ||
|
|
91bc319cf1 | ||
|
|
d42540b9ef | ||
|
|
71ecf2e6d3 | ||
|
|
8a9cb8fabf | ||
|
|
37f6be52ee | ||
|
|
b72449bef1 | ||
|
|
5cb47a588f | ||
|
|
d5c2404715 | ||
|
|
0276c51ac7 | ||
|
|
1eee819c9d | ||
|
|
2113cc7873 | ||
|
|
32714ccedf | ||
|
|
e72b34b541 | ||
|
|
b44a1296a0 | ||
|
|
3bf132286b | ||
|
|
c9c3365c0a | ||
|
|
7c2937a7b7 | ||
|
|
f27a031ea8 | ||
|
|
05a0245988 | ||
|
|
b6b9423823 | ||
|
|
1696aab43b | ||
|
|
33d2f23955 | ||
|
|
961219835e | ||
|
|
031a42b9eb | ||
|
|
8a1189d011 | ||
|
|
53f6ada4d3 | ||
|
|
6ea02b1db1 | ||
|
|
737b1383d0 | ||
|
|
38d0e9495c | ||
|
|
19798ddbc9 | ||
|
|
08ba965921 | ||
|
|
008b5fd1a0 | ||
|
|
97cfd60b59 | ||
|
|
24e283e22c | ||
|
|
07db7409cb | ||
|
|
346ddc16be | ||
|
|
f9eee53ed0 | ||
|
|
1ab4e24c96 | ||
|
|
34165b390f | ||
|
|
ef0d53db8f | ||
|
|
78b9de869f | ||
|
|
81c03b51e2 | ||
|
|
0d3d9a358f |
@@ -1,18 +1,62 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
Dockerfile
|
||||
./**/*/Dockerfile
|
||||
|
||||
# Docker
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Dependencies (rebuilt in Docker)
|
||||
node_modules
|
||||
./**/*/node_modules
|
||||
**/node_modules
|
||||
|
||||
# Build outputs (rebuilt in Docker)
|
||||
**/.next
|
||||
**/dist
|
||||
**/out
|
||||
**/.turbo
|
||||
**/.cache
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.husky
|
||||
.changeset
|
||||
|
||||
# Documentation (not needed in build)
|
||||
*.md
|
||||
!packages/db/migrations/**
|
||||
LICENSE
|
||||
CONTRIBUTING.md
|
||||
AGENTS.md
|
||||
CHANGELOG.md
|
||||
|
||||
# Test files
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
**/__tests__
|
||||
**/coverage
|
||||
|
||||
# Logs
|
||||
pnpm-debug.log
|
||||
./**/*/pnpm-debug.log
|
||||
**/pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
# .git
|
||||
# Cloud compose (separate deployment)
|
||||
cloud/
|
||||
|
||||
56
.github/workflows/docker-publish.yml
vendored
56
.github/workflows/docker-publish.yml
vendored
@@ -6,23 +6,31 @@ name: Docker
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "21 21 * * *"
|
||||
push:
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
# Only trigger on tags (releases), not main branch pushes
|
||||
# Main branch builds are handled by workflow_run after Translate completes
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_run:
|
||||
workflows: ["Translate"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
# Docker builds after Translate completes (success or failure)
|
||||
# This ensures Docker always has the latest translations
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
||||
|
||||
jobs:
|
||||
build:
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -74,7 +82,22 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
|
||||
- name: Extract Docker metadata (migrate)
|
||||
id: meta-migrate
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
@@ -129,6 +152,22 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push migrate Docker image
|
||||
id: build-and-push-migrate
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
target: migrate
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||
labels: ${{ steps.meta-migrate.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
@@ -143,3 +182,10 @@ jobs:
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
- name: Sign the published migrate Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
56
.github/workflows/translate.yml
vendored
Normal file
56
.github/workflows/translate.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
66
README.md
66
README.md
@@ -47,39 +47,71 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Self Hosting 🐳
|
||||
|
||||
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||
### One-click Deployments
|
||||
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
|
||||
|
||||
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
||||
</a>
|
||||
|
||||
### 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.
|
||||
|
||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
||||
|
||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: kan-migrate
|
||||
networks:
|
||||
- kan-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: kan-web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
||||
BETTER_AUTH_SECRET: your_auth_secret
|
||||
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
||||
depends_on:
|
||||
- postgres
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: kan-db
|
||||
environment:
|
||||
POSTGRES_DB: kan_db
|
||||
POSTGRES_USER: kan
|
||||
POSTGRES_PASSWORD: your_postgres_password
|
||||
- POSTGRES_DB=kan_db
|
||||
- POSTGRES_USER=kan
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
@@ -91,23 +123,23 @@ volumes:
|
||||
kan_postgres_data:
|
||||
```
|
||||
|
||||
2. Start the containers in detached mode:
|
||||
3. Start the containers in detached mode:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Access Kan at http://localhost:3000
|
||||
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`).
|
||||
|
||||
The application will be running in the background. You can manage the containers using these commands:
|
||||
**Managing containers:**
|
||||
|
||||
- To stop the containers: `docker compose down`
|
||||
- To view logs: `docker compose logs -f`
|
||||
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
||||
- To restart the containers: `docker compose restart`
|
||||
- To rebuild after code changes: `docker compose up -d --build`
|
||||
|
||||
For the complete Docker Compose configuration, 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.
|
||||
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
ARG NODE_VERSION=20
|
||||
ARG APP_DIRNAME=web
|
||||
ARG PROJECT=@kan/web
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# 1. Alpine image
|
||||
ARG NODE_VERSION=20
|
||||
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Alpine base with pnpm and turbo
|
||||
# ============================================
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update && \
|
||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true
|
||||
apk add --no-cache libc6-compat && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||
corepack enable && \
|
||||
npm install turbo@2.3.1 --global && \
|
||||
pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# Setup pnpm and turbo on the alpine base
|
||||
FROM alpine AS base
|
||||
RUN corepack enable
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN npm install turbo@2.1.3 --global
|
||||
RUN npm install turbo@2.3.1 --global
|
||||
|
||||
RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
ARG PROJECT
|
||||
# ============================================
|
||||
# Stage 2: Prune the monorepo
|
||||
# ============================================
|
||||
FROM alpine AS pruner
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
@@ -35,53 +33,89 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
ARG APP_VERSION
|
||||
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Install dependencies
|
||||
# ============================================
|
||||
FROM alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
ENV CI=true
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||
|
||||
# 4. Final image - runner stage to run the application
|
||||
FROM base AS runner
|
||||
ARG APP_DIRNAME
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
# ============================================
|
||||
# Stage 4: Build the web application
|
||||
# ============================================
|
||||
FROM alpine AS builder
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/ ./
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
# Force standalone output for production Docker image
|
||||
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
||||
ENV CI=true
|
||||
|
||||
RUN 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
|
||||
|
||||
# ============================================
|
||||
# Stage 5: Migration image (run-once container)
|
||||
# ============================================
|
||||
FROM node:${NODE_VERSION}-alpine AS migrate
|
||||
WORKDIR /db
|
||||
|
||||
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY packages/db/migrations/ ./migrations/
|
||||
|
||||
RUN npm init -y && \
|
||||
npm install drizzle-kit drizzle-orm pg --save-exact && \
|
||||
# Strip unnecessary files from node_modules
|
||||
find node_modules -type f \( \
|
||||
-name '*.d.ts' -o \
|
||||
-name '*.d.mts' -o \
|
||||
-name '*.d.cts' -o \
|
||||
-name '*.map' -o \
|
||||
-name '*.md' -o \
|
||||
-name '*.txt' -o \
|
||||
-name 'LICENSE*' -o \
|
||||
-name 'CHANGELOG*' -o \
|
||||
-name 'README*' -o \
|
||||
-name '.eslint*' -o \
|
||||
-name '.prettier*' -o \
|
||||
-name 'tsconfig*.json' \
|
||||
\) -delete && \
|
||||
find node_modules -type d -empty -delete && \
|
||||
rm -rf /root/.npm /tmp/*
|
||||
|
||||
CMD ["npx", "drizzle-kit", "migrate"]
|
||||
|
||||
# ============================================
|
||||
# Stage 6: Production web image (distroless)
|
||||
# ============================================
|
||||
FROM ${DISTROLESS_NODE_IMAGE} AS web
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||
WORKDIR /app/apps/${APP_DIRNAME}
|
||||
# Copy the standalone Next.js server
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
|
||||
ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
|
||||
44
apps/web/bootstrap.cjs
Normal file
44
apps/web/bootstrap.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Bootstrap script for the distroless production image.
|
||||
*
|
||||
* Distroless images have no shell, so this Node.js script handles two tasks
|
||||
* that would normally be done in an entrypoint.sh:
|
||||
*
|
||||
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
||||
* environment variables. The file was originally created at build time by
|
||||
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
||||
* env vars are provided at *run* time via docker-compose / docker run.
|
||||
*
|
||||
* 2. Start the Next.js standalone server.
|
||||
*/
|
||||
|
||||
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const publicDir = path.join(__dirname, "apps", "web", "public");
|
||||
|
||||
if (!existsSync(publicDir)) {
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
|
||||
const envVars = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith("NEXT_PUBLIC_")) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(publicDir, "__ENV.js"),
|
||||
`self.__ENV = ${JSON.stringify(envVars)};`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Start the Next.js standalone server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
require("./apps/web/server.js");
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": 0,
|
||||
"version": "1.10",
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
"json": {
|
||||
"include": ["src/locales/[locale]/messages.json"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
|
||||
3341
apps/web/i18n.lock
3341
apps/web/i18n.lock
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
import { formatter } from "@lingui/format-json";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
|
||||
export default defineConfig({
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
format: formatter({ style: "lingui" }),
|
||||
});
|
||||
|
||||
@@ -16,6 +16,18 @@ const config = {
|
||||
: undefined,
|
||||
reactStrictMode: true,
|
||||
|
||||
/** Exclude build tools and dev-only packages from the standalone output */
|
||||
outputFileTracingExcludes: {
|
||||
"**/*": [
|
||||
"@esbuild/**",
|
||||
"esbuild/**",
|
||||
"typescript/**",
|
||||
"webpack/**",
|
||||
"uglify-js/**",
|
||||
"terser/**",
|
||||
],
|
||||
},
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@kan/api",
|
||||
@@ -35,50 +47,13 @@ const config = {
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
const patterns = [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{ protocol: "https", hostname: "**" },
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
pathname: '/avatars/**',
|
||||
},
|
||||
];
|
||||
|
||||
// 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;
|
||||
})(),
|
||||
},
|
||||
@@ -95,12 +70,6 @@ const config = {
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
},
|
||||
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
||||
},
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/lib-storage": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
@@ -38,6 +39,7 @@
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-mention": "^3.0.9",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/extension-typography": "^3.18.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
"@tiptap/starter-kit": "^2.14.0",
|
||||
@@ -77,6 +79,7 @@
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/format-json": "^5.9.1",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
|
||||
@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
||||
socialProviders?.length === 0 && (
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||
@@ -371,65 +371,62 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.email && (
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
||||
icon?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const initials = name
|
||||
const initials = name?.trim()
|
||||
? getInitialsFromName(name)
|
||||
: inferInitialsFromEmail(email);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -44,6 +45,12 @@ export default function Dashboard({
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!session?.user,
|
||||
},
|
||||
);
|
||||
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
@@ -155,8 +162,12 @@ export default function Dashboard({
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
user={{
|
||||
displayName: user?.name ?? session?.user.name,
|
||||
email: user?.email ?? session?.user.email ?? "",
|
||||
image: user?.image ?? undefined,
|
||||
}}
|
||||
isLoading={sessionLoading || userLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Dropdown({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ReactRenderer,
|
||||
useEditor,
|
||||
} from "@tiptap/react";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const CommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
||||
const headingCommands: SlashCommandItem[] = disableHeadings
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...headingCommands,
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Editor({
|
||||
content,
|
||||
@@ -434,6 +443,8 @@ export default function Editor({
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
placeholder,
|
||||
disableHeadings = false,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -441,18 +452,16 @@ export default function Editor({
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
placeholder?: string;
|
||||
disableHeadings?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
StarterKit.configure({
|
||||
heading: disableHeadings ? false : undefined,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
@@ -463,12 +472,20 @@ export default function Editor({
|
||||
},
|
||||
validate: (href) => /^https?:\/\//.test(href),
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: CommandItems,
|
||||
commandItems: getCommandItems(disableHeadings),
|
||||
suggestion: {
|
||||
items: ({ query }: { query: string }) =>
|
||||
filterSlashCommandItems(CommandItems, query),
|
||||
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
||||
startOfLine: true,
|
||||
char: "/",
|
||||
},
|
||||
@@ -480,20 +497,28 @@ export default function Editor({
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const all: MentionItem[] = workspaceMembers.map(
|
||||
(member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}),
|
||||
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||
|
||||
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name?.trim() || member.email || "",
|
||||
image: member?.user?.image ?? null,
|
||||
}));
|
||||
|
||||
const all: MentionItem[] = mapped.filter(
|
||||
(item) => item.label && item.label.length > 0,
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(u) =>
|
||||
u.label &&
|
||||
typeof u.label === "string" &&
|
||||
u.label.toLowerCase().includes(q),
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
if (q === "") {
|
||||
return all;
|
||||
}
|
||||
|
||||
const filtered = all.filter((u) =>
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
return filtered;
|
||||
},
|
||||
command: ({ editor, range, props }) => {
|
||||
const id = props.id ?? "";
|
||||
@@ -514,6 +539,17 @@ export default function Editor({
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
Typography.configure({
|
||||
openDoubleQuote: false,
|
||||
closeDoubleQuote: false,
|
||||
openSingleQuote: false,
|
||||
closeSingleQuote: false,
|
||||
oneHalf: false,
|
||||
oneQuarter: false,
|
||||
threeQuarters: false,
|
||||
superscriptTwo: false,
|
||||
superscriptThree: false,
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
|
||||
@@ -33,7 +33,7 @@ const schema = z.object({
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
})
|
||||
|
||||
@@ -55,9 +55,10 @@ export default function SideNavigation({
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
|
||||
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
content?: ReactNode;
|
||||
placement?: Placement;
|
||||
delay?: number | [number, number];
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export function Tooltip({
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
rootRef.current = root;
|
||||
|
||||
@@ -19,6 +19,7 @@ interface UsePermissionsResult {
|
||||
canCreateBoard: boolean;
|
||||
canEditBoard: boolean;
|
||||
canDeleteBoard: boolean;
|
||||
canArchiveBoard: boolean;
|
||||
canViewComment: boolean;
|
||||
canCreateComment: boolean;
|
||||
canEditComment: boolean;
|
||||
@@ -33,7 +34,7 @@ interface UsePermissionsResult {
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||
const workspaceContext = useContext(WorkspaceContext);
|
||||
|
||||
|
||||
// If WorkspaceProvider is not available, return safe defaults
|
||||
if (!workspaceContext) {
|
||||
const emptyPermissions: UsePermissionsResult = {
|
||||
@@ -51,6 +52,7 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: false,
|
||||
canEditBoard: false,
|
||||
canDeleteBoard: false,
|
||||
canArchiveBoard: false,
|
||||
canViewComment: false,
|
||||
canCreateComment: false,
|
||||
canEditComment: false,
|
||||
@@ -95,6 +97,7 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: hasPermission("board:create"),
|
||||
canEditBoard: hasPermission("board:edit"),
|
||||
canDeleteBoard: hasPermission("board:delete"),
|
||||
canArchiveBoard: hasPermission("board:edit"),
|
||||
canViewComment: hasPermission("comment:view"),
|
||||
canCreateComment: hasPermission("comment:create"),
|
||||
canEditComment: hasPermission("comment:edit"),
|
||||
|
||||
6252
apps/web/src/locales/de/messages.json
Normal file
6252
apps/web/src/locales/de/messages.json
Normal file
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
10623
apps/web/src/locales/en/messages.json
Normal file
10623
apps/web/src/locales/en/messages.json
Normal file
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
6252
apps/web/src/locales/es/messages.json
Normal file
6252
apps/web/src/locales/es/messages.json
Normal file
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
6252
apps/web/src/locales/fr/messages.json
Normal file
6252
apps/web/src/locales/fr/messages.json
Normal file
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
@@ -7,7 +7,7 @@ export const locales = [
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"pt-BR"
|
||||
"ptbr"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
"pt-BR": "Português",
|
||||
ptbr: "Português",
|
||||
};
|
||||
|
||||
6252
apps/web/src/locales/it/messages.json
Normal file
6252
apps/web/src/locales/it/messages.json
Normal file
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
6252
apps/web/src/locales/nl/messages.json
Normal file
6252
apps/web/src/locales/nl/messages.json
Normal file
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
6252
apps/web/src/locales/pl/messages.json
Normal file
6252
apps/web/src/locales/pl/messages.json
Normal file
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
6252
apps/web/src/locales/pt-BR/messages.json
Normal file
6252
apps/web/src/locales/pt-BR/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/ptbr/messages.ts
Normal file
1
apps/web/src/locales/ptbr/messages.ts
Normal file
File diff suppressed because one or more lines are too long
6252
apps/web/src/locales/ru/messages.json
Normal file
6252
apps/web/src/locales/ru/messages.json
Normal file
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
@@ -13,6 +13,23 @@ const authHandler = toNodeHandler(auth.handler);
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req, res) => {
|
||||
/**
|
||||
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||
*/
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (forwardedProto) {
|
||||
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
const forwardedHost = req.headers["x-forwarded-host"];
|
||||
if (forwardedHost) {
|
||||
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
const { url, filename } = req.query;
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({ message: "url parameter is required" });
|
||||
}
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
const s3Endpoint = env.S3_ENDPOINT;
|
||||
|
||||
if (s3Endpoint) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ message: "Invalid URL" });
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
let allowedHost: string;
|
||||
try {
|
||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||
} catch {
|
||||
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||
}
|
||||
|
||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||
return res.status(403).json({ message: "URL not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename =
|
||||
typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res
|
||||
.status(upstream.status)
|
||||
.json({ message: "Failed to fetch attachment" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to download attachment" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.max(64)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||
|
||||
interface CheckoutSessionRequest {
|
||||
|
||||
136
apps/web/src/pages/api/upload/attachment.ts
Normal file
136
apps/web/src/pages/api/upload/attachment.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||
import { createS3Client, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
||||
}
|
||||
|
||||
const cardPublicId = req.query.cardPublicId;
|
||||
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
|
||||
return res.status(400).json({ error: "Invalid cardPublicId" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
// Get card and check permissions
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
db,
|
||||
cardPublicId,
|
||||
);
|
||||
|
||||
if (!card) {
|
||||
return res.status(404).json({ error: "Card not found" });
|
||||
}
|
||||
|
||||
// Check if user has permission to edit the card
|
||||
try {
|
||||
await assertPermission(db, user.id, card.workspaceId, "card:edit");
|
||||
} catch {
|
||||
return res.status(403).json({ error: "Permission denied" });
|
||||
}
|
||||
|
||||
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
},
|
||||
leavePartsOnError: false,
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
|
||||
// Create attachment record and log activity
|
||||
const attachment = await cardAttachmentRepo.create(db, {
|
||||
cardId: card.id,
|
||||
filename: sanitizedFilename,
|
||||
originalFilename: originalFilenameHeader,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
s3Key,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return res.status(500).json({ error: "Failed to create attachment" });
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: originalFilenameHeader,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
return res.status(200).json({ attachment });
|
||||
} catch (error) {
|
||||
console.error("Attachment upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Avatar bucket not configured" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!allowedContentTypes.includes(contentType)) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
const s3Key = `${user.id}/${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
// Upload the file to S3
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
);
|
||||
|
||||
// Update user image in database
|
||||
const updatedUser = await userRepo.update(db, user.id, {
|
||||
image: s3Key,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
key: s3Key,
|
||||
filename: sanitizedFilename,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Avatar upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const { filename, contentType } = req.body as {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
// Specific to avatar uploads for now
|
||||
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
|
||||
|
||||
if (!filenameRegex.test(filename)) {
|
||||
return res.status(400).json({ error: "Invalid filename" });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof contentType !== "string" ||
|
||||
!allowedContentTypes.includes(contentType)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
const credentials =
|
||||
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const client = new S3Client({
|
||||
region: env.S3_REGION ?? "",
|
||||
endpoint: env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials,
|
||||
});
|
||||
|
||||
const signedUrl = await getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({ url: signedUrl, key: filename });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
@@ -464,19 +465,37 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
||||
? stroke.modifiers.map(stringifyModifier)
|
||||
: [];
|
||||
|
||||
modifierStrings.forEach((mod) => {
|
||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
||||
modifierStrings.forEach((mod, index) => {
|
||||
parts.push(
|
||||
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
|
||||
{mod}
|
||||
</kbd>,
|
||||
);
|
||||
});
|
||||
|
||||
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
||||
parts.push(
|
||||
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
|
||||
{stroke.key.toUpperCase()}
|
||||
</kbd>,
|
||||
);
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
if (shortcut.type === "SEQUENCE") {
|
||||
const parts: ReactNode[] = [];
|
||||
shortcut.strokes.forEach((stroke) => {
|
||||
parts.push(...formatStroke(stroke));
|
||||
shortcut.strokes.forEach((stroke, strokeIndex) => {
|
||||
const strokeParts = formatStroke(stroke);
|
||||
// Add stroke index to keys to ensure uniqueness across multiple strokes
|
||||
const keyedParts = strokeParts.map((part, partIndex) => {
|
||||
if (React.isValidElement(part)) {
|
||||
return React.cloneElement(part, {
|
||||
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
|
||||
});
|
||||
}
|
||||
return part;
|
||||
});
|
||||
parts.push(...keyedParts);
|
||||
});
|
||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export async function invalidateCard(
|
||||
utils: ReturnType<typeof api.useUtils>,
|
||||
cardPublicId: string,
|
||||
) {
|
||||
if (!cardPublicId || cardPublicId.length < 12) return;
|
||||
|
||||
await Promise.all([
|
||||
utils.card.byId.invalidate({ cardPublicId }),
|
||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const formatToArray = (
|
||||
value: string | string[] | undefined,
|
||||
): string[] => {
|
||||
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
|
||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||
|
||||
if (useVirtualHostedUrls === "true" && storageDomain) {
|
||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isSignUpDisabled && (
|
||||
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
|
||||
@@ -28,7 +28,9 @@ export default function SignUpPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
if (isSignUpDisabled) {
|
||||
const isInviteFlow = redirect?.startsWith("/invite/");
|
||||
|
||||
if (isSignUpDisabled && !isInviteFlow) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
HiOutlineStar,
|
||||
HiStar,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { IoArchiveOutline } from "react-icons/io5";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -17,6 +17,7 @@ import { api } from "~/utils/api";
|
||||
export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
isArchived,
|
||||
boardPublicId,
|
||||
isFavorite,
|
||||
boardName,
|
||||
@@ -24,28 +25,29 @@ export default function BoardDropdown({
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||
usePermissions();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
onSuccess: (_data, variables) => {
|
||||
void utils.board.all.invalidate();
|
||||
void utils.board.byId.invalidate();
|
||||
|
||||
// Show popup notification
|
||||
if (variables.favorite !== undefined) {
|
||||
if (variables.isArchived !== undefined) {
|
||||
showPopup({
|
||||
header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
|
||||
message: variables.isArchived
|
||||
? t`The board has been archived.`
|
||||
: t`The board has been unarchived.`,
|
||||
icon: "success",
|
||||
});
|
||||
} else if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
? t`Added to favorites`
|
||||
@@ -65,27 +67,54 @@ export default function BoardDropdown({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveOrUnarchive = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
isArchived: !isArchived,
|
||||
});
|
||||
};
|
||||
|
||||
const isArchiveActionPending = updateBoard.isPending;
|
||||
|
||||
const items = [
|
||||
...(isTemplate && canCreateBoard
|
||||
? [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canArchiveBoard
|
||||
? [
|
||||
{
|
||||
label: isArchived ? t`Unarchive board` : t`Archive board`,
|
||||
action: handleArchiveOrUnarchive,
|
||||
icon: (
|
||||
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
@@ -100,22 +129,26 @@ export default function BoardDropdown({
|
||||
},
|
||||
...(canDeleteBoard
|
||||
? [
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown disabled={isLoading} items={items}>
|
||||
<Dropdown
|
||||
disabled={isLoading || isArchiveActionPending}
|
||||
items={items}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -66,8 +66,8 @@ const Card = ({
|
||||
const hasDueDate = !!dueDate;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span>{title}</span>
|
||||
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span className="break-words">{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
checklists.length > 0 ||
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiLink } from "react-icons/hi";
|
||||
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
const displayBaseUrl =
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn"
|
||||
: env("NEXT_PUBLIC_BASE_URL");
|
||||
|
||||
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||
|
||||
const pathSeparator = (
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
|
||||
);
|
||||
|
||||
const UpdateBoardSlugButton = ({
|
||||
handleOnClick,
|
||||
workspaceSlug,
|
||||
boardSlug,
|
||||
boardPublicId,
|
||||
visibility,
|
||||
isLoading,
|
||||
canEdit,
|
||||
}: {
|
||||
handleOnClick: () => void;
|
||||
workspaceSlug: string;
|
||||
boardSlug: string;
|
||||
boardPublicId: string;
|
||||
visibility: "public" | "private";
|
||||
isLoading: boolean;
|
||||
canEdit: boolean;
|
||||
}) => {
|
||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -26,44 +42,55 @@ const UpdateBoardSlugButton = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !boardSlug || !boardPublicId) return <></>;
|
||||
|
||||
const isPublic = visibility === "public";
|
||||
const boardUrl = isPublic
|
||||
? `${linkBaseUrl}/${workspaceSlug}/${boardSlug}`
|
||||
: `${linkBaseUrl}/boards/${boardPublicId}`;
|
||||
|
||||
const pathSegments = isPublic
|
||||
? [displayBaseUrl, workspaceSlug, boardSlug]
|
||||
: [displayBaseUrl, "boards", boardPublicId];
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
||||
content={!canEdit ? t`You don't have permission` : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={canEdit ? handleOnClick : undefined}
|
||||
disabled={!canEdit || isLoading}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn"
|
||||
: env("NEXT_PUBLIC_BASE_URL")}
|
||||
</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{workspaceSlug}</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{boardSlug}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!canEdit) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</Link>
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
{pathSegments.map((segment, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && pathSeparator}
|
||||
<span>{segment}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(boardUrl).then(
|
||||
() =>
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
aria-label={t`Copy board link`}
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</button>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default UpdateBoardSlugButton;
|
||||
|
||||
@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="/settings?tab=workspace"
|
||||
href="/settings/workspace"
|
||||
onClick={closeModal}
|
||||
>
|
||||
{t`Edit workspace URL`}
|
||||
|
||||
@@ -58,13 +58,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } =
|
||||
usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
@@ -417,10 +418,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{...register("name")}
|
||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||
readOnly={!canEditBoard}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none disabled:cursor-not-allowed dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
/>
|
||||
</form>
|
||||
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
@@ -443,6 +443,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
boardPublicId={boardId ?? ""}
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
canEdit={canEditBoard}
|
||||
/>
|
||||
<VisibilityButton
|
||||
@@ -492,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
isArchived={boardData?.isArchived ?? false}
|
||||
isFavorite={boardData?.favorite}
|
||||
boardName={boardData?.name}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
@@ -26,6 +26,7 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
type: isTemplate ? "template" : "regular",
|
||||
archived: archived,
|
||||
},
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
@@ -59,10 +60,10 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
@@ -109,18 +110,18 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FaTrello } from "react-icons/fa";
|
||||
import { FaGithub, FaTrello } from "react-icons/fa";
|
||||
import {
|
||||
HiChevronUpDown,
|
||||
HiMiniArrowTopRightOnSquare,
|
||||
@@ -27,12 +27,23 @@ const integrationProviders: Record<
|
||||
name: "Trello",
|
||||
icon: <FaTrello />,
|
||||
},
|
||||
github: {
|
||||
name: "GitHub",
|
||||
icon: <FaGithub />,
|
||||
},
|
||||
};
|
||||
|
||||
const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
const SelectSource = ({
|
||||
handleNextStep,
|
||||
}: {
|
||||
handleNextStep: (provider: string) => void;
|
||||
}) => {
|
||||
const { data: integrations, refetch: refetchIntegrations } =
|
||||
api.integration.providers.useQuery();
|
||||
const { control, handleSubmit } = useForm({
|
||||
const { data: githubStatus, refetch: refetchGithubStatus } =
|
||||
api.integration.getGitHubStatus.useQuery();
|
||||
|
||||
const { control, handleSubmit, watch } = useForm({
|
||||
defaultValues: {
|
||||
source: integrations?.[0]?.provider ?? "trello",
|
||||
},
|
||||
@@ -47,23 +58,36 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
},
|
||||
);
|
||||
|
||||
const hasIntegrations = integrations && integrations.length > 0;
|
||||
const availableIntegrations = [
|
||||
...(integrations ?? []),
|
||||
...(githubStatus?.connected ? [{ provider: "github" }] : []),
|
||||
];
|
||||
|
||||
const hasIntegrations = availableIntegrations.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
refetchIntegrations();
|
||||
void refetchIntegrations();
|
||||
void refetchGithubStatus();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
}, [refetchIntegrations, refetchGithubStatus]);
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!hasIntegrations && trelloUrl) {
|
||||
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||
const selected = watch("source");
|
||||
if (
|
||||
selected === "trello" &&
|
||||
!integrations?.some((i) => i.provider === "trello")
|
||||
) {
|
||||
if (trelloUrl)
|
||||
window.open(trelloUrl.url, "trello_auth", "height=800,width=600");
|
||||
} else if (selected === "github" && !githubStatus?.connected) {
|
||||
window.open("/settings/integrations", "_blank");
|
||||
} else {
|
||||
handleNextStep();
|
||||
handleNextStep(selected);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,7 +126,7 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
>
|
||||
<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 ? (
|
||||
integrations.map((integration, index) => (
|
||||
availableIntegrations.map((integration, index) => (
|
||||
<Listbox.Option
|
||||
key={`source_${index}`}
|
||||
className="relative cursor-default select-none px-1"
|
||||
@@ -123,18 +147,32 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
</Listbox.Option>
|
||||
))
|
||||
) : (
|
||||
<Listbox.Option
|
||||
key="trello_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
<>
|
||||
<Listbox.Option
|
||||
key="trello_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="trello"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.trello?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.trello?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
<Listbox.Option
|
||||
key="github_placeholder"
|
||||
className="font-sm relative cursor-default select-none px-1"
|
||||
value="github"
|
||||
>
|
||||
<div className="flex items-center rounded-[5px] p-1 text-sm hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
{integrationProviders.github?.icon}
|
||||
<span className="ml-2 block truncate text-sm">
|
||||
{integrationProviders.github?.name}
|
||||
</span>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
@@ -154,7 +192,159 @@ const SelectSource = ({ handleNextStep }: { handleNextStep: () => void }) => {
|
||||
!hasIntegrations ? <HiMiniArrowTopRightOnSquare /> : undefined
|
||||
}
|
||||
>
|
||||
{hasIntegrations ? t`Select source` : t`Connect Trello`}
|
||||
{hasIntegrations ? t`Select source` : t`Connect`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportGithub: React.FC = () => {
|
||||
const utils = api.useUtils();
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const [isSelectAllEnabled, setIsSelectAllEnabled] = useState(false);
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const { data: projects, isLoading: projectsLoading } =
|
||||
api.import.github.getProjects.useQuery();
|
||||
|
||||
const {
|
||||
register: registerProjects,
|
||||
handleSubmit: handleSubmitProjects,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues: Object.fromEntries(
|
||||
projects?.map((project) => [project.id, true]) ?? [],
|
||||
),
|
||||
});
|
||||
|
||||
const importProjects = api.import.github.importProjects.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Import complete`,
|
||||
message: t`Your projects have been imported.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
await refetchBoards();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.log(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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +403,7 @@ const ImportTrello: React.FC = () => {
|
||||
value: watch(board.id),
|
||||
}));
|
||||
|
||||
const boardCount = boardWatchers?.filter((w) => w.value === true).length || 0;
|
||||
const boardCount = boardWatchers?.filter((w) => w.value === true).length ?? 0;
|
||||
|
||||
const onSubmitBoards = (values: Record<string, boolean>) => {
|
||||
const boardIds = Object.keys(values).filter((key) => values[key] === true);
|
||||
@@ -267,7 +457,7 @@ const ImportTrello: React.FC = () => {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-scroll px-5">{renderContent()}</div>
|
||||
<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
|
||||
@@ -277,7 +467,7 @@ const ImportTrello: React.FC = () => {
|
||||
const newState = !isSelectAllEnabled;
|
||||
setIsSelectAllEnabled(newState);
|
||||
|
||||
for (const board of boards || []) {
|
||||
for (const board of boards ?? []) {
|
||||
setValue(board.id, newState);
|
||||
}
|
||||
}}
|
||||
@@ -313,6 +503,7 @@ const ImportTrello: React.FC = () => {
|
||||
export function ImportBoardsForm() {
|
||||
const { closeModal } = useModal();
|
||||
const [step, setStep] = useState(1);
|
||||
const [provider, setProvider] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -339,8 +530,16 @@ export function ImportBoardsForm() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 1 && <SelectSource handleNextStep={() => setStep(step + 1)} />}
|
||||
{step === 2 && <ImportTrello />}
|
||||
{step === 1 && (
|
||||
<SelectSource
|
||||
handleNextStep={(p) => {
|
||||
setProvider(p);
|
||||
setStep(step + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && provider === "trello" && <ImportTrello />}
|
||||
{step === 2 && provider === "github" && <ImportGithub />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ export const getTemplates = (): Template[] => [
|
||||
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
||||
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-basic",
|
||||
name: t`Basic Roadmap`,
|
||||
lists: [t`Requested`, t`Planned`, t`In Progress`, t`Done`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-extended",
|
||||
name: t`Extended Roadmap`,
|
||||
lists: [t`Requested`, t`Under Review`, t`Planned`, t`In Progress`, t`Done`, t`Rejected`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "content-creation",
|
||||
name: t`Content Creation`,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -15,9 +22,15 @@ import { BoardsList } from "./components/BoardsList";
|
||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||
import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
const boardsTabs = [
|
||||
{ key: "boards" as const, label: t`Active` },
|
||||
{ key: "archived" as const, label: t`Archived` },
|
||||
];
|
||||
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
@@ -34,7 +47,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<PageHead
|
||||
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
|
||||
/>
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="m-auto h-full max-w-[1100px] p-8 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||
@@ -115,9 +128,79 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
{!isTemplate ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="focus:outline-none">
|
||||
<div className="sm:hidden">
|
||||
<Listbox
|
||||
value={activeTab}
|
||||
onChange={(tab) => setActiveTab(tab)}
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
|
||||
"Select a tab"}
|
||||
<HiChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
|
||||
{boardsTabs.map((tab) => (
|
||||
<ListboxOption
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={({ selected }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
|
||||
? "font-bold text-light-1000 dark:text-dark-1000"
|
||||
: "font-normal text-light-1000 dark:text-dark-1000"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div>
|
||||
<nav
|
||||
aria-label="Tabs"
|
||||
className="-mb-px flex space-x-8 focus:outline-none"
|
||||
>
|
||||
{boardsTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key
|
||||
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
|
||||
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full flex-row focus:outline-none">
|
||||
{activeTab === "boards" && (
|
||||
<BoardsList isTemplate={false} archived={false} />
|
||||
)}
|
||||
{activeTab === "archived" && (
|
||||
<BoardsList isTemplate={false} archived={true} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineClock,
|
||||
HiOutlinePaperClip,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
@@ -31,29 +32,50 @@ import Comment from "./Comment";
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
type ActivityWithMergedLabels =
|
||||
GetCardActivitiesOutput["activities"][number] & {
|
||||
mergedLabels?: string[];
|
||||
attachment?: {
|
||||
publicId: string;
|
||||
filename: string;
|
||||
originalFilename: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
};
|
||||
|
||||
const getUserDisplayName = (
|
||||
user: { name?: string | null; email?: string | null } | null | undefined,
|
||||
): string => {
|
||||
if (user?.name?.trim()) return user.name;
|
||||
if (user?.email) return user.email;
|
||||
return t`Member`;
|
||||
};
|
||||
|
||||
const getActivityText = ({
|
||||
type,
|
||||
toTitle,
|
||||
fromList,
|
||||
toList,
|
||||
memberName,
|
||||
memberEmail,
|
||||
isSelf,
|
||||
label,
|
||||
fromTitle,
|
||||
toDueDate,
|
||||
dateLocale,
|
||||
mergedLabels,
|
||||
attachmentName,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
fromList: string | null;
|
||||
toList: string | null;
|
||||
memberName: string | null;
|
||||
memberEmail: string | null;
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
fromTitle?: string | null;
|
||||
@@ -61,7 +83,9 @@ const getActivityText = ({
|
||||
toDueDate?: Date | null;
|
||||
dateLocale: DateFnsLocale;
|
||||
mergedLabels?: string[];
|
||||
attachmentName?: string | null;
|
||||
}) => {
|
||||
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
||||
{children}
|
||||
@@ -113,6 +137,8 @@ const getActivityText = ({
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
"card.updated.attachment.added": t`added an attachment`,
|
||||
"card.updated.attachment.removed": t`removed an attachment`,
|
||||
"card.updated.dueDate.added": t`set the due date`,
|
||||
"card.updated.dueDate.updated": t`updated the due date`,
|
||||
"card.updated.dueDate.removed": t`removed the due date`,
|
||||
@@ -139,23 +165,23 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.added" && memberName) {
|
||||
if (type === "card.updated.member.added" && displayName) {
|
||||
if (isSelf) return <Trans>self-assigned the card</Trans>;
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
|
||||
assigned <TextHighlight>{truncate(displayName)}</TextHighlight> to the
|
||||
card
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.removed" && memberName) {
|
||||
if (type === "card.updated.member.removed" && displayName) {
|
||||
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
|
||||
unassigned <TextHighlight>{truncate(displayName)}</TextHighlight> from
|
||||
the card
|
||||
</Trans>
|
||||
);
|
||||
@@ -245,6 +271,27 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.added") {
|
||||
const filename = attachmentName ?? toTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
added an attachment <TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.removed") {
|
||||
const filename = attachmentName ?? fromTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
removed an attachment{" "}
|
||||
<TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
@@ -297,6 +344,8 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
"card.updated.attachment.added": <HiOutlinePaperClip />,
|
||||
"card.updated.attachment.removed": <HiOutlinePaperClip />,
|
||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||
@@ -330,7 +379,7 @@ const ActivityList = ({
|
||||
isAdmin?: boolean;
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { dateLocale, locale } = useLocalisation();
|
||||
const { dateLocale } = useLocalisation();
|
||||
const { data: sessionData } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const [allActivities, setAllActivities] = useState<
|
||||
@@ -352,7 +401,7 @@ const ActivityList = ({
|
||||
limit: ACTIVITIES_PAGE_SIZE,
|
||||
},
|
||||
{
|
||||
enabled: !!cardPublicId,
|
||||
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -380,25 +429,21 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
}
|
||||
|
||||
setAllActivities(currentActivities);
|
||||
setHasMore(false);
|
||||
};
|
||||
|
||||
fetchAllRemaining();
|
||||
void fetchAllRemaining();
|
||||
} else {
|
||||
setAllActivities(firstPageData.activities);
|
||||
setHasMore(firstPageData.hasMore);
|
||||
@@ -425,17 +470,15 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
@@ -455,13 +498,17 @@ const ActivityList = ({
|
||||
fromList: activity.fromList?.name ?? null,
|
||||
toList: activity.toList?.name ?? null,
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
memberEmail: activity.member?.user?.email ?? null,
|
||||
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
fromTitle: activity.fromTitle ?? null,
|
||||
fromDueDate: activity.fromDueDate ?? null,
|
||||
toDueDate: activity.toDueDate ?? null,
|
||||
dateLocale: dateLocale,
|
||||
mergedLabels: (activity as any).mergedLabels,
|
||||
mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels,
|
||||
attachmentName:
|
||||
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
|
||||
null,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
@@ -508,7 +555,7 @@ const ActivityList = ({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
|
||||
<span className="font-medium dark:text-dark-1000">{`${getUserDisplayName(activity.user)} `}</span>
|
||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||
{activityText}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { env } from "next-runtime-env";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
||||
onSuccess: async () => {
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"x-original-filename": file.name,
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
showPopup({
|
||||
header: t`Attachment uploaded`,
|
||||
message: t`Your file has been uploaded successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
message: t`Failed to upload attachment. Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// Generate presigned URL
|
||||
const { url, key } = await generateUploadUrl.mutateAsync({
|
||||
cardPublicId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// Upload file to S3
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
// Confirm attachment in database
|
||||
await confirmAttachment.mutateAsync({
|
||||
cardPublicId,
|
||||
s3Key: key,
|
||||
filename: file.name,
|
||||
originalFilename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
} catch {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Editor from "~/components/Editor";
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -29,7 +30,6 @@ const Comment = ({
|
||||
createdAt,
|
||||
comment,
|
||||
isAuthor,
|
||||
isAdmin,
|
||||
isEdited = false,
|
||||
isViewOnly = false,
|
||||
}: {
|
||||
@@ -42,7 +42,6 @@ const Comment = ({
|
||||
createdAt: string;
|
||||
comment: string | undefined;
|
||||
isAuthor: boolean;
|
||||
isAdmin: boolean;
|
||||
isEdited: boolean;
|
||||
isViewOnly: boolean;
|
||||
}) => {
|
||||
@@ -57,6 +56,30 @@ const Comment = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: cardData } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId,
|
||||
},
|
||||
{
|
||||
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
const workspaceMembers: WorkspaceMember[] =
|
||||
cardData?.list.board.workspace.members
|
||||
.filter((member) => member.email)
|
||||
.map((member) => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user
|
||||
? {
|
||||
id: member.user.id,
|
||||
name: member.user.name ?? null,
|
||||
image: member.user.image ?? null,
|
||||
}
|
||||
: null,
|
||||
})) ?? [];
|
||||
|
||||
if (!publicId) return null;
|
||||
|
||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||
@@ -142,21 +165,28 @@ const Comment = ({
|
||||
)}
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<ContentEditable
|
||||
html={comment ?? ""}
|
||||
disabled={true}
|
||||
className="break-anywhere mt-2 text-sm"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={comment ?? null}
|
||||
readOnly={true}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={watch("comment")}
|
||||
onChange={(value) => setValue("comment", value)}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
@@ -10,18 +11,54 @@ import { authClient } from "@kan/auth/client";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
export default function CardDropdown({
|
||||
cardPublicId,
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
cardCreatedBy,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
isTemplate?: boolean;
|
||||
boardPublicId?: string;
|
||||
cardCreatedBy?: string | null;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
const path =
|
||||
isTemplate && boardPublicId
|
||||
? `/templates/${boardPublicId}/cards/${cardPublicId}`
|
||||
: `/cards/${cardPublicId}`;
|
||||
const url = `${window.location.origin}${path}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Card URL copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: t`Copy card link`,
|
||||
action: handleCopyCardLink,
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
|
||||
import Editor from "~/components/Editor";
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -13,7 +14,13 @@ interface FormValues {
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const NewCommentForm = ({
|
||||
cardPublicId,
|
||||
workspaceMembers,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { canCreateComment } = usePermissions();
|
||||
@@ -23,10 +30,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
},
|
||||
});
|
||||
|
||||
const queryParams = {
|
||||
cardPublicId,
|
||||
};
|
||||
|
||||
const addCommentMutation = api.card.addComment.useMutation({
|
||||
onError: (_error, _newList) => {
|
||||
showPopup({
|
||||
@@ -57,18 +60,13 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
<Editor
|
||||
content={watch("comment")}
|
||||
onChange={(value) => setValue("comment", value)}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -3,8 +3,11 @@ import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -14,8 +17,6 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -53,9 +54,10 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card } = api.card.byId.useQuery({
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
const { data: card } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
@@ -166,7 +168,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const {
|
||||
modalContentType,
|
||||
entityId,
|
||||
openModal,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
isOpen,
|
||||
@@ -184,9 +185,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery({
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
@@ -196,8 +198,24 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
};
|
||||
|
||||
const board = card?.list.board;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
const boardId = board?.publicId;
|
||||
|
||||
const editorWorkspaceMembers =
|
||||
workspaceMembers
|
||||
?.filter((member) => member.email)
|
||||
.map((member) => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user
|
||||
? {
|
||||
id: member.user.id,
|
||||
name: member.user.name ?? null,
|
||||
image: member.user.image ?? null,
|
||||
}
|
||||
: null,
|
||||
})) ?? [];
|
||||
|
||||
const updateCard = api.card.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -315,7 +333,19 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown cardCreatedBy={card?.createdBy} />
|
||||
<Dropdown
|
||||
cardPublicId={cardId}
|
||||
isTemplate={isTemplate}
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
<HiXMark className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -372,9 +402,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={canEdit ? (e) => setValue("description", e) : undefined}
|
||||
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
onChange={
|
||||
canEdit
|
||||
? (e) => setValue("description", e)
|
||||
: undefined
|
||||
}
|
||||
onBlur={
|
||||
canEdit ? () => handleSubmit(onSubmit)() : undefined
|
||||
}
|
||||
workspaceMembers={workspaceMembers ?? []}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -418,7 +454,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
<NewCommentForm
|
||||
cardPublicId={cardId}
|
||||
workspaceMembers={editorWorkspaceMembers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function MembersPage() {
|
||||
|
||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
// { enabled: workspace?.publicId ? true : false },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
@@ -48,9 +48,11 @@ export default function MembersPage() {
|
||||
|
||||
const updateRoleMutation = api.member.updateRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
if (workspace.publicId && workspace.publicId.length >= 12) {
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
}
|
||||
|
||||
showPopup({
|
||||
header: t`Role updated`,
|
||||
@@ -124,7 +126,6 @@ export default function MembersPage() {
|
||||
name={memberName ?? ""}
|
||||
email={memberEmail ?? ""}
|
||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||
icon={showPendingIcon ? "?" : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -212,7 +213,7 @@ export default function MembersPage() {
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative z-50",
|
||||
"relative",
|
||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,293 +1,539 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
|
||||
|
||||
interface Feature {
|
||||
key: string;
|
||||
label: string;
|
||||
kan: boolean | string;
|
||||
trello: boolean | string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
name: string;
|
||||
features: Feature[];
|
||||
}
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type FrequencyValue = "monthly" | "annually";
|
||||
|
||||
const CellValue = ({ value }: { value: boolean | string }) => {
|
||||
if (typeof value === "string") {
|
||||
return (
|
||||
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span>
|
||||
);
|
||||
}
|
||||
return value ? (
|
||||
<HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" />
|
||||
);
|
||||
};
|
||||
interface PlanFeature {
|
||||
key: string;
|
||||
label: string;
|
||||
free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
teams: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
pro: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
}
|
||||
|
||||
interface FeatureSection {
|
||||
name: string;
|
||||
features: PlanFeature[];
|
||||
}
|
||||
|
||||
const FeatureComparisonTable = ({
|
||||
frequencyValue,
|
||||
frequencyValue: _frequencyValue,
|
||||
}: {
|
||||
frequencyValue: FrequencyValue;
|
||||
}) => {
|
||||
const sections: Section[] = [
|
||||
// Keep pricing column headers visible as you scroll
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderFixed, setIsHeaderFixed] = useState(false);
|
||||
const [headerRect, setHeaderRect] = useState<{ left: number; width: number }>({
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const HEADER_OFFSET = 64; // px; matches fixed top nav height
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Hide fixed header on mobile (screens smaller than 620px)
|
||||
if (window.innerWidth < 620) {
|
||||
setIsHeaderFixed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const pastHeader = rect.top <= HEADER_OFFSET;
|
||||
const aboveBottom = rect.bottom > HEADER_OFFSET + 40;
|
||||
|
||||
const shouldFix = pastHeader && aboveBottom;
|
||||
setIsHeaderFixed(shouldFix);
|
||||
|
||||
if (shouldFix) {
|
||||
setHeaderRect({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
const planSpecificLimits: PlanFeature[] = [
|
||||
{
|
||||
name: t`Core features`,
|
||||
features: [
|
||||
{
|
||||
key: "ulimited-workspaces",
|
||||
label: t`Unlimited workspaces`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-boards",
|
||||
label: t`Unlimited boards`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-lists",
|
||||
label: t`Unlimited lists`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "unlimited-cards",
|
||||
label: t`Unlimited cards`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: t`Custom templates`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
|
||||
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
|
||||
{
|
||||
key: "import",
|
||||
label: t`Import from Trello`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "visibility",
|
||||
label: t`Board visibility`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "workspace-url",
|
||||
label: t`Custom workspace link`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
],
|
||||
key: "boards",
|
||||
label: t`Boards`,
|
||||
free: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
teams: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
pro: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Unlimited boards`, highlightFirstWord: true },
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
features: [
|
||||
{
|
||||
key: "pricing",
|
||||
label: t`Price per user/month`,
|
||||
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`,
|
||||
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments & mentions`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "assignments",
|
||||
label: t`Assignees`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "sharing",
|
||||
label: t`Board sharing & invites`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
key: "members",
|
||||
label: t`Members`,
|
||||
free: { text: t`1 user`, highlightFirstWord: true },
|
||||
teams: { text: t`Per-seat pricing`, highlightFirstWord: true },
|
||||
pro: { text: t`Unlimited members`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Unlimited members`, highlightFirstWord: true },
|
||||
},
|
||||
{
|
||||
key: "file-uploads",
|
||||
label: t`File uploads`,
|
||||
free: { text: t`10mb file uploads`, highlightFirstWord: true },
|
||||
teams: { text: t`Unlimited file uploads`, highlightFirstWord: true },
|
||||
pro: { text: t`Unlimited file uploads`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true },
|
||||
},
|
||||
{
|
||||
key: "workspace-username",
|
||||
label: t`Workspace username`,
|
||||
free: { text: t`Default username`, highlightFirstWord: true },
|
||||
teams: { text: t`Default username`, highlightFirstWord: true },
|
||||
pro: { text: t`Custom username`, highlightFirstWord: true },
|
||||
enterprise: { text: t`Custom username`, highlightFirstWord: true },
|
||||
},
|
||||
];
|
||||
|
||||
const coreFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "checklists",
|
||||
label: t`Checklists`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-templates",
|
||||
label: t`Board templates`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "labels-filters",
|
||||
label: t`Labels & filters`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "board-visibility",
|
||||
label: t`Board visibility`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "roles-permissions",
|
||||
label: t`Roles & permissions`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-branding",
|
||||
label: t`Custom branding`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const collaborationFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "mentions",
|
||||
label: t`Mentions`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "email-notifications",
|
||||
label: t`Email notifications`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "assignees",
|
||||
label: t`Assignees`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const importsFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "import-export",
|
||||
label: t`Trello`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const platformFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "rest-api",
|
||||
label: t`API`,
|
||||
free: { text: t`Limited API access` },
|
||||
teams: { text: t`Full API access`, highlight: true },
|
||||
pro: { text: t`Full API access`, highlight: true },
|
||||
enterprise: { text: t`Full API access`, highlight: true },
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`On-premise`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const securityFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "sso",
|
||||
label: t`SSO`,
|
||||
free: { text: t`Google SSO`, highlight: true },
|
||||
teams: { text: t`Google SSO`, highlight: true },
|
||||
pro: { text: t`Google SSO`, highlight: true },
|
||||
enterprise: { text: t`Google SSO + SAML`, highlight: true },
|
||||
},
|
||||
{
|
||||
key: "admin-roles",
|
||||
label: t`Admin roles`,
|
||||
free: { text: t`Admin roles`, highlight: true },
|
||||
teams: { text: t`Admin roles`, highlight: true },
|
||||
pro: { text: t`Admin roles`, highlight: true },
|
||||
enterprise: { text: t`Advanced admin roles`, highlight: true },
|
||||
},
|
||||
];
|
||||
|
||||
const supportFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "priority-email-support",
|
||||
label: t`Priority email support`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "account-manager",
|
||||
label: t`Account manager`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "sla",
|
||||
label: t`SLA`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const sections: FeatureSection[] = [
|
||||
{
|
||||
name: "",
|
||||
features: planSpecificLimits,
|
||||
},
|
||||
{
|
||||
name: t`Core features`,
|
||||
features: coreFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Collaboration`,
|
||||
features: collaborationFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Imports`,
|
||||
features: importsFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Platform`,
|
||||
features: [
|
||||
{
|
||||
key: "api",
|
||||
label: t`REST API`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`Self-hostable`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "white-label",
|
||||
label: t`White label`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
label: t`Fast & lightweight`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "this-is-a-joke",
|
||||
label: t`Owned by Atlassian`,
|
||||
kan: false,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
features: platformFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Security`,
|
||||
features: securityFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Support`,
|
||||
features: supportFeatures,
|
||||
},
|
||||
];
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: "kan",
|
||||
name: t`Kan`,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "trello",
|
||||
name: t`Trello`,
|
||||
featured: false,
|
||||
},
|
||||
const plans = [
|
||||
{ id: "free", name: t`Free`, tierId: "tier-free" },
|
||||
{ id: "teams", name: t`Teams`, tierId: "tier-teams" },
|
||||
{ id: "pro", name: t`Pro`, tierId: "tier-pro" },
|
||||
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
|
||||
];
|
||||
|
||||
const CellValue = ({
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
|
||||
label: string;
|
||||
}) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const { text, highlight, highlightFirstWord } = value;
|
||||
|
||||
if (highlightFirstWord) {
|
||||
const spaceIndex = text.indexOf(" ");
|
||||
const firstWord = spaceIndex === -1 ? text : text.slice(0, spaceIndex);
|
||||
const rest = spaceIndex === -1 ? "" : text.slice(spaceIndex);
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
|
||||
<span className="text-sm font-medium">
|
||||
<span className="text-light-1000 dark:text-dark-1000">{firstWord}</span>
|
||||
<span className="text-light-950 dark:text-dark-800">{rest}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle
|
||||
className={twMerge(
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
highlight
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-400 dark:text-dark-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
highlight
|
||||
? "text-light-1000 dark:text-dark-950"
|
||||
: "text-light-950 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
|
||||
<span className="text-sm font-medium text-light-1000 dark:text-dark-950">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
|
||||
<span className="text-sm font-medium text-light-800 dark:text-dark-800">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFeatureValue = (
|
||||
feature: PlanFeature,
|
||||
planId: string,
|
||||
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
return feature.free;
|
||||
case "teams":
|
||||
return feature.teams;
|
||||
case "pro":
|
||||
return feature.pro;
|
||||
case "enterprise":
|
||||
return feature.enterprise;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="comparison-heading" className="w-full px-4">
|
||||
<div className="mx-auto max-w-6xl py-8 lg:py-10">
|
||||
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} aria-hidden="true" className="-mt-px">
|
||||
<div
|
||||
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`}
|
||||
>
|
||||
<p
|
||||
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<section aria-labelledby="comparison-heading" className="w-full">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
|
||||
>
|
||||
{isHeaderFixed && (
|
||||
<div
|
||||
className="hidden sm:block fixed z-40 border-b border-light-300 bg-light-50/95 dark:border-dark-400 dark:bg-dark-50/95"
|
||||
style={{
|
||||
top: 64,
|
||||
left: headerRect.left,
|
||||
width: headerRect.width,
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-4 border-x border-light-300 dark:border-dark-300">
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<div
|
||||
key={`fixed-${plan.id}`}
|
||||
className={twMerge(
|
||||
"flex items-center px-6 py-3 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="-mt-6 space-y-12">
|
||||
{sections.map((section) => (
|
||||
<div key={section.name}>
|
||||
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{section.name}
|
||||
</h4>
|
||||
<div className="relative -mx-8 mt-8">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table className="relative w-full border-separate border-spacing-x-8">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th scope="col">
|
||||
<span className="sr-only">{t`Feature`}</span>
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<th key={p.id} scope="col">
|
||||
<span className="sr-only">{p.name}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.features.map((feature, featureIdx) => (
|
||||
<tr key={feature.key}>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
|
||||
>
|
||||
{feature.label}
|
||||
{featureIdx !== section.features.length - 1 ? (
|
||||
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
|
||||
) : null}
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
)}
|
||||
<table className="w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<th
|
||||
key={plan.id}
|
||||
scope="col"
|
||||
className={twMerge(
|
||||
"w-1/4 px-6 py-4 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<React.Fragment key={section.name || `section-${sectionIdx}`}>
|
||||
{section.name && (
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={p.id}
|
||||
className="relative w-1/3 px-4 py-0 text-center"
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"border-t px-6 py-3 text-left text-sm font-semibold text-dark-900 dark:text-dark-900",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-400 dark:bg-dark-50",
|
||||
)}
|
||||
>
|
||||
<span className="relative inline-flex w-full items-center justify-center py-3">
|
||||
<CellValue
|
||||
value={
|
||||
p.id === "kan" ? feature.kan : feature.trello
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{plan.id === "free" ? section.name : ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
{section.features.map((feature) => (
|
||||
<tr key={feature.key}>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"w-1/4 border-t px-6 py-3 text-left",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 dark:border-dark-400",
|
||||
)}
|
||||
>
|
||||
<CellValue
|
||||
value={getFeatureValue(feature, plan.id)}
|
||||
label={feature.label}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,81 +23,115 @@ const Pricing = ({
|
||||
}) => {
|
||||
const tiers = [
|
||||
{
|
||||
name: t`Individuals`,
|
||||
id: "tier-individuals",
|
||||
name: t`Free`,
|
||||
id: "tier-free",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: t`Free`, annually: t`Free` },
|
||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
||||
featureHeader: t`Free, forever`,
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: t`$0`, annually: t`$0` },
|
||||
description: t`Free for everyone`,
|
||||
bestFor: t`Best for individuals and solo creators getting started`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
t`1 user`,
|
||||
t`Unlimited boards`,
|
||||
t`Unlimited cards`,
|
||||
t`Custom board templates`,
|
||||
t`Checklists`,
|
||||
{ text: t`1 user` },
|
||||
{ text: t`Unlimited boards` },
|
||||
{ text: t`Unlimited cards` },
|
||||
{ text: t`Custom board templates` },
|
||||
{ text: t`Checklists` },
|
||||
],
|
||||
showPrice: true,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
||||
featureHeader: t`Everything in the free plan, plus:`,
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$10", annually: "$8" },
|
||||
description: t`Perfect for small and growing teams looking to collaborate.`,
|
||||
bestFor: t`Best for small and growing teams that need collaboration`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
t`Workspace members`,
|
||||
t`Admin roles`,
|
||||
t`Priority email support`,
|
||||
t`Support the development of the project`,
|
||||
{ text: t`All Free features +` },
|
||||
{ text: t`Workspace members` },
|
||||
{ text: t`Invite links` },
|
||||
{ text: t`Unlimited file uploads` },
|
||||
{ text: t`Email notifications for mentions` },
|
||||
{ text: t`Priority email support` },
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: false,
|
||||
},
|
||||
{
|
||||
name: t`Pro`,
|
||||
id: "tier-pro",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$29", annually: "$24" },
|
||||
description: t`Unlimited seats and advanced features for growing teams.`,
|
||||
bestFor: t`Best for teams that want to scale without limits`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
{ text: t`All Teams features +` },
|
||||
{ text: t`Unlimited members` },
|
||||
{ text: t`Custom workspace username` },
|
||||
{ text: t`Configurable user roles and permissions` },
|
||||
{ text: t`Custom branding` },
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: true,
|
||||
},
|
||||
{
|
||||
name: t`Self Host`,
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: t`View docs`,
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
||||
featureHeader: t`Complete control and ownership:`,
|
||||
name: t`Enterprise`,
|
||||
id: "tier-enterprise",
|
||||
href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
|
||||
buttonText: t`Contact Sales`,
|
||||
price: { monthly: t`Contact us`, annually: t`Contact us` },
|
||||
description: t`Advanced security, compliance, and dedicated support for large organizations.`,
|
||||
bestFor: t`Best for large organizations with advanced needs`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
t`Run on your own infrastructure`,
|
||||
t`Own your data`,
|
||||
t`Custom domain`,
|
||||
{ text: t`All Pro features +` },
|
||||
{ text: t`SAML SSO` },
|
||||
{ text: t`Advanced admin controls` },
|
||||
{ text: t`Dedicated account manager` },
|
||||
{ text: t`Custom SLA` },
|
||||
{ text: t`On-premise deployment option` },
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="mt-14 flex flex-col items-center justify-center">
|
||||
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="mb-8 flex items-center justify-center">
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
onChange={(value) => setFrequency(value)}
|
||||
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
|
||||
className="flex gap-1 rounded-lg border border-light-300 bg-light-50 p-1 dark:border-dark-300 dark:bg-dark-50"
|
||||
>
|
||||
{frequencies.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option}
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm",
|
||||
"cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
frequency?.value === option.value
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
|
||||
? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "text-light-600 dark:text-dark-700",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -106,103 +140,129 @@ const Pricing = ({
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="isolate mx-auto mb-10 grid max-w-md grid-cols-1 gap-8 px-4 lg:mx-0 lg:max-w-none lg:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800"
|
||||
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
|
||||
"rounded-3xl p-8 xl:p-10",
|
||||
"relative flex h-full flex-col rounded-lg border p-6",
|
||||
tier.mostPopular
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<h3
|
||||
id={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "text-dark-1000 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
"text-lg/8 font-semibold",
|
||||
{tier.id === "tier-pro" && (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-light-400 bg-light-100 px-3 py-1 text-center text-xs text-dark-600 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||
<HiBolt className="h-3 w-3 -ml-0.5" />
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
id={tier.id}
|
||||
className={twMerge(
|
||||
"mb-4 flex items-center text-base font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
{tier.id === "tier-pro" && <span className="ml-1 text-xl leading-none">∞</span>}
|
||||
</h3>
|
||||
<div className="mb-4">
|
||||
<p className="flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.id === "tier-pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold line-through",
|
||||
tier.mostPopular
|
||||
? "text-light-600 dark:text-dark-600"
|
||||
: "text-light-600 dark:text-dark-600",
|
||||
)}
|
||||
>
|
||||
{frequency?.value === "annually" ? "$79" : "$100"}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
{tier.highlighted && (
|
||||
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
|
||||
{t`14 day free trial`}
|
||||
</p>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
{tier.id === "tier-pro" && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{t`/month`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{tier.bestFor && (
|
||||
<p className="mb-6 text-sm text-dark-400 dark:text-dark-700">
|
||||
{tier.bestFor}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
{tier.id === "tier-enterprise" ? (
|
||||
<a
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className="block w-full rounded-md border border-dark-200 bg-transparent px-4 py-2 text-center text-sm font-medium text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200"
|
||||
>
|
||||
{tier.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
"block w-full rounded-md px-4 py-2 text-center text-sm font-medium transition-colors",
|
||||
tier.mostPopular
|
||||
? "bg-dark-50 text-light-50 shadow-sm hover:bg-dark-100 dark:bg-dark-1000 dark:text-dark-50 dark:hover:bg-dark-900"
|
||||
: "border border-dark-200 bg-transparent text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900",
|
||||
tier.highlighted ? "text-light-100" : "text-dark-50",
|
||||
)}
|
||||
>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className="mt-6 flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-3xl font-semibold tracking-tight text-light-100",
|
||||
tier.highlighted
|
||||
? "text-light-50 dark:text-dark-1000"
|
||||
: "text-gray-900 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
|
||||
: "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
|
||||
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-8 text-sm/6 font-bold",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.featureHeader}
|
||||
</p>
|
||||
<ul
|
||||
role="list"
|
||||
className={twMerge(
|
||||
"mt-2 space-y-3 text-sm/6 text-light-600",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-x-3">
|
||||
<HiCheckCircle className="h-5 w-5" />
|
||||
{feature}
|
||||
<ul role="list" className="space-y-2.5">
|
||||
{tier.features.map((feature, index) => (
|
||||
<li
|
||||
key={`${tier.id}-feature-${index}`}
|
||||
className="flex items-start gap-x-2.5"
|
||||
>
|
||||
<HiCheckCircle
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
|
||||
/>
|
||||
<span className="text-sm text-dark-600 dark:text-dark-800">
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto mt-8 max-w-3xl px-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<p>{t`14 day free trial · Switch plans or cancel anytime`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function PricingView() {
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
<p className="text:md lg:text-md mt-6 max-w-[300px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
|
||||
</p>
|
||||
</div>
|
||||
<PricingTiers
|
||||
@@ -59,23 +59,9 @@ export default function PricingView() {
|
||||
</div>
|
||||
|
||||
<div className="pb-22 flex flex-col items-center justify-center px-4">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Features`}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Feature breakdown`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Compare our features to see why Kan is the best choice.`}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button variant="primary" href="/signup">
|
||||
{t`Get started`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full max-w-[900px]">
|
||||
<div className="mt-10 w-full">
|
||||
<FeatureComparisonTable
|
||||
frequencyValue={frequency?.value ?? "annually"}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { HiLink, HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Badge from "~/components/Badge";
|
||||
import Editor from "~/components/Editor";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import ActivityList from "~/views/card/components/ActivityList";
|
||||
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
|
||||
@@ -23,16 +24,35 @@ export function CardModal({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal, isOpen } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Card URL copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId: cardPublicId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!cardPublicId,
|
||||
enabled: isOpen && !!cardPublicId && cardPublicId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -65,33 +85,44 @@ export function CardModal({
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCardLink}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
|
||||
aria-label="Copy card link"
|
||||
>
|
||||
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
|
||||
setTimeout(() => {
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
workspaceSlug,
|
||||
boardSlug: [boardSlug],
|
||||
setTimeout(() => {
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
boardSlug: [boardSlug ?? ""],
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
<HiXMark
|
||||
size={18}
|
||||
className="dark:text-dark-9000 text-light-900"
|
||||
/>
|
||||
</button>
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
<HiXMark
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function PublicBoardView() {
|
||||
const { showPopup } = usePopup();
|
||||
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
@@ -70,30 +70,35 @@ export default function PublicBoardView() {
|
||||
},
|
||||
);
|
||||
|
||||
const CopyBoardLink = () => {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
});
|
||||
}}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
aria-label={`Copy board URL`}
|
||||
>
|
||||
<HiLink className={`h-4 w-4 text-light-900 dark:text-dark-900`} />
|
||||
</button>
|
||||
);
|
||||
const handleCopyBoardLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const CopyBoardLink = () => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyBoardLink}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
|
||||
aria-label="Copy board URL"
|
||||
>
|
||||
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const pathWithoutQuery = router.asPath.split("?")[0];
|
||||
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Input from "~/components/Input";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
@@ -11,10 +15,28 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const githubTokenSchema = z.object({
|
||||
token: z.string().min(1, { message: t`Token is required` }),
|
||||
});
|
||||
|
||||
type GitHubTokenFormValues = z.infer<typeof githubTokenSchema>;
|
||||
|
||||
export default function IntegrationsSettings() {
|
||||
const { modalContentType, isOpen } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, errors },
|
||||
reset,
|
||||
} = useForm<GitHubTokenFormValues>({
|
||||
resolver: zodResolver(githubTokenSchema),
|
||||
defaultValues: {
|
||||
token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: integrations,
|
||||
refetch: refetchIntegrations,
|
||||
@@ -34,21 +56,25 @@ export default function IntegrationsSettings() {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: githubStatus, refetch: refetchGithubStatus } =
|
||||
api.integration.getGitHubStatus.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
refetchIntegrations();
|
||||
void refetchIntegrations();
|
||||
void refetchGithubStatus();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
}, [refetchIntegrations, refetchGithubStatus]);
|
||||
|
||||
const { mutateAsync: disconnectTrello } =
|
||||
api.integration.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchIntegrations();
|
||||
refetchTrelloUrl();
|
||||
void refetchIntegrations();
|
||||
void refetchTrelloUrl();
|
||||
showPopup({
|
||||
header: t`Trello disconnected`,
|
||||
message: t`Your Trello account has been disconnected.`,
|
||||
@@ -64,6 +90,49 @@ export default function IntegrationsSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: saveGithubToken, isPending: isSavingGithubToken } =
|
||||
api.integration.saveGitHubToken.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchGithubStatus();
|
||||
reset();
|
||||
showPopup({
|
||||
header: t`GitHub connected`,
|
||||
message: t`Your GitHub account has been connected.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error connecting GitHub`,
|
||||
message: t`An error occurred while connecting your GitHub account.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmitGithubToken = (data: GitHubTokenFormValues) => {
|
||||
void saveGithubToken({ token: data.token });
|
||||
};
|
||||
|
||||
const { mutateAsync: disconnectGithub } =
|
||||
api.integration.disconnectGitHub.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchGithubStatus();
|
||||
showPopup({
|
||||
header: t`GitHub disconnected`,
|
||||
message: t`Your GitHub account has been disconnected.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error disconnecting GitHub`,
|
||||
message: t`An error occurred while disconnecting your GitHub account.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Integrations`} />
|
||||
@@ -112,6 +181,51 @@ export default function IntegrationsSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`GitHub`}
|
||||
</h2>
|
||||
{!githubStatus?.connected ? (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Connect your GitHub account to import projects.`}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmitGithubToken)}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Personal Access Token"
|
||||
{...register("token")}
|
||||
errorMessage={errors.token?.message}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={!isDirty || isSavingGithubToken}
|
||||
isLoading={isSavingGithubToken}
|
||||
>
|
||||
{t`Connect GitHub`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Your GitHub account is connected.`}
|
||||
</p>
|
||||
<Button variant="secondary" onClick={() => disconnectGithub()}>
|
||||
{t`Disconnect GitHub`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
|
||||
@@ -28,9 +28,11 @@ export default function PermissionsSettings() {
|
||||
});
|
||||
|
||||
// Refresh any relevant workspace data
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
if (workspace.publicId && workspace.publicId.length >= 12) {
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
|
||||
@@ -31,9 +31,10 @@ export default function WorkspaceSettings() {
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
|
||||
@@ -58,28 +58,6 @@ export default function Avatar({
|
||||
const [crop, setCrop] = useState<PercentCrop>();
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const updateUser = api.user.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Profile image updated`,
|
||||
message: t`Your profile image has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
try {
|
||||
await utils.user.getUser.refetch();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error updating profile image`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
|
||||
|
||||
@@ -187,29 +165,32 @@ export default function Avatar({
|
||||
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
|
||||
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
|
||||
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||
const response = await fetch(
|
||||
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
|
||||
`${baseUrl}/api/upload/avatar`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": blob.type,
|
||||
"x-original-filename": fileName,
|
||||
},
|
||||
body: JSON.stringify({ filename: fileName, contentType: blob.type }),
|
||||
body: blob,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to get pre-signed URL");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload profile image");
|
||||
}
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
// User image is updated in the backend, refresh user data
|
||||
await utils.user.getUser.refetch();
|
||||
|
||||
showPopup({
|
||||
header: t`Profile image updated`,
|
||||
message: t`Your profile image has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
|
||||
|
||||
updateUser.mutate({ image: fileName });
|
||||
|
||||
setCropDialogOpen(false);
|
||||
resetCropState();
|
||||
} catch (error) {
|
||||
@@ -227,7 +208,7 @@ export default function Avatar({
|
||||
resetCropState,
|
||||
selectedFile,
|
||||
showPopup,
|
||||
updateUser,
|
||||
utils.user.getUser,
|
||||
userId,
|
||||
]);
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
||||
|
||||
const updateWorkspace = api.workspace.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
if (workspacePublicId && workspacePublicId.length >= 12) {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
services:
|
||||
migrator:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: migrate
|
||||
networks:
|
||||
- dokploy-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-web}
|
||||
@@ -9,6 +22,7 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: web
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-}
|
||||
env_file:
|
||||
@@ -79,6 +93,9 @@ services:
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||
depends_on:
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-migrate}
|
||||
networks:
|
||||
- kan-network
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
target: migrate
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-web}
|
||||
@@ -6,10 +22,10 @@ services:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
- dokploy-network
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile.new
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
target: web
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -107,7 +123,8 @@ services:
|
||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||
depends_on:
|
||||
- postgres
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
@@ -121,14 +138,17 @@ services:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
|
||||
networks:
|
||||
kan-network:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
kan_postgres_data:
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"./utils/rateLimit": {
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
},
|
||||
"./utils/permissions": {
|
||||
"types": "./dist/utils/permissions.d.ts",
|
||||
"default": "./src/utils/permissions.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
@@ -39,8 +43,6 @@
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:*",
|
||||
"@kan/email": "workspace:^",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { deleteObject, generateUploadUrl } from "../utils/s3";
|
||||
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
|
||||
|
||||
export const attachmentRouter = createTRPCRouter({
|
||||
generateUploadUrl: protectedProcedure
|
||||
@@ -142,9 +142,18 @@ export const attachmentRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new TRPCError({
|
||||
message: "Failed to create attachment",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: input.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
@@ -206,6 +215,8 @@ export const attachmentRouter = createTRPCRouter({
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.removed",
|
||||
cardId: attachment.cardId,
|
||||
attachmentId: attachment.id,
|
||||
fromTitle: attachment.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { colours } from "@kan/shared/constants";
|
||||
import {
|
||||
convertDueDateFiltersToRanges,
|
||||
generateAvatarUrl,
|
||||
generateSlug,
|
||||
generateUID,
|
||||
} from "@kan/shared/utils";
|
||||
@@ -33,6 +34,7 @@ export const boardRouter = createTRPCRouter({
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
archived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
@@ -64,7 +66,10 @@ export const boardRouter = createTRPCRouter({
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
userId,
|
||||
{ type: input.type }
|
||||
{
|
||||
type: input.type,
|
||||
archived: input.archived ?? false,
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -142,7 +147,63 @@ export const boardRouter = createTRPCRouter({
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate presigned URLs for workspace member avatars
|
||||
const workspaceWithAvatarUrls = result.workspace
|
||||
? {
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
: result.workspace;
|
||||
|
||||
// Generate presigned URLs for card member avatars
|
||||
const listsWithAvatarUrls = await Promise.all(
|
||||
result.lists.map(async (list) => ({
|
||||
...list,
|
||||
cards: await Promise.all(
|
||||
list.cards.map(async (card) => ({
|
||||
...card,
|
||||
members: await Promise.all(
|
||||
card.members.map(async (member) => {
|
||||
if (!member.user?.image) return member;
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: { ...member.user, image: avatarUrl },
|
||||
};
|
||||
}),
|
||||
),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
lists: listsWithAvatarUrls,
|
||||
workspace: workspaceWithAvatarUrls,
|
||||
};
|
||||
}),
|
||||
bySlug: publicProcedure
|
||||
.meta({
|
||||
@@ -161,7 +222,7 @@ export const boardRouter = createTRPCRouter({
|
||||
workspaceSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.max(64)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardSlug: z
|
||||
.string()
|
||||
@@ -404,7 +465,8 @@ export const boardRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.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>>>()))
|
||||
@@ -446,7 +508,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Handle other updates (name, slug, visibility)
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined;
|
||||
|
||||
if (!hasOtherUpdates) {
|
||||
// Only favorite was updated, return success
|
||||
@@ -473,6 +535,7 @@ export const boardRouter = createTRPCRouter({
|
||||
slug: input.slug,
|
||||
boardPublicId: input.boardPublicId,
|
||||
visibility: input.visibility,
|
||||
isArchived: input.isArchived,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
|
||||
@@ -10,8 +10,13 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateDownloadUrl } from "../utils/s3";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
import {
|
||||
createCardWebhookPayload,
|
||||
sendWebhooksForWorkspace,
|
||||
} from "../utils/webhook";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -153,6 +158,43 @@ export const cardRouter = createTRPCRouter({
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
|
||||
if (input.description) {
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: newCard.publicId,
|
||||
commentHtml: input.description,
|
||||
commenterUserId: userId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
list.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: String(newCard.id),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate ?? null,
|
||||
listId: String(newCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: list.boardPublicId,
|
||||
boardName: list.boardName,
|
||||
listName: list.name,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
|
||||
return newCard;
|
||||
}),
|
||||
addComment: protectedProcedure
|
||||
@@ -215,6 +257,16 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.comment,
|
||||
commenterUserId: userId,
|
||||
commentId: newComment.id,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
|
||||
return newComment;
|
||||
}),
|
||||
updateComment: protectedProcedure
|
||||
@@ -295,6 +347,16 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.comment,
|
||||
commenterUserId: userId,
|
||||
commentId: updatedComment.id,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
}),
|
||||
deleteComment: protectedProcedure
|
||||
@@ -631,45 +693,54 @@ export const cardRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// Generate URLs for all attachments
|
||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (result.attachments && Array.isArray(result.attachments)) {
|
||||
const attachments = result.attachments as {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
s3Key: string;
|
||||
originalFilename: string | null;
|
||||
size?: number | null;
|
||||
}[];
|
||||
const attachmentsWithUrls = await Promise.all(
|
||||
result.attachments.map(async (attachment) => {
|
||||
const url = await generateAttachmentUrl(attachment.s3Key);
|
||||
return {
|
||||
publicId: attachment.publicId,
|
||||
contentType: attachment.contentType,
|
||||
s3Key: attachment.s3Key,
|
||||
originalFilename: attachment.originalFilename,
|
||||
size: attachment.size,
|
||||
url,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const attachmentsWithUrls = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const base = {
|
||||
publicId: attachment.publicId,
|
||||
contentType: attachment.contentType,
|
||||
s3Key: attachment.s3Key,
|
||||
originalFilename: attachment.originalFilename,
|
||||
size: attachment.size,
|
||||
};
|
||||
if (!bucket || !attachment.s3Key) {
|
||||
return { ...base, url: null };
|
||||
}
|
||||
try {
|
||||
const url = await generateDownloadUrl(
|
||||
bucket,
|
||||
attachment.s3Key,
|
||||
86400, // 24 hours expiration
|
||||
);
|
||||
return { ...base, url };
|
||||
} catch {
|
||||
// If URL generation fails, return attachment with url: null
|
||||
return { ...base, url: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { ...result, attachments: attachmentsWithUrls };
|
||||
}
|
||||
// Generate presigned URLs for workspace member avatars
|
||||
const workspaceWithAvatarUrls = result.list.board.workspace
|
||||
? {
|
||||
...result.list.board.workspace,
|
||||
members: await Promise.all(
|
||||
result.list.board.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
return { ...result, attachments: [] };
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
: result.list.board.workspace;
|
||||
|
||||
return {
|
||||
...result,
|
||||
attachments: attachmentsWithUrls,
|
||||
list: {
|
||||
...result.list,
|
||||
board: {
|
||||
...result.list.board,
|
||||
workspace: workspaceWithAvatarUrls,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
getActivities: publicProcedure
|
||||
.meta({
|
||||
@@ -738,7 +809,39 @@ export const cardRouter = createTRPCRouter({
|
||||
},
|
||||
);
|
||||
|
||||
const mergedActivities = mergeActivities(result.activities);
|
||||
// Generate presigned URLs for user avatars in activities
|
||||
const activitiesWithAvatarUrls = await Promise.all(
|
||||
result.activities.map(async (activity) => {
|
||||
const updatedActivity = { ...activity };
|
||||
|
||||
// Generate presigned URL for activity user avatar
|
||||
if (activity.user?.image) {
|
||||
const userAvatarUrl = await generateAvatarUrl(activity.user.image);
|
||||
updatedActivity.user = {
|
||||
...activity.user,
|
||||
image: userAvatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate presigned URL for member user avatar (if exists)
|
||||
if (activity.member?.user?.image) {
|
||||
const memberAvatarUrl = await generateAvatarUrl(
|
||||
activity.member.user.image,
|
||||
);
|
||||
updatedActivity.member = {
|
||||
...activity.member,
|
||||
user: {
|
||||
...activity.member.user,
|
||||
image: memberAvatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return updatedActivity;
|
||||
}),
|
||||
);
|
||||
|
||||
const mergedActivities = mergeActivities(activitiesWithAvatarUrls);
|
||||
|
||||
return {
|
||||
activities: mergedActivities,
|
||||
@@ -883,6 +986,15 @@ export const cardRouter = createTRPCRouter({
|
||||
fromDescription: existingCard.description ?? undefined,
|
||||
toDescription: input.description,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.description,
|
||||
commenterUserId: userId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -925,6 +1037,59 @@ export const cardRouter = createTRPCRouter({
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
// Build changes object for webhook
|
||||
const webhookChanges: Record<string, { from: unknown; to: unknown }> = {};
|
||||
if (input.title && existingCard.title !== input.title) {
|
||||
webhookChanges.title = { from: existingCard.title, to: input.title };
|
||||
}
|
||||
if (input.description && existingCard.description !== input.description) {
|
||||
webhookChanges.description = {
|
||||
from: existingCard.description,
|
||||
to: input.description,
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.dueDate !== undefined &&
|
||||
previousDueDate?.getTime() !== input.dueDate?.getTime()
|
||||
) {
|
||||
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
|
||||
}
|
||||
if (newListId && existingCard.listId !== newListId) {
|
||||
webhookChanges.listId = { from: existingCard.listId, to: newListId };
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
newListId && existingCard.listId !== newListId
|
||||
? "card.moved"
|
||||
: "card.updated",
|
||||
{
|
||||
id: String(result.id),
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
dueDate: result.dueDate,
|
||||
listId: String(newListId ?? existingCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
changes:
|
||||
Object.keys(webhookChanges).length > 0
|
||||
? webhookChanges
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
@@ -972,6 +1137,9 @@ export const cardRouter = createTRPCRouter({
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
// Fetch full card data before delete for webhook
|
||||
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
await cardRepo.softDelete(ctx.db, {
|
||||
@@ -986,6 +1154,34 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
if (fullCard) {
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
"card.deleted",
|
||||
{
|
||||
id: String(fullCard.id),
|
||||
title: fullCard.title,
|
||||
description: fullCard.description,
|
||||
dueDate: fullCard.dueDate,
|
||||
listId: String(fullCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -81,6 +81,16 @@ export const checklistRouter = createTRPCRouter({
|
||||
return newChecklist;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a checklist",
|
||||
method: "PUT",
|
||||
path: "/checklists/{checklistPublicId}",
|
||||
description: "Updates a checklist by its public ID",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
checklistPublicId: z.string().length(12),
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
createTRPCRouter,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
import { createS3Client } from "../utils/s3";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const checkDatabaseConnection = async (db: dbClient) => {
|
||||
try {
|
||||
|
||||
@@ -11,10 +11,12 @@ import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { colours } from "@kan/shared/constants";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { generateSlug, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
import { decryptToken } from "../utils/encryption";
|
||||
import { apiKeys, urls } from "./integration";
|
||||
|
||||
export interface TrelloBoard {
|
||||
@@ -50,6 +52,51 @@ interface TrelloCheckItem {
|
||||
pos: number;
|
||||
}
|
||||
|
||||
interface GitHubProjectsResponse {
|
||||
data: {
|
||||
viewer: {
|
||||
projectsV2: {
|
||||
nodes?: { id: string; title: string }[];
|
||||
};
|
||||
organizations: {
|
||||
nodes: {
|
||||
projectsV2: {
|
||||
nodes?: { id: string; title: string }[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
interface GitHubGraphQLResponse {
|
||||
data?: {
|
||||
node?: GitHubProjectV2Node;
|
||||
};
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
interface GitHubProjectV2Node {
|
||||
title: string;
|
||||
field?: {
|
||||
options?: { id: string; name: string }[];
|
||||
};
|
||||
areaField?: {
|
||||
options?: { id: string; name: string; color: string }[];
|
||||
};
|
||||
items?: {
|
||||
nodes: {
|
||||
fieldValueByName?: { name: string };
|
||||
areaValue?: { name: string };
|
||||
content?: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TrelloCard {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -464,4 +511,436 @@ export const importRouter = createTRPCRouter({
|
||||
return { boardsCreated };
|
||||
}),
|
||||
}),
|
||||
github: createTRPCRouter({
|
||||
getProjects: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get projects from GitHub",
|
||||
method: "GET",
|
||||
path: "/integrations/github/projects",
|
||||
description: "Retrieves all projects from GitHub",
|
||||
tags: ["Integrations"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.array(z.object({ id: z.string(), name: z.string() })))
|
||||
.query(async ({ ctx }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const integration = await integrationsRepo.getProviderForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
"github",
|
||||
);
|
||||
|
||||
if (!integration)
|
||||
throw new TRPCError({
|
||||
message: "GitHub token not found",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const token = decryptToken(integration.accessToken);
|
||||
|
||||
// GraphQL query to fetch Projects V2 for the user and their organizations
|
||||
const query = `
|
||||
query {
|
||||
viewer {
|
||||
projectsV2(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
organizations(first: 10) {
|
||||
nodes {
|
||||
projectsV2(first: 10) {
|
||||
nodes {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Kan-App",
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(
|
||||
`GitHub API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
console.error(`GitHub API Response: ${errorText}`);
|
||||
|
||||
throw new TRPCError({
|
||||
message: `Failed to fetch GitHub projects: ${response.status} ${response.statusText}`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
const result = (await response.json()) as GitHubProjectsResponse;
|
||||
|
||||
if (result.errors) {
|
||||
console.error("GitHub GraphQL Errors:", result.errors);
|
||||
throw new TRPCError({
|
||||
message: "Failed to fetch GitHub projects (GraphQL Error)",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
const userProjects = result.data.viewer.projectsV2.nodes ?? [];
|
||||
const orgProjects = result.data.viewer.organizations.nodes.flatMap(
|
||||
(org) => org.projectsV2.nodes ?? [],
|
||||
);
|
||||
|
||||
const allProjects = [...userProjects, ...orgProjects];
|
||||
|
||||
return allProjects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.title,
|
||||
}));
|
||||
}),
|
||||
|
||||
importProjects: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Import projects from GitHub",
|
||||
method: "POST",
|
||||
path: "/imports/github/projects",
|
||||
description: "Imports projects from GitHub",
|
||||
tags: ["Imports"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
projectIds: z.array(z.string()),
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ projectsImported: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
if (!userId) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
const integration = await integrationsRepo.getProviderForUser(
|
||||
ctx.db,
|
||||
userId,
|
||||
"github",
|
||||
);
|
||||
|
||||
if (!integration)
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "GitHub token not found",
|
||||
});
|
||||
|
||||
const token = decryptToken(integration.accessToken);
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Workspace not found",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
const newImport = await importRepo.create(ctx.db, {
|
||||
source: "github",
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!newImport) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create import record",
|
||||
});
|
||||
}
|
||||
|
||||
const newImportId = newImport.id;
|
||||
let projectsImported = 0;
|
||||
|
||||
for (const projectId of input.projectIds) {
|
||||
// GraphQL query to fetch Project V2 details, status options, area options, and items
|
||||
const query = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ProjectV2 {
|
||||
title
|
||||
field(name: "Status") {
|
||||
... on ProjectV2SingleSelectField {
|
||||
options {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
areaField: field(name: "Area") {
|
||||
... on ProjectV2SingleSelectField {
|
||||
options {
|
||||
id
|
||||
name
|
||||
color
|
||||
}
|
||||
}
|
||||
}
|
||||
items(first: 100) {
|
||||
nodes {
|
||||
fieldValueByName(name: "Status") {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name
|
||||
}
|
||||
}
|
||||
areaValue: fieldValueByName(name: "Area") {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name
|
||||
}
|
||||
}
|
||||
content {
|
||||
... on Issue {
|
||||
title
|
||||
body
|
||||
}
|
||||
... on PullRequest {
|
||||
title
|
||||
body
|
||||
}
|
||||
... on DraftIssue {
|
||||
title
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Kan-App",
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { id: projectId } }),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as GitHubGraphQLResponse;
|
||||
if (result.errors || !result.data?.node) continue;
|
||||
|
||||
const projectData = result.data.node;
|
||||
const statusOptions = projectData.field?.options ?? [];
|
||||
const areaOptions = projectData.areaField?.options ?? [];
|
||||
const items = projectData.items?.nodes ?? [];
|
||||
|
||||
const boardPublicId = generateUID();
|
||||
const board = await boardRepo.create(ctx.db, {
|
||||
publicId: boardPublicId,
|
||||
name: projectData.title,
|
||||
workspaceId: workspace.id,
|
||||
slug: generateSlug(projectData.title),
|
||||
createdBy: userId,
|
||||
importId: newImportId,
|
||||
});
|
||||
|
||||
if (!board) continue;
|
||||
|
||||
// Prepare Labels
|
||||
const labelsInsert = areaOptions.map((option) => {
|
||||
let colourCode = "#0d9488"; // Default Teal
|
||||
const ghColor = option.color;
|
||||
|
||||
// Map GitHub colors to Kan colors
|
||||
if (ghColor === "BLUE") colourCode = "#0284c7";
|
||||
else if (ghColor === "GREEN") colourCode = "#65a30d";
|
||||
else if (ghColor === "YELLOW") colourCode = "#ca8a04";
|
||||
else if (ghColor === "ORANGE") colourCode = "#ea580c";
|
||||
else if (ghColor === "RED") colourCode = "#dc2626";
|
||||
else if (ghColor === "PINK") colourCode = "#db2777";
|
||||
else if (ghColor === "PURPLE") colourCode = "#4f46e5";
|
||||
else if (ghColor === "GRAY") colourCode = "#0d9488";
|
||||
|
||||
return {
|
||||
publicId: generateUID(),
|
||||
name: option.name,
|
||||
colourCode,
|
||||
createdBy: userId,
|
||||
boardId: board.id,
|
||||
importId: newImportId,
|
||||
};
|
||||
});
|
||||
|
||||
const createdLabels = await labelRepo.bulkCreate(
|
||||
ctx.db,
|
||||
labelsInsert,
|
||||
);
|
||||
const labelMap = new Map<string, number>();
|
||||
|
||||
createdLabels.forEach((label, index) => {
|
||||
const originalName = areaOptions[index]?.name;
|
||||
if (originalName) {
|
||||
labelMap.set(originalName, label.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Prepare Lists
|
||||
const listsInsert: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
boardId: number;
|
||||
index: number;
|
||||
importId: number;
|
||||
}[] = [];
|
||||
|
||||
if (statusOptions.length === 0) {
|
||||
listsInsert.push({
|
||||
publicId: generateUID(),
|
||||
name: "To Do",
|
||||
createdBy: userId,
|
||||
boardId: board.id,
|
||||
index: 0,
|
||||
importId: newImportId,
|
||||
});
|
||||
} else {
|
||||
statusOptions.forEach((option, index) => {
|
||||
listsInsert.push({
|
||||
publicId: generateUID(),
|
||||
name: option.name,
|
||||
createdBy: userId,
|
||||
boardId: board.id,
|
||||
index: index,
|
||||
importId: newImportId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const createdLists = await listRepo.bulkCreate(ctx.db, listsInsert);
|
||||
const listIdMap = new Map<string, number>();
|
||||
createdLists.forEach((list, index) => {
|
||||
const originalName = listsInsert[index]?.name;
|
||||
if (originalName) {
|
||||
listIdMap.set(originalName, list.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Prepare Cards
|
||||
const itemsToInsert: {
|
||||
item: NonNullable<
|
||||
NonNullable<
|
||||
NonNullable<GitHubProjectV2Node["items"]>["nodes"]
|
||||
>[number]
|
||||
>;
|
||||
listId: number;
|
||||
title: string;
|
||||
description: string;
|
||||
}[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const statusName = item.fieldValueByName?.name;
|
||||
const content = item.content ?? {};
|
||||
const title = content.title ?? "Untitled Card";
|
||||
const description = content.body ?? "";
|
||||
|
||||
let listId = statusName ? listIdMap.get(statusName) : undefined;
|
||||
|
||||
// Fallback to first list
|
||||
if (!listId && createdLists.length > 0) {
|
||||
listId = createdLists[0]?.id;
|
||||
}
|
||||
|
||||
if (listId) {
|
||||
itemsToInsert.push({
|
||||
item,
|
||||
listId,
|
||||
title,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cardsInput = itemsToInsert.map((data, index) => ({
|
||||
publicId: generateUID(),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
createdBy: userId,
|
||||
listId: data.listId,
|
||||
index: index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
|
||||
const createdCards = await cardRepo.bulkCreate(ctx.db, cardsInput);
|
||||
|
||||
// Create Activities
|
||||
const activities = createdCards.map((card) => ({
|
||||
type: "card.created" as const,
|
||||
cardId: card.id,
|
||||
createdBy: userId,
|
||||
}));
|
||||
|
||||
if (activities.length > 0) {
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
// Link Labels
|
||||
const cardLabelRelations: { cardId: number; labelId: number }[] = [];
|
||||
createdCards.forEach((card, index) => {
|
||||
const originalItem = itemsToInsert[index]?.item;
|
||||
const areaName = originalItem?.areaValue?.name;
|
||||
|
||||
if (areaName) {
|
||||
const labelId = labelMap.get(areaName);
|
||||
if (labelId) {
|
||||
cardLabelRelations.push({
|
||||
cardId: card.id,
|
||||
labelId: labelId,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (cardLabelRelations.length > 0) {
|
||||
await cardRepo.bulkCreateCardLabelRelationships(
|
||||
ctx.db,
|
||||
cardLabelRelations,
|
||||
);
|
||||
}
|
||||
|
||||
projectsImported++;
|
||||
}
|
||||
|
||||
if (projectsImported > 0 && newImportId) {
|
||||
await importRepo.update(
|
||||
ctx.db,
|
||||
{ status: "success" },
|
||||
{ importId: newImportId },
|
||||
);
|
||||
}
|
||||
|
||||
return { projectsImported };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -14,7 +14,65 @@ export const apiKeys = {
|
||||
trello: process.env.TRELLO_APP_API_KEY,
|
||||
};
|
||||
|
||||
import { encryptToken } from "../utils/encryption";
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
saveGitHubToken: protectedProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const encryptedToken = encryptToken(input.token);
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
|
||||
|
||||
await integrationsRepo.createOrUpdateProvider(ctx.db, {
|
||||
provider: "github",
|
||||
userId: user.id,
|
||||
accessToken: encryptedToken,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
await integrationsRepo.deleteProviderForUser(ctx.db, user.id, "github");
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const connected = await integrationsRepo.isProviderAvailableForUser(
|
||||
ctx.db,
|
||||
user.id,
|
||||
"github",
|
||||
);
|
||||
return { connected };
|
||||
}),
|
||||
|
||||
providers: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
@@ -67,7 +125,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ provider: z.enum(["trello"]) }))
|
||||
.input(z.object({ provider: z.enum(["trello", "github"]) }))
|
||||
.output(z.object({}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
@@ -210,11 +210,12 @@ export const memberRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member)
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
throw new TRPCError({
|
||||
message: `Member with public ID ${input.memberPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const deletedMember = await memberRepo.softDelete(ctx.db, {
|
||||
memberId: member.id,
|
||||
@@ -720,7 +721,7 @@ export const memberRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
|
||||
@@ -133,7 +133,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -209,7 +209,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -274,7 +274,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -339,7 +339,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
getUser: protectedProcedure
|
||||
@@ -55,8 +56,12 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
const apiKey = result.apiKeys[0];
|
||||
|
||||
// Generate presigned URL for avatar
|
||||
const imageUrl = await generateAvatarUrl(result.image);
|
||||
|
||||
return {
|
||||
...result,
|
||||
image: imageUrl,
|
||||
apiKey: apiKey ?? null,
|
||||
};
|
||||
}),
|
||||
@@ -102,6 +107,12 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
// Generate presigned URL for avatar
|
||||
const imageUrl = await generateAvatarUrl(result.image);
|
||||
|
||||
return {
|
||||
...result,
|
||||
image: imageUrl,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user