Compare commits
47 Commits
fix/update
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed8187c956 | ||
|
|
541d160222 | ||
|
|
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 | ||
|
|
2269d23c94 | ||
|
|
e437d075e3 | ||
|
|
b422907b53 | ||
|
|
672dfe6540 |
@@ -1,18 +1,62 @@
|
|||||||
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
docker-compose.override.yml
|
# Docker
|
||||||
|
docker-compose*.yml
|
||||||
Dockerfile
|
|
||||||
./**/*/Dockerfile
|
|
||||||
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
# Dependencies (rebuilt in Docker)
|
||||||
node_modules
|
node_modules
|
||||||
./**/*/node_modules
|
**/node_modules
|
||||||
|
|
||||||
|
# Build outputs (rebuilt in Docker)
|
||||||
|
**/.next
|
||||||
|
**/dist
|
||||||
|
**/out
|
||||||
|
**/.turbo
|
||||||
|
**/.cache
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github
|
||||||
|
.husky
|
||||||
|
.changeset
|
||||||
|
|
||||||
|
# Documentation (not needed in build)
|
||||||
|
*.md
|
||||||
|
!packages/db/migrations/**
|
||||||
|
LICENSE
|
||||||
|
CONTRIBUTING.md
|
||||||
|
AGENTS.md
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.test.tsx
|
||||||
|
**/*.spec.ts
|
||||||
|
**/*.spec.tsx
|
||||||
|
**/__tests__
|
||||||
|
**/coverage
|
||||||
|
|
||||||
|
# Logs
|
||||||
pnpm-debug.log
|
pnpm-debug.log
|
||||||
./**/*/pnpm-debug.log
|
**/pnpm-debug.log
|
||||||
|
|
||||||
README.md
|
# Cloud compose (separate deployment)
|
||||||
.next
|
cloud/
|
||||||
# .git
|
|
||||||
|
|||||||
13
.github/workflows/docker-publish.yml
vendored
13
.github/workflows/docker-publish.yml
vendored
@@ -9,11 +9,17 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "21 21 * * *"
|
- cron: "21 21 * * *"
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
# Only trigger on tags (releases), not main branch pushes
|
||||||
# Publish semver tags as releases.
|
# Main branch builds are handled by workflow_run after Translate completes
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Translate"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
# Docker builds after Translate completes (success or failure)
|
||||||
|
# This ensures Docker always has the latest translations
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Use docker.io for Docker Hub if empty
|
# Use docker.io for Docker Hub if empty
|
||||||
@@ -23,6 +29,9 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
49
.github/workflows/translate.yml
vendored
Normal file
49
.github/workflows/translate.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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: Extract translations
|
||||||
|
working-directory: apps/web
|
||||||
|
run: npx --yes @lingui/cli 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: npx --yes @lingui/cli compile --typescript
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
12
README.md
12
README.md
@@ -47,7 +47,17 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
|
|
||||||
## Self Hosting 🐳
|
## 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
ARG NODE_VERSION=20
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG APP_DIRNAME=web
|
|
||||||
ARG PROJECT=@kan/web
|
|
||||||
|
|
||||||
# 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
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
apk add --no-cache libc6-compat && \
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true
|
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||||
|
corepack enable && \
|
||||||
|
npm install turbo@2.3.1 --global && \
|
||||||
|
pnpm config set store-dir ~/.pnpm-store
|
||||||
|
|
||||||
# Setup pnpm and turbo on the alpine base
|
# ============================================
|
||||||
FROM alpine AS base
|
# Stage 2: Prune the monorepo
|
||||||
RUN corepack enable
|
# ============================================
|
||||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
FROM alpine AS pruner
|
||||||
# RUN npm install turbo@2.1.3 --global
|
|
||||||
RUN npm install turbo@2.3.1 --global
|
|
||||||
|
|
||||||
RUN pnpm config set store-dir ~/.pnpm-store
|
|
||||||
|
|
||||||
# 2. Prune projects
|
|
||||||
FROM base AS pruner
|
|
||||||
ARG PROJECT
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
@@ -35,53 +33,89 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
|
|||||||
echo "unknown") && \
|
echo "unknown") && \
|
||||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||||
|
|
||||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
||||||
|
|
||||||
# 3. Build the project
|
|
||||||
FROM base AS builder
|
|
||||||
ARG PROJECT
|
|
||||||
ARG APP_VERSION
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 3: Install dependencies
|
||||||
|
# ============================================
|
||||||
|
FROM alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
|
||||||
|
|
||||||
ENV CI=true
|
ENV CI=true
|
||||||
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
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')}" && \
|
# Stage 4: Build the web application
|
||||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
# ============================================
|
||||||
|
FROM alpine AS builder
|
||||||
# # Copy static files to standalone directory
|
ARG APP_VERSION
|
||||||
# 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
|
|
||||||
|
|
||||||
WORKDIR /app
|
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 NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
# Copy the standalone Next.js server
|
||||||
WORKDIR /app/apps/${APP_DIRNAME}
|
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
|
# Copy bootstrap script for runtime env var injection
|
||||||
ENV PORT=${PORT}
|
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||||
EXPOSE ${PORT}
|
|
||||||
|
|
||||||
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": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"json": {
|
||||||
"include": ["src/locales/[locale]/messages.po"]
|
"include": ["src/locales/[locale]/messages.json"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||||
|
|||||||
3255
apps/web/i18n.lock
3255
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",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
|
|||||||
exclude: ["**/node_modules/**"],
|
exclude: ["**/node_modules/**"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
format: formatter({ style: "lingui" }),
|
||||||
|
});
|
||||||
export default config;
|
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ const config = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
/** Exclude build tools and dev-only packages from the standalone output */
|
||||||
|
outputFileTracingExcludes: {
|
||||||
|
"**/*": [
|
||||||
|
"@esbuild/**",
|
||||||
|
"esbuild/**",
|
||||||
|
"typescript/**",
|
||||||
|
"webpack/**",
|
||||||
|
"uglify-js/**",
|
||||||
|
"terser/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@kan/api",
|
"@kan/api",
|
||||||
@@ -35,50 +47,13 @@ const config = {
|
|||||||
remotePatterns: (() => {
|
remotePatterns: (() => {
|
||||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||||
const patterns = [
|
const patterns = [
|
||||||
{
|
{ protocol: "https", hostname: "**" },
|
||||||
protocol: "https",
|
|
||||||
hostname:
|
|
||||||
env("S3_FORCE_PATH_STYLE") === "true"
|
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
|
||||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "*.googleusercontent.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'cdn.discordapp.com',
|
|
||||||
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;
|
return patterns;
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
@@ -95,12 +70,6 @@ const config = {
|
|||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
},
|
},
|
||||||
|
|
||||||
api: {
|
|
||||||
bodyParser: {
|
|
||||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.802.0",
|
"@aws-sdk/client-s3": "^3.802.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.802.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"@tiptap/extension-link": "^2.22.2",
|
"@tiptap/extension-link": "^2.22.2",
|
||||||
"@tiptap/extension-mention": "^3.0.9",
|
"@tiptap/extension-mention": "^3.0.9",
|
||||||
"@tiptap/extension-placeholder": "^2.14.0",
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
|
"@tiptap/extension-typography": "^3.18.0",
|
||||||
"@tiptap/pm": "^2.14.0",
|
"@tiptap/pm": "^2.14.0",
|
||||||
"@tiptap/react": "^2.14.0",
|
"@tiptap/react": "^2.14.0",
|
||||||
"@tiptap/starter-kit": "^2.14.0",
|
"@tiptap/starter-kit": "^2.14.0",
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
"@kan/tailwind-config": "workspace:*",
|
"@kan/tailwind-config": "workspace:*",
|
||||||
"@kan/tsconfig": "workspace:*",
|
"@kan/tsconfig": "workspace:*",
|
||||||
"@lingui/cli": "^5.3.2",
|
"@lingui/cli": "^5.3.2",
|
||||||
|
"@lingui/format-json": "^5.9.1",
|
||||||
"@lingui/loader": "^5.3.2",
|
"@lingui/loader": "^5.3.2",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.5.2",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
|
|||||||
@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
||||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
socialProviders?.length === 0 && (
|
||||||
<div className="flex w-full items-center gap-4">
|
<div className="flex w-full items-center gap-4">
|
||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
<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">
|
<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 className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
{socialProviders?.length !== 0 && (
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
{t`or`}
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
</span>
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
{t`or`}
|
||||||
</div>
|
</span>
|
||||||
)}
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
<div className="space-y-2">
|
||||||
<>
|
{isSignUp && isCredentialsEnabled && (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
{...register("email", { required: true })}
|
{...register("name", { required: true })}
|
||||||
placeholder={t`Enter your email address`}
|
placeholder={t`Enter your name`}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.name && (
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{t`Please enter a valid email address`}
|
{t`Please enter a valid name`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{isCredentialsEnabled && (
|
<div>
|
||||||
<div>
|
<Input
|
||||||
<Input
|
{...register("email", { required: true })}
|
||||||
type="password"
|
placeholder={t`Enter your email address`}
|
||||||
{...register("password", { required: true })}
|
/>
|
||||||
placeholder={t`Enter your password`}
|
{errors.email && (
|
||||||
/>
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{errors.password && (
|
{t`Please enter a valid email address`}
|
||||||
<p className="mt-2 text-xs text-red-400">
|
</p>
|
||||||
{errors.password.message ??
|
|
||||||
t`Please enter a valid password`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
{loginError && (
|
{isCredentialsEnabled && (
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
<div>
|
||||||
)}
|
<Input
|
||||||
</div>
|
type="password"
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
{...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">
|
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoginWithEmailPending}
|
isLoading={isLoginWithEmailPending}
|
||||||
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</form>
|
||||||
</form>
|
)}
|
||||||
|
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const initials = name
|
const initials = name?.trim()
|
||||||
? getInitialsFromName(name)
|
? getInitialsFromName(name)
|
||||||
: inferInitialsFromEmail(email);
|
: inferInitialsFromEmail(email);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
|
|||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -44,6 +45,12 @@ export default function Dashboard({
|
|||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||||
|
|
||||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
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 [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||||
const [isRightPanelOpen, setIsRightPanelOpen] = 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"} `}
|
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
|
<SideNavigation
|
||||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
user={{
|
||||||
isLoading={sessionLoading}
|
displayName: user?.name ?? session?.user.name,
|
||||||
|
email: user?.email ?? session?.user.email ?? "",
|
||||||
|
image: user?.image ?? undefined,
|
||||||
|
}}
|
||||||
|
isLoading={sessionLoading || userLoading}
|
||||||
onCloseSideNav={closeSideNav}
|
onCloseSideNav={closeSideNav}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-[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">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ReactRenderer,
|
ReactRenderer,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
|
import Typography from "@tiptap/extension-typography";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import {
|
import {
|
||||||
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
|
|||||||
label?: string | null;
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommandItems: SlashCommandItem[] = [
|
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 1",
|
||||||
},
|
icon: <HiH1 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 2",
|
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||||
icon: <HiH2 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
title: "Heading 2",
|
||||||
},
|
icon: <HiH2 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 3",
|
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||||
icon: <HiH3 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
title: "Heading 3",
|
||||||
},
|
icon: <HiH3 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Bullet List",
|
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||||
icon: <HiOutlineListBullet />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
];
|
||||||
},
|
|
||||||
{
|
return [
|
||||||
title: "Ordered List",
|
...headingCommands,
|
||||||
icon: <HiOutlineNumberedList />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
title: "Bullet List",
|
||||||
},
|
icon: <HiOutlineListBullet />,
|
||||||
{
|
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||||
title: "Blockquote",
|
},
|
||||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
title: "Ordered List",
|
||||||
},
|
icon: <HiOutlineNumberedList />,
|
||||||
{
|
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||||
title: "Code Block",
|
},
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().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({
|
export default function Editor({
|
||||||
content,
|
content,
|
||||||
@@ -434,6 +443,8 @@ export default function Editor({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
enableYouTubeEmbed = true,
|
enableYouTubeEmbed = true,
|
||||||
|
placeholder,
|
||||||
|
disableHeadings = false,
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
@@ -441,18 +452,23 @@ export default function Editor({
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
workspaceMembers: WorkspaceMember[];
|
||||||
enableYouTubeEmbed?: boolean;
|
enableYouTubeEmbed?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disableHeadings?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit.configure({
|
||||||
|
heading: disableHeadings ? false : undefined,
|
||||||
|
}),
|
||||||
Markdown,
|
Markdown,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: readOnly
|
placeholder: readOnly
|
||||||
? ""
|
? ""
|
||||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
: placeholder ??
|
||||||
|
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
@@ -465,10 +481,10 @@ export default function Editor({
|
|||||||
autolink: true,
|
autolink: true,
|
||||||
}),
|
}),
|
||||||
SlashCommands.configure({
|
SlashCommands.configure({
|
||||||
commandItems: CommandItems,
|
commandItems: getCommandItems(disableHeadings),
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: ({ query }: { query: string }) =>
|
items: ({ query }: { query: string }) =>
|
||||||
filterSlashCommandItems(CommandItems, query),
|
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
||||||
startOfLine: true,
|
startOfLine: true,
|
||||||
char: "/",
|
char: "/",
|
||||||
},
|
},
|
||||||
@@ -480,20 +496,28 @@ export default function Editor({
|
|||||||
suggestion: {
|
suggestion: {
|
||||||
char: "@",
|
char: "@",
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
const all: MentionItem[] = workspaceMembers.map(
|
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||||
(member: WorkspaceMember) => ({
|
|
||||||
id: member.publicId,
|
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||||
label: member?.user?.name ?? member.email,
|
id: member.publicId,
|
||||||
image: member?.user?.image ?? null,
|
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(
|
const q = query.toLowerCase().trim();
|
||||||
(u) =>
|
|
||||||
u.label &&
|
if (q === "") {
|
||||||
typeof u.label === "string" &&
|
return all;
|
||||||
u.label.toLowerCase().includes(q),
|
}
|
||||||
|
|
||||||
|
const filtered = all.filter((u) =>
|
||||||
|
u.label.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
|
return filtered;
|
||||||
},
|
},
|
||||||
command: ({ editor, range, props }) => {
|
command: ({ editor, range, props }) => {
|
||||||
const id = props.id ?? "";
|
const id = props.id ?? "";
|
||||||
@@ -514,6 +538,17 @@ export default function Editor({
|
|||||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
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] : []),
|
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const schema = z.object({
|
|||||||
.min(3, {
|
.min(3, {
|
||||||
message: t`URL must be at least 3 characters long`,
|
message: t`URL must be at least 3 characters long`,
|
||||||
})
|
})
|
||||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ export default function SideNavigation({
|
|||||||
const [isInitialised, setIsInitialised] = useState(false);
|
const [isInitialised, setIsInitialised] = useState(false);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||||
workspacePublicId: workspace.publicId,
|
{ workspacePublicId: workspace.publicId },
|
||||||
});
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
|
|||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
content: ReactNode;
|
content?: ReactNode;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
delay?: number | [number, number];
|
delay?: number | [number, number];
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ export function Tooltip({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!triggerRef.current) return;
|
if (!triggerRef.current) return;
|
||||||
|
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
rootRef.current = root;
|
rootRef.current = root;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Permission } from "@kan/shared";
|
import type { Permission } from "@kan/shared";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { WorkspaceContext } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
interface UsePermissionsResult {
|
interface UsePermissionsResult {
|
||||||
@@ -30,7 +31,40 @@ interface UsePermissionsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePermissions(): UsePermissionsResult {
|
export function usePermissions(): UsePermissionsResult {
|
||||||
const { workspace } = useWorkspace();
|
// 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 = {
|
||||||
|
permissions: [],
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
hasPermission: () => false,
|
||||||
|
canViewCard: false,
|
||||||
|
canCreateCard: false,
|
||||||
|
canEditCard: false,
|
||||||
|
canDeleteCard: false,
|
||||||
|
canCreateList: false,
|
||||||
|
canEditList: false,
|
||||||
|
canDeleteList: false,
|
||||||
|
canCreateBoard: false,
|
||||||
|
canEditBoard: false,
|
||||||
|
canDeleteBoard: false,
|
||||||
|
canViewComment: false,
|
||||||
|
canCreateComment: false,
|
||||||
|
canEditComment: false,
|
||||||
|
canDeleteComment: false,
|
||||||
|
canInviteMember: false,
|
||||||
|
canEditMember: false,
|
||||||
|
canRemoveMember: false,
|
||||||
|
canViewWorkspace: false,
|
||||||
|
canEditWorkspace: false,
|
||||||
|
};
|
||||||
|
return emptyPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace } = workspaceContext;
|
||||||
|
|
||||||
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
|||||||
6051
apps/web/src/locales/de/messages.json
Normal file
6051
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
10279
apps/web/src/locales/en/messages.json
Normal file
10279
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
6051
apps/web/src/locales/es/messages.json
Normal file
6051
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
6051
apps/web/src/locales/fr/messages.json
Normal file
6051
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
6051
apps/web/src/locales/it/messages.json
Normal file
6051
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
6051
apps/web/src/locales/nl/messages.json
Normal file
6051
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
6051
apps/web/src/locales/pl/messages.json
Normal file
6051
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
6051
apps/web/src/locales/pt-BR/messages.json
Normal file
6051
apps/web/src/locales/pt-BR/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
6051
apps/web/src/locales/ru/messages.json
Normal file
6051
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(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
/**
|
||||||
|
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||||
|
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||||
|
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||||
|
*/
|
||||||
|
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||||
|
if (forwardedProto) {
|
||||||
|
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||||
|
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedHost = req.headers["x-forwarded-host"];
|
||||||
|
if (forwardedHost) {
|
||||||
|
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||||
|
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
return await authHandler(req, res);
|
return await authHandler(req, res);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|||||||
const workspaceSlugSchema = z
|
const workspaceSlugSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(24)
|
.max(64)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||||
|
|
||||||
interface CheckoutSessionRequest {
|
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 type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogBackdrop,
|
DialogBackdrop,
|
||||||
@@ -464,19 +465,37 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|||||||
? stroke.modifiers.map(stringifyModifier)
|
? stroke.modifiers.map(stringifyModifier)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
modifierStrings.forEach((mod) => {
|
modifierStrings.forEach((mod, index) => {
|
||||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
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;
|
return parts;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shortcut.type === "SEQUENCE") {
|
if (shortcut.type === "SEQUENCE") {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
shortcut.strokes.forEach((stroke) => {
|
shortcut.strokes.forEach((stroke, strokeIndex) => {
|
||||||
parts.push(...formatStroke(stroke));
|
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>;
|
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
|
|||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
|
|
||||||
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export async function invalidateCard(
|
|||||||
utils: ReturnType<typeof api.useUtils>,
|
utils: ReturnType<typeof api.useUtils>,
|
||||||
cardPublicId: string,
|
cardPublicId: string,
|
||||||
) {
|
) {
|
||||||
|
if (!cardPublicId || cardPublicId.length < 12) return;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
utils.card.byId.invalidate({ cardPublicId }),
|
utils.card.byId.invalidate({ cardPublicId }),
|
||||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
utils.card.getActivities.invalidate({ cardPublicId }),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { env } from "next-runtime-env";
|
|
||||||
|
|
||||||
export const formatToArray = (
|
export const formatToArray = (
|
||||||
value: string | string[] | undefined,
|
value: string | string[] | undefined,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
|||||||
return imageOrKey;
|
return imageOrKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
return "";
|
||||||
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}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ const Card = ({
|
|||||||
const hasDueDate = !!dueDate;
|
const hasDueDate = !!dueDate;
|
||||||
|
|
||||||
return (
|
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">
|
<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>{title}</span>
|
<span className="break-words">{title}</span>
|
||||||
{labels.length ||
|
{labels.length ||
|
||||||
members.length ||
|
members.length ||
|
||||||
checklists.length > 0 ||
|
checklists.length > 0 ||
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
import Link from "next/link";
|
import { Fragment } from "react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { HiLink } from "react-icons/hi";
|
import { HiLink } from "react-icons/hi";
|
||||||
|
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
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 = ({
|
const UpdateBoardSlugButton = ({
|
||||||
handleOnClick,
|
handleOnClick,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
boardSlug,
|
boardSlug,
|
||||||
|
boardPublicId,
|
||||||
|
visibility,
|
||||||
isLoading,
|
isLoading,
|
||||||
canEdit,
|
canEdit,
|
||||||
}: {
|
}: {
|
||||||
handleOnClick: () => void;
|
handleOnClick: () => void;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
boardSlug: string;
|
boardSlug: string;
|
||||||
|
boardPublicId: string;
|
||||||
|
visibility: "public" | "private";
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
content={!canEdit ? t`You don't have permission` : undefined}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={canEdit ? handleOnClick : undefined}
|
onClick={canEdit ? handleOnClick : undefined}
|
||||||
disabled={!canEdit || isLoading}
|
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"
|
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">
|
<div className="flex items-center">
|
||||||
<span>
|
{pathSegments.map((segment, i) => (
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
<Fragment key={i}>
|
||||||
? "kan.bn"
|
{i > 0 && pathSeparator}
|
||||||
: env("NEXT_PUBLIC_BASE_URL")}
|
<span>{segment}</span>
|
||||||
</span>
|
</Fragment>
|
||||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
))}
|
||||||
<span>{workspaceSlug}</span>
|
</div>
|
||||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
<button
|
||||||
<span>{boardSlug}</span>
|
type="button"
|
||||||
</div>
|
onClick={(e) => {
|
||||||
<Link
|
e.stopPropagation();
|
||||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
navigator.clipboard.writeText(boardUrl).then(
|
||||||
target="_blank"
|
() =>
|
||||||
rel="noopener noreferrer"
|
showPopup({
|
||||||
onClick={(e) => {
|
header: t`Link copied`,
|
||||||
e.stopPropagation();
|
icon: "success",
|
||||||
if (!canEdit) {
|
message: t`Board URL copied to clipboard`,
|
||||||
e.preventDefault();
|
}),
|
||||||
}
|
).catch(() => undefined);
|
||||||
}}
|
}}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
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]" />
|
>
|
||||||
</Link>
|
<HiLink className="h-[13px] w-[13px]" />
|
||||||
</button>
|
</button>
|
||||||
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default UpdateBoardSlugButton;
|
export default UpdateBoardSlugButton;
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
href="/settings?tab=workspace"
|
href="/settings/workspace"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
>
|
>
|
||||||
{t`Edit workspace URL`}
|
{t`Edit workspace URL`}
|
||||||
|
|||||||
@@ -58,13 +58,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
const [selectedPublicListId, setSelectedPublicListId] =
|
const [selectedPublicListId, setSelectedPublicListId] =
|
||||||
useState<PublicListId>("");
|
useState<PublicListId>("");
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
|
||||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
direction: "horizontal",
|
direction: "horizontal",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
const { canCreateList, canEditList, canEditCard, canEditBoard } =
|
||||||
|
usePermissions();
|
||||||
|
|
||||||
const { tooltipContent: createListShortcutTooltipContent } =
|
const { tooltipContent: createListShortcutTooltipContent } =
|
||||||
useKeyboardShortcut({
|
useKeyboardShortcut({
|
||||||
@@ -417,10 +418,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||||
readOnly={!canEditBoard}
|
readOnly={!canEditBoard}
|
||||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none 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>
|
</form>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
{!boardData && !isLoading && (
|
||||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||||
@@ -443,6 +443,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
workspaceSlug={workspace.slug ?? ""}
|
workspaceSlug={workspace.slug ?? ""}
|
||||||
boardSlug={boardData?.slug ?? ""}
|
boardSlug={boardData?.slug ?? ""}
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
visibility={boardData?.visibility ?? "private"}
|
||||||
canEdit={canEditBoard}
|
canEdit={canEditBoard}
|
||||||
/>
|
/>
|
||||||
<VisibilityButton
|
<VisibilityButton
|
||||||
@@ -596,12 +598,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? `/templates/${boardId}/cards/${card.publicId}`
|
? `/templates/${boardId}/cards/${card.publicId}`
|
||||||
: `/cards/${card.publicId}`
|
: `/cards/${card.publicId}`
|
||||||
}
|
}
|
||||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||||
"PLACEHOLDER",
|
card.publicId.startsWith(
|
||||||
)
|
"PLACEHOLDER",
|
||||||
? "pointer-events-none"
|
)
|
||||||
: ""
|
? "pointer-events-none"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ export const getTemplates = (): Template[] => [
|
|||||||
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
||||||
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "roadmap-basic",
|
||||||
|
name: t`Basic Roadmap`,
|
||||||
|
lists: [t`Requested`, t`Planned`, t`In Progress`, t`Done`],
|
||||||
|
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "roadmap-extended",
|
||||||
|
name: t`Extended Roadmap`,
|
||||||
|
lists: [t`Requested`, t`Under Review`, t`Planned`, t`In Progress`, t`Done`, t`Rejected`],
|
||||||
|
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "content-creation",
|
id: "content-creation",
|
||||||
name: t`Content Creation`,
|
name: t`Content Creation`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
HiOutlineArrowRight,
|
HiOutlineArrowRight,
|
||||||
HiOutlineCheckCircle,
|
HiOutlineCheckCircle,
|
||||||
HiOutlineClock,
|
HiOutlineClock,
|
||||||
|
HiOutlinePaperClip,
|
||||||
HiOutlinePencil,
|
HiOutlinePencil,
|
||||||
HiOutlinePlus,
|
HiOutlinePlus,
|
||||||
HiOutlineTag,
|
HiOutlineTag,
|
||||||
@@ -31,29 +32,50 @@ import Comment from "./Comment";
|
|||||||
type ActivityType =
|
type ActivityType =
|
||||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||||
|
|
||||||
|
type ActivityWithMergedLabels =
|
||||||
|
GetCardActivitiesOutput["activities"][number] & {
|
||||||
|
mergedLabels?: string[];
|
||||||
|
attachment?: {
|
||||||
|
publicId: string;
|
||||||
|
filename: string;
|
||||||
|
originalFilename: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
const truncate = (value: string | null, maxLength = 50) => {
|
const truncate = (value: string | null, maxLength = 50) => {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = ({
|
const getActivityText = ({
|
||||||
type,
|
type,
|
||||||
toTitle,
|
toTitle,
|
||||||
fromList,
|
fromList,
|
||||||
toList,
|
toList,
|
||||||
memberName,
|
memberName,
|
||||||
|
memberEmail,
|
||||||
isSelf,
|
isSelf,
|
||||||
label,
|
label,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
toDueDate,
|
toDueDate,
|
||||||
dateLocale,
|
dateLocale,
|
||||||
mergedLabels,
|
mergedLabels,
|
||||||
|
attachmentName,
|
||||||
}: {
|
}: {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
toTitle: string | null;
|
toTitle: string | null;
|
||||||
fromList: string | null;
|
fromList: string | null;
|
||||||
toList: string | null;
|
toList: string | null;
|
||||||
memberName: string | null;
|
memberName: string | null;
|
||||||
|
memberEmail: string | null;
|
||||||
isSelf: boolean;
|
isSelf: boolean;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
fromTitle?: string | null;
|
fromTitle?: string | null;
|
||||||
@@ -61,7 +83,9 @@ const getActivityText = ({
|
|||||||
toDueDate?: Date | null;
|
toDueDate?: Date | null;
|
||||||
dateLocale: DateFnsLocale;
|
dateLocale: DateFnsLocale;
|
||||||
mergedLabels?: string[];
|
mergedLabels?: string[];
|
||||||
|
attachmentName?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
|
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||||
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
||||||
{children}
|
{children}
|
||||||
@@ -113,6 +137,8 @@ const getActivityText = ({
|
|||||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||||
|
"card.updated.attachment.added": t`added an attachment`,
|
||||||
|
"card.updated.attachment.removed": t`removed an attachment`,
|
||||||
"card.updated.dueDate.added": t`set the due date`,
|
"card.updated.dueDate.added": t`set the due date`,
|
||||||
"card.updated.dueDate.updated": t`updated the due date`,
|
"card.updated.dueDate.updated": t`updated the due date`,
|
||||||
"card.updated.dueDate.removed": t`removed the due date`,
|
"card.updated.dueDate.removed": t`removed the due date`,
|
||||||
@@ -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>;
|
if (isSelf) return <Trans>self-assigned the card</Trans>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
|
assigned <TextHighlight>{truncate(displayName)}</TextHighlight> to the
|
||||||
card
|
card
|
||||||
</Trans>
|
</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>;
|
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
|
unassigned <TextHighlight>{truncate(displayName)}</TextHighlight> from
|
||||||
the card
|
the card
|
||||||
</Trans>
|
</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) {
|
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||||
const showYear = !isSameYear(toDueDate, new Date());
|
const showYear = !isSameYear(toDueDate, new Date());
|
||||||
const formattedDate = format(
|
const formattedDate = format(
|
||||||
@@ -297,6 +344,8 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
|||||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||||
|
"card.updated.attachment.added": <HiOutlinePaperClip />,
|
||||||
|
"card.updated.attachment.removed": <HiOutlinePaperClip />,
|
||||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||||
@@ -330,7 +379,7 @@ const ActivityList = ({
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
isViewOnly?: boolean;
|
isViewOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { dateLocale, locale } = useLocalisation();
|
const { dateLocale } = useLocalisation();
|
||||||
const { data: sessionData } = authClient.useSession();
|
const { data: sessionData } = authClient.useSession();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [allActivities, setAllActivities] = useState<
|
const [allActivities, setAllActivities] = useState<
|
||||||
@@ -352,7 +401,7 @@ const ActivityList = ({
|
|||||||
limit: ACTIVITIES_PAGE_SIZE,
|
limit: ACTIVITIES_PAGE_SIZE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!cardPublicId,
|
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -380,25 +429,21 @@ const ActivityList = ({
|
|||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextPage) {
|
const existingIds = new Set(
|
||||||
const existingIds = new Set(
|
currentActivities.map((a) => a.publicId),
|
||||||
currentActivities.map((a) => a.publicId),
|
);
|
||||||
);
|
const newActivities = nextPage.activities.filter(
|
||||||
const newActivities = nextPage.activities.filter(
|
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
);
|
||||||
);
|
currentActivities = [...currentActivities, ...newActivities];
|
||||||
currentActivities = [...currentActivities, ...newActivities];
|
currentHasMore = nextPage.hasMore;
|
||||||
currentHasMore = nextPage.hasMore;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAllActivities(currentActivities);
|
setAllActivities(currentActivities);
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAllRemaining();
|
void fetchAllRemaining();
|
||||||
} else {
|
} else {
|
||||||
setAllActivities(firstPageData.activities);
|
setAllActivities(firstPageData.activities);
|
||||||
setHasMore(firstPageData.hasMore);
|
setHasMore(firstPageData.hasMore);
|
||||||
@@ -425,17 +470,15 @@ const ActivityList = ({
|
|||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextPage) {
|
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
const newActivities = nextPage.activities.filter(
|
||||||
const newActivities = nextPage.activities.filter(
|
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
);
|
||||||
);
|
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
setHasMore(nextPage.hasMore);
|
||||||
setHasMore(nextPage.hasMore);
|
|
||||||
|
|
||||||
if (!nextPage.hasMore) {
|
if (!nextPage.hasMore) {
|
||||||
isFullyExpandedRef.current = true;
|
isFullyExpandedRef.current = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
@@ -455,13 +498,17 @@ const ActivityList = ({
|
|||||||
fromList: activity.fromList?.name ?? null,
|
fromList: activity.fromList?.name ?? null,
|
||||||
toList: activity.toList?.name ?? null,
|
toList: activity.toList?.name ?? null,
|
||||||
memberName: activity.member?.user?.name ?? null,
|
memberName: activity.member?.user?.name ?? null,
|
||||||
|
memberEmail: activity.member?.user?.email ?? null,
|
||||||
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
||||||
label: activity.label?.name ?? null,
|
label: activity.label?.name ?? null,
|
||||||
fromTitle: activity.fromTitle ?? null,
|
fromTitle: activity.fromTitle ?? null,
|
||||||
fromDueDate: activity.fromDueDate ?? null,
|
fromDueDate: activity.fromDueDate ?? null,
|
||||||
toDueDate: activity.toDueDate ?? null,
|
toDueDate: activity.toDueDate ?? null,
|
||||||
dateLocale: dateLocale,
|
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")
|
if (activity.type === "card.updated.comment.added")
|
||||||
@@ -508,7 +555,7 @@ const ActivityList = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<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">
|
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||||
{activityText}
|
{activityText}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||||
|
|
||||||
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
const uploadFile = async (file: File) => {
|
||||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
setUploading(true);
|
||||||
onSuccess: async () => {
|
|
||||||
|
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);
|
await invalidateCard(utils, cardPublicId);
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Attachment uploaded`,
|
header: t`Attachment uploaded`,
|
||||||
message: t`Your file has been uploaded successfully.`,
|
message: t`Your file has been uploaded successfully.`,
|
||||||
icon: "success",
|
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 {
|
} catch {
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Upload failed`,
|
header: t`Upload failed`,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ContentEditable from "react-contenteditable";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
|
import type { WorkspaceMember } from "~/components/Editor";
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
@@ -29,7 +30,6 @@ const Comment = ({
|
|||||||
createdAt,
|
createdAt,
|
||||||
comment,
|
comment,
|
||||||
isAuthor,
|
isAuthor,
|
||||||
isAdmin,
|
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
isViewOnly = false,
|
isViewOnly = false,
|
||||||
}: {
|
}: {
|
||||||
@@ -42,7 +42,6 @@ const Comment = ({
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
comment: string | undefined;
|
comment: string | undefined;
|
||||||
isAuthor: boolean;
|
isAuthor: boolean;
|
||||||
isAdmin: boolean;
|
|
||||||
isEdited: boolean;
|
isEdited: boolean;
|
||||||
isViewOnly: 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;
|
if (!publicId) return null;
|
||||||
|
|
||||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||||
@@ -142,21 +165,28 @@ const Comment = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<ContentEditable
|
<div className="mt-2">
|
||||||
html={comment ?? ""}
|
<Editor
|
||||||
disabled={true}
|
content={comment ?? null}
|
||||||
className="break-anywhere mt-2 text-sm"
|
readOnly={true}
|
||||||
/>
|
workspaceMembers={workspaceMembers}
|
||||||
|
enableYouTubeEmbed={false}
|
||||||
|
disableHeadings={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<ContentEditable
|
<div className="mt-2">
|
||||||
placeholder={t`Add a comment...`}
|
<Editor
|
||||||
html={watch("comment")}
|
content={watch("comment")}
|
||||||
disabled={false}
|
onChange={(value) => setValue("comment", value)}
|
||||||
onChange={(e) => setValue("comment", e.target.value)}
|
workspaceMembers={workspaceMembers}
|
||||||
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"
|
enableYouTubeEmbed={false}
|
||||||
/>
|
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||||
<div className="flex justify-end space-x-2">
|
disableHeadings={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2 mt-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import {
|
import {
|
||||||
HiEllipsisHorizontal,
|
HiEllipsisHorizontal,
|
||||||
|
HiLink,
|
||||||
HiOutlineCheckCircle,
|
HiOutlineCheckCircle,
|
||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
@@ -10,18 +11,54 @@ import { authClient } from "@kan/auth/client";
|
|||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
|
||||||
export default function CardDropdown({
|
export default function CardDropdown({
|
||||||
|
cardPublicId,
|
||||||
|
isTemplate,
|
||||||
|
boardPublicId,
|
||||||
cardCreatedBy,
|
cardCreatedBy,
|
||||||
}: {
|
}: {
|
||||||
|
cardPublicId: string;
|
||||||
|
isTemplate?: boolean;
|
||||||
|
boardPublicId?: string;
|
||||||
cardCreatedBy?: string | null;
|
cardCreatedBy?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
const { canEditCard, canDeleteCard } = usePermissions();
|
const { canEditCard, canDeleteCard } = usePermissions();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
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 = [
|
const items = [
|
||||||
|
{
|
||||||
|
label: t`Copy card link`,
|
||||||
|
action: handleCopyCardLink,
|
||||||
|
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
|
},
|
||||||
...(canEditCard
|
...(canEditCard
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import ContentEditable from "react-contenteditable";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||||
|
|
||||||
|
import Editor from "~/components/Editor";
|
||||||
|
import type { WorkspaceMember } from "~/components/Editor";
|
||||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -13,7 +14,13 @@ interface FormValues {
|
|||||||
comment: string;
|
comment: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
const NewCommentForm = ({
|
||||||
|
cardPublicId,
|
||||||
|
workspaceMembers,
|
||||||
|
}: {
|
||||||
|
cardPublicId: string;
|
||||||
|
workspaceMembers: WorkspaceMember[];
|
||||||
|
}) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { canCreateComment } = usePermissions();
|
const { canCreateComment } = usePermissions();
|
||||||
@@ -23,10 +30,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryParams = {
|
|
||||||
cardPublicId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCommentMutation = api.card.addComment.useMutation({
|
const addCommentMutation = api.card.addComment.useMutation({
|
||||||
onError: (_error, _newList) => {
|
onError: (_error, _newList) => {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -57,18 +60,13 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
|||||||
onSubmit={handleSubmit(onSubmit)}
|
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"
|
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
|
<Editor
|
||||||
placeholder={t`Add a comment...`}
|
content={watch("comment")}
|
||||||
html={watch("comment")}
|
onChange={(value) => setValue("comment", value)}
|
||||||
disabled={false}
|
workspaceMembers={workspaceMembers}
|
||||||
onChange={(e) => setValue("comment", e.target.value)}
|
enableYouTubeEmbed={false}
|
||||||
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"
|
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||||
onKeyDown={async (e) => {
|
disableHeadings={true}
|
||||||
if (e.key === "Enter" && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||||
|
import { HiXMark } from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
@@ -14,8 +17,6 @@ import Modal from "~/components/modal";
|
|||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -53,9 +54,10 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
|
|
||||||
const { data: card } = api.card.byId.useQuery({
|
const { data: card } = api.card.byId.useQuery(
|
||||||
cardPublicId: cardId ?? "",
|
{ cardPublicId: cardId ?? "" },
|
||||||
});
|
{ enabled: !!cardId && cardId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||||
const canEdit = canEditCard || isCreator;
|
const canEdit = canEditCard || isCreator;
|
||||||
@@ -166,7 +168,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
const {
|
const {
|
||||||
modalContentType,
|
modalContentType,
|
||||||
entityId,
|
entityId,
|
||||||
openModal,
|
|
||||||
getModalState,
|
getModalState,
|
||||||
clearModalState,
|
clearModalState,
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -184,9 +185,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
: router.query.cardId;
|
: router.query.cardId;
|
||||||
|
|
||||||
const { data: card, isLoading } = api.card.byId.useQuery({
|
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||||
cardPublicId: cardId ?? "",
|
{ cardPublicId: cardId ?? "" },
|
||||||
});
|
{ enabled: !!cardId && cardId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||||
const canEdit = canEditCard || isCreator;
|
const canEdit = canEditCard || isCreator;
|
||||||
@@ -196,8 +198,24 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const board = card?.list.board;
|
const board = card?.list.board;
|
||||||
|
const workspaceMembers = board?.workspace.members;
|
||||||
const boardId = board?.publicId;
|
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({
|
const updateCard = api.card.update.useMutation({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -284,6 +302,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
|
|
||||||
if (!cardId) return <></>;
|
if (!cardId) return <></>;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
@@ -315,7 +334,19 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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-5 w-5" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -372,9 +403,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Editor
|
<Editor
|
||||||
content={card.description}
|
content={card.description}
|
||||||
onChange={canEdit ? (e) => setValue("description", e) : undefined}
|
onChange={
|
||||||
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
|
canEdit
|
||||||
workspaceMembers={board?.workspace.members ?? []}
|
? (e) => setValue("description", e)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onBlur={
|
||||||
|
canEdit ? () => handleSubmit(onSubmit)() : undefined
|
||||||
|
}
|
||||||
|
workspaceMembers={workspaceMembers ?? []}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +455,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<NewCommentForm cardPublicId={cardId} />
|
<NewCommentForm
|
||||||
|
cardPublicId={cardId}
|
||||||
|
workspaceMembers={editorWorkspaceMembers}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{ workspacePublicId: workspace.publicId },
|
||||||
// { enabled: workspace?.publicId ? true : false },
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
@@ -48,9 +48,11 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
const updateRoleMutation = api.member.updateRole.useMutation({
|
const updateRoleMutation = api.member.updateRole.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.workspace.byId.invalidate({
|
if (workspace.publicId && workspace.publicId.length >= 12) {
|
||||||
workspacePublicId: workspace.publicId,
|
await utils.workspace.byId.invalidate({
|
||||||
});
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Role updated`,
|
header: t`Role updated`,
|
||||||
@@ -124,7 +126,6 @@ export default function MembersPage() {
|
|||||||
name={memberName ?? ""}
|
name={memberName ?? ""}
|
||||||
email={memberEmail ?? ""}
|
email={memberEmail ?? ""}
|
||||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||||
icon={showPendingIcon ? "?" : undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +213,7 @@ export default function MembersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative z-50",
|
"relative",
|
||||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,293 +1,587 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
|
import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
interface Feature {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
kan: boolean | string;
|
|
||||||
trello: boolean | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Section {
|
|
||||||
name: string;
|
|
||||||
features: Feature[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type FrequencyValue = "monthly" | "annually";
|
type FrequencyValue = "monthly" | "annually";
|
||||||
|
|
||||||
const CellValue = ({ value }: { value: boolean | string }) => {
|
interface PlanFeature {
|
||||||
if (typeof value === "string") {
|
key: string;
|
||||||
return (
|
label: string;
|
||||||
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span>
|
free: string | boolean | { text: string; highlight?: string };
|
||||||
);
|
teams: string | boolean | { text: string; highlight?: string };
|
||||||
}
|
pro: string | boolean | { text: string; highlight?: string };
|
||||||
return value ? (
|
enterprise: string | boolean | { text: string; highlight?: string };
|
||||||
<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 FeatureSection {
|
||||||
);
|
name: string;
|
||||||
};
|
features: PlanFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
const FeatureComparisonTable = ({
|
const FeatureComparisonTable = ({
|
||||||
frequencyValue,
|
frequencyValue: _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`,
|
key: "boards",
|
||||||
features: [
|
label: t`Boards`,
|
||||||
{
|
free: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||||
key: "ulimited-workspaces",
|
teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||||
label: t`Unlimited workspaces`,
|
pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||||
kan: true,
|
enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "unlimited-boards",
|
|
||||||
label: t`Unlimited boards`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "unlimited-lists",
|
|
||||||
label: t`Unlimited lists`,
|
|
||||||
kan: true,
|
|
||||||
trello: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "unlimited-cards",
|
|
||||||
label: t`Unlimited cards`,
|
|
||||||
kan: true,
|
|
||||||
trello: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "activity-log",
|
|
||||||
label: t`Activity log`,
|
|
||||||
kan: true,
|
|
||||||
trello: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "templates",
|
|
||||||
label: t`Custom templates`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
|
|
||||||
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
|
|
||||||
{
|
|
||||||
key: "import",
|
|
||||||
label: t`Import from Trello`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "visibility",
|
|
||||||
label: t`Board visibility`,
|
|
||||||
kan: true,
|
|
||||||
trello: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "search",
|
|
||||||
label: t`Intelligent search`,
|
|
||||||
kan: true,
|
|
||||||
trello: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "workspace-url",
|
|
||||||
label: t`Custom workspace link`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Teams`,
|
key: "members",
|
||||||
features: [
|
label: t`Members`,
|
||||||
{
|
free: { text: t`1 user`, highlight: "1" },
|
||||||
key: "pricing",
|
teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
|
||||||
label: t`Price per user/month`,
|
pro: { text: t`Unlimited members`, highlight: "Unlimited" },
|
||||||
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`,
|
enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
|
||||||
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "file-uploads",
|
||||||
key: "comments",
|
label: t`File uploads`,
|
||||||
label: t`Comments & mentions`,
|
free: { text: t`10mb file uploads`, highlight: "10mb" },
|
||||||
kan: true,
|
teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||||
trello: true,
|
pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||||
},
|
enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||||
{
|
},
|
||||||
key: "assignments",
|
{
|
||||||
label: t`Assignees`,
|
key: "workspace-username",
|
||||||
kan: true,
|
label: t`Workspace username`,
|
||||||
trello: true,
|
free: { text: t`Default username`, highlight: "Default" },
|
||||||
},
|
teams: { text: t`Default username`, highlight: "Default" },
|
||||||
{
|
pro: { text: t`Custom username`, highlight: "Custom" },
|
||||||
key: "sharing",
|
enterprise: { text: t`Custom username`, highlight: "Custom" },
|
||||||
label: t`Board sharing & invites`,
|
},
|
||||||
kan: true,
|
];
|
||||||
trello: true,
|
|
||||||
},
|
const coreFeatures: PlanFeature[] = [
|
||||||
{
|
{
|
||||||
key: "invite-links",
|
key: "checklists",
|
||||||
label: t`Invite links`,
|
label: t`Checklists`,
|
||||||
kan: true,
|
free: true,
|
||||||
trello: 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` },
|
||||||
|
pro: { text: t`Full API access` },
|
||||||
|
enterprise: { text: t`Full API access` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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` },
|
||||||
|
teams: { text: t`Google SSO` },
|
||||||
|
pro: { text: t`Google SSO` },
|
||||||
|
enterprise: { text: t`Google SSO + SAML` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin-roles",
|
||||||
|
label: t`Admin roles`,
|
||||||
|
free: { text: t`Admin roles` },
|
||||||
|
teams: { text: t`Admin roles` },
|
||||||
|
pro: { text: t`Admin roles` },
|
||||||
|
enterprise: { text: t`Advanced admin roles` },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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`,
|
name: t`Platform`,
|
||||||
features: [
|
features: platformFeatures,
|
||||||
{
|
},
|
||||||
key: "api",
|
{
|
||||||
label: t`REST API`,
|
name: t`Security`,
|
||||||
kan: true,
|
features: securityFeatures,
|
||||||
trello: true,
|
},
|
||||||
},
|
{
|
||||||
{
|
name: t`Support`,
|
||||||
key: "open-source",
|
features: supportFeatures,
|
||||||
label: t`Open source`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "self-hostable",
|
|
||||||
label: t`Self-hostable`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "white-label",
|
|
||||||
label: t`White label`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "performance",
|
|
||||||
label: t`Fast & lightweight`,
|
|
||||||
kan: true,
|
|
||||||
trello: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "this-is-a-joke",
|
|
||||||
label: t`Owned by Atlassian`,
|
|
||||||
kan: false,
|
|
||||||
trello: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const products = [
|
const plans = [
|
||||||
{
|
{ id: "free", name: t`Free`, tierId: "tier-free" },
|
||||||
id: "kan",
|
{ id: "teams", name: t`Teams`, tierId: "tier-teams" },
|
||||||
name: t`Kan`,
|
{ id: "pro", name: t`Pro`, tierId: "tier-pro" },
|
||||||
featured: true,
|
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trello",
|
|
||||||
name: t`Trello`,
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const isHighlightValue = (
|
||||||
|
value: string | boolean | { text: string; highlight?: string },
|
||||||
|
): value is { text: string; highlight?: string } => {
|
||||||
|
return typeof value === "object" && value !== null && "text" in value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldHighlight = (text: string): boolean => {
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
|
||||||
|
(keyword) => lowerText.includes(keyword),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CellValue = ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: string | boolean | { text: string; highlight?: string };
|
||||||
|
label: string;
|
||||||
|
}) => {
|
||||||
|
// Handle object with text and highlight
|
||||||
|
if (isHighlightValue(value)) {
|
||||||
|
const { text, highlight } = value;
|
||||||
|
const isUnlimited = shouldHighlight(text);
|
||||||
|
|
||||||
|
let displayText;
|
||||||
|
if (highlight) {
|
||||||
|
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
|
||||||
|
displayText = (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) =>
|
||||||
|
part.toLowerCase() === highlight.toLowerCase() ? (
|
||||||
|
<span key={index} className="text-light-1000 dark:text-dark-1000">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={index} className="text-light-950 dark:text-dark-800">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
displayText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<HiCheckCircle
|
||||||
|
className={twMerge(
|
||||||
|
"mt-0.5 h-4 w-4 shrink-0",
|
||||||
|
isUnlimited
|
||||||
|
? "text-light-1000 dark:text-dark-1000"
|
||||||
|
: "text-light-400 dark:text-dark-600",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isUnlimited
|
||||||
|
? "text-light-1000 dark:text-dark-950"
|
||||||
|
: "text-light-950 dark:text-dark-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const isUnlimited = shouldHighlight(value);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<HiCheckCircle
|
||||||
|
className={twMerge(
|
||||||
|
"mt-0.5 h-4 w-4 shrink-0",
|
||||||
|
isUnlimited
|
||||||
|
? "text-light-1000 dark:text-dark-1000"
|
||||||
|
: "text-light-400 dark:text-dark-600",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isUnlimited
|
||||||
|
? "text-light-1000 dark:text-dark-950"
|
||||||
|
: "text-light-950 dark:text-dark-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
// Boolean true - show checkmark icon
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Boolean false - show cross icon
|
||||||
|
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?: string } => {
|
||||||
|
switch (planId) {
|
||||||
|
case "free":
|
||||||
|
return feature.free;
|
||||||
|
case "teams":
|
||||||
|
return feature.teams;
|
||||||
|
case "pro":
|
||||||
|
return feature.pro;
|
||||||
|
case "enterprise":
|
||||||
|
return feature.enterprise;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby="comparison-heading" className="w-full px-4">
|
<section aria-labelledby="comparison-heading" className="w-full">
|
||||||
<div className="mx-auto max-w-6xl py-8 lg:py-10">
|
<div className="mx-auto max-w-7xl">
|
||||||
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300">
|
<div
|
||||||
{products.map((product) => (
|
ref={containerRef}
|
||||||
<div key={product.id} aria-hidden="true" className="-mt-px">
|
className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
|
||||||
<div
|
>
|
||||||
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`}
|
{isHeaderFixed && (
|
||||||
>
|
<div
|
||||||
<p
|
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"
|
||||||
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`}
|
style={{
|
||||||
>
|
top: 64,
|
||||||
{product.name}
|
left: headerRect.left,
|
||||||
</p>
|
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>
|
||||||
))}
|
)}
|
||||||
</div>
|
<table className="w-full border-separate border-spacing-0">
|
||||||
|
<thead>
|
||||||
<div className="-mt-6 space-y-12">
|
<tr>
|
||||||
{sections.map((section) => (
|
{plans.map((plan) => {
|
||||||
<div key={section.name}>
|
const isMostPopular = plan.id === "pro";
|
||||||
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
return (
|
||||||
{section.name}
|
<th
|
||||||
</h4>
|
key={plan.id}
|
||||||
<div className="relative -mx-8 mt-8">
|
scope="col"
|
||||||
<div
|
className={twMerge(
|
||||||
aria-hidden="true"
|
"w-1/4 px-6 py-4 text-left text-base font-semibold",
|
||||||
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
isMostPopular
|
||||||
>
|
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||||
{products.map((product) => (
|
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||||
<div
|
)}
|
||||||
key={product.id}
|
>
|
||||||
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`}
|
<span className="flex items-center">
|
||||||
/>
|
{plan.name}
|
||||||
))}
|
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||||
</div>
|
</span>
|
||||||
|
</th>
|
||||||
<table className="relative w-full border-separate border-spacing-x-8">
|
);
|
||||||
<thead>
|
})}
|
||||||
<tr className="text-left">
|
</tr>
|
||||||
<th scope="col">
|
</thead>
|
||||||
<span className="sr-only">{t`Feature`}</span>
|
<tbody>
|
||||||
</th>
|
{sections.map((section, sectionIdx) => (
|
||||||
{products.map((p) => (
|
<React.Fragment key={section.name || `section-${sectionIdx}`}>
|
||||||
<th key={p.id} scope="col">
|
{section.name && (
|
||||||
<span className="sr-only">{p.name}</span>
|
<tr>
|
||||||
</th>
|
{plans.map((plan) => {
|
||||||
))}
|
const isPro = plan.id === "pro";
|
||||||
</tr>
|
return (
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{section.features.map((feature, featureIdx) => (
|
|
||||||
<tr key={feature.key}>
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
|
|
||||||
>
|
|
||||||
{feature.label}
|
|
||||||
{featureIdx !== section.features.length - 1 ? (
|
|
||||||
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
|
|
||||||
) : null}
|
|
||||||
</th>
|
|
||||||
{products.map((p) => (
|
|
||||||
<td
|
<td
|
||||||
key={p.id}
|
key={plan.id}
|
||||||
className="relative w-1/3 px-4 py-0 text-center"
|
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">
|
{plan.id === "free" ? section.name : ""}
|
||||||
<CellValue
|
|
||||||
value={
|
|
||||||
p.id === "kan" ? feature.kan : feature.trello
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
))}
|
);
|
||||||
</tr>
|
})}
|
||||||
))}
|
</tr>
|
||||||
</tbody>
|
)}
|
||||||
</table>
|
{section.features.map((feature) => (
|
||||||
|
<tr key={feature.key}>
|
||||||
<div
|
{plans.map((plan) => {
|
||||||
aria-hidden="true"
|
const isPro = plan.id === "pro";
|
||||||
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
return (
|
||||||
>
|
<td
|
||||||
{products.map((product) => (
|
key={plan.id}
|
||||||
<div
|
className={twMerge(
|
||||||
key={product.id}
|
"w-1/4 border-t px-6 py-3 text-left",
|
||||||
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`}
|
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>
|
</React.Fragment>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</tbody>
|
||||||
))}
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -23,81 +23,115 @@ const Pricing = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const tiers = [
|
const tiers = [
|
||||||
{
|
{
|
||||||
name: t`Individuals`,
|
name: t`Free`,
|
||||||
id: "tier-individuals",
|
id: "tier-free",
|
||||||
href: "signup",
|
href: "signup",
|
||||||
buttonText: t`Get Started`,
|
buttonText: t`Get started`,
|
||||||
price: { monthly: t`Free`, annually: t`Free` },
|
price: { monthly: t`$0`, annually: t`$0` },
|
||||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
description: t`Free for everyone`,
|
||||||
featureHeader: t`Free, forever`,
|
bestFor: t`Best for individuals and solo creators getting started`,
|
||||||
|
featureHeader: undefined,
|
||||||
features: [
|
features: [
|
||||||
t`1 user`,
|
{ text: t`1 user` },
|
||||||
t`Unlimited boards`,
|
{ text: t`Unlimited boards` },
|
||||||
t`Unlimited cards`,
|
{ text: t`Unlimited cards` },
|
||||||
t`Custom board templates`,
|
{ text: t`Custom board templates` },
|
||||||
t`Checklists`,
|
{ text: t`Checklists` },
|
||||||
],
|
],
|
||||||
showPrice: true,
|
showPrice: true,
|
||||||
|
ctaSubtext: undefined,
|
||||||
|
mostPopular: false,
|
||||||
|
highlighted: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Teams`,
|
name: t`Teams`,
|
||||||
id: "tier-teams",
|
id: "tier-teams",
|
||||||
href: "signup",
|
href: "signup",
|
||||||
buttonText: t`Get Started`,
|
buttonText: t`Get started`,
|
||||||
price: { monthly: "$10.00", annually: "$8.00" },
|
price: { monthly: "$10", annually: "$8" },
|
||||||
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
description: t`Perfect for small and growing teams looking to collaborate.`,
|
||||||
featureHeader: t`Everything in the free plan, plus:`,
|
bestFor: t`Best for small and growing teams that need collaboration`,
|
||||||
|
featureHeader: undefined,
|
||||||
features: [
|
features: [
|
||||||
t`Workspace members`,
|
{ text: t`All Free features +` },
|
||||||
t`Admin roles`,
|
{ text: t`Workspace members` },
|
||||||
t`Priority email support`,
|
{ text: t`Invite links` },
|
||||||
t`Support the development of the project`,
|
{ 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,
|
highlighted: true,
|
||||||
showPrice: true,
|
showPrice: true,
|
||||||
showPriceSuffix: true,
|
showPriceSuffix: false,
|
||||||
|
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||||
|
mostPopular: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Self Host`,
|
name: t`Enterprise`,
|
||||||
id: "tier-self-host",
|
id: "tier-enterprise",
|
||||||
href: "https://github.com/kanbn/kan",
|
href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
|
||||||
buttonText: t`View docs`,
|
buttonText: t`Contact Sales`,
|
||||||
price: { monthly: "-", annually: "-" },
|
price: { monthly: t`Contact us`, annually: t`Contact us` },
|
||||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
description: t`Advanced security, compliance, and dedicated support for large organizations.`,
|
||||||
featureHeader: t`Complete control and ownership:`,
|
bestFor: t`Best for large organizations with advanced needs`,
|
||||||
|
featureHeader: undefined,
|
||||||
features: [
|
features: [
|
||||||
t`Run on your own infrastructure`,
|
{ text: t`All Pro features +` },
|
||||||
t`Own your data`,
|
{ text: t`SAML SSO` },
|
||||||
t`Custom domain`,
|
{ 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,
|
mostPopular: false,
|
||||||
showPrice: false,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
<div className="mx-auto max-w-7xl px-4">
|
||||||
<div className="mt-14 flex flex-col items-center justify-center">
|
<div className="mb-8 flex items-center justify-center">
|
||||||
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
|
|
||||||
<HiBolt />
|
|
||||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
|
||||||
</div>
|
|
||||||
<fieldset aria-label={t`Payment frequency`}>
|
<fieldset aria-label={t`Payment frequency`}>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={frequency}
|
value={frequency}
|
||||||
onChange={(value) => setFrequency(value)}
|
onChange={(value) => setFrequency(value)}
|
||||||
className="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) => (
|
{frequencies.map((option) => (
|
||||||
<Radio
|
<Radio
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option}
|
value={option}
|
||||||
className={twMerge(
|
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
|
frequency?.value === option.value
|
||||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
|
||||||
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
|
: "text-light-600 dark:text-dark-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -106,103 +140,129 @@ const Pricing = ({
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</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) => (
|
{tiers.map((tier) => (
|
||||||
<div
|
<div
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
tier.highlighted
|
"relative flex h-full flex-col rounded-lg border p-6",
|
||||||
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800"
|
tier.mostPopular
|
||||||
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
|
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||||
"rounded-3xl p-8 xl:p-10",
|
: "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-x-4">
|
{tier.id === "tier-pro" && (
|
||||||
<h3
|
<div className="absolute right-2 top-2 z-10">
|
||||||
id={tier.id}
|
<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">
|
||||||
className={twMerge(
|
<HiBolt className="h-3 w-3 -ml-0.5" />
|
||||||
tier.highlighted
|
{t`Launch offer`}
|
||||||
? "text-dark-1000 dark:text-dark-1000"
|
</span>
|
||||||
: "text-dark-50 dark:text-dark-1000",
|
</div>
|
||||||
"text-lg/8 font-semibold",
|
)}
|
||||||
|
<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.showPriceSuffix && (
|
||||||
{tier.name}
|
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||||
</h3>
|
{frequency?.priceSuffix}
|
||||||
{tier.highlighted && (
|
</span>
|
||||||
<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`}
|
{tier.id === "tier-pro" && (
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<p
|
<ul role="list" className="space-y-2.5">
|
||||||
className={twMerge(
|
{tier.features.map((feature, index) => (
|
||||||
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900",
|
<li
|
||||||
tier.highlighted ? "text-light-100" : "text-dark-50",
|
key={`${tier.id}-feature-${index}`}
|
||||||
)}
|
className="flex items-start gap-x-2.5"
|
||||||
>
|
>
|
||||||
{tier.description}
|
<HiCheckCircle
|
||||||
</p>
|
className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
|
||||||
<p className="mt-6 flex items-baseline gap-x-1">
|
/>
|
||||||
<span
|
<span className="text-sm text-dark-600 dark:text-dark-800">
|
||||||
className={twMerge(
|
{feature.text}
|
||||||
"text-3xl font-semibold tracking-tight text-light-100",
|
</span>
|
||||||
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}
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto mt-8 max-w-3xl px-4 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||||
|
<p>{t`14 day free trial · Switch plans or cancel anytime`}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export default function PricingView() {
|
|||||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||||
{t`Simple pricing`}
|
{t`Simple pricing`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
<p className="text:md lg:text-md mt-6 max-w-[300px] 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.`}
|
{t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PricingTiers
|
<PricingTiers
|
||||||
@@ -59,23 +59,9 @@ export default function PricingView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pb-22 flex flex-col items-center justify-center px-4">
|
<div className="pb-22 flex flex-col items-center justify-center px-4">
|
||||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
|
||||||
<p>{t`Features`}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
<div className="mt-10 w-full">
|
||||||
{t`Feature breakdown`}
|
|
||||||
</p>
|
|
||||||
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
|
||||||
{t`Compare our features to see why Kan is the best choice.`}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Button variant="primary" href="/signup">
|
|
||||||
{t`Get started`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 w-full max-w-[900px]">
|
|
||||||
<FeatureComparisonTable
|
<FeatureComparisonTable
|
||||||
frequencyValue={frequency?.value ?? "annually"}
|
frequencyValue={frequency?.value ?? "annually"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 Badge from "~/components/Badge";
|
||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import ActivityList from "~/views/card/components/ActivityList";
|
import ActivityList from "~/views/card/components/ActivityList";
|
||||||
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
|
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
|
||||||
@@ -23,16 +24,35 @@ export function CardModal({
|
|||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { closeModal, isOpen } = useModal();
|
const { closeModal, isOpen } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
const [showFade, setShowFade] = useState(false);
|
const [showFade, setShowFade] = useState(false);
|
||||||
const [showTopFade, setShowTopFade] = useState(false);
|
const [showTopFade, setShowTopFade] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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(
|
const { data, isLoading } = api.card.byId.useQuery(
|
||||||
{
|
{
|
||||||
cardPublicId: cardPublicId ?? "",
|
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="h-full p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<button
|
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
||||||
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.preventDefault();
|
onClick={handleCopyCardLink}
|
||||||
closeModal();
|
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(() => {
|
setTimeout(() => {
|
||||||
void router.replace(
|
void router.replace(
|
||||||
{
|
{
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: {
|
query: {
|
||||||
...router.query,
|
...router.query,
|
||||||
workspaceSlug,
|
workspaceSlug: workspaceSlug ?? "",
|
||||||
boardSlug: [boardSlug],
|
boardSlug: [boardSlug ?? ""],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
undefined,
|
||||||
undefined,
|
{ shallow: true },
|
||||||
{ shallow: true },
|
);
|
||||||
);
|
}, 400);
|
||||||
}, 400);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<HiXMark
|
||||||
<HiXMark
|
size={18}
|
||||||
size={18}
|
className="text-light-900 dark:text-dark-900"
|
||||||
className="dark:text-dark-9000 text-light-900"
|
/>
|
||||||
/>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
<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 { showPopup } = usePopup();
|
||||||
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
direction: "horizontal",
|
direction: "horizontal",
|
||||||
@@ -70,30 +70,35 @@ export default function PublicBoardView() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const CopyBoardLink = () => {
|
const handleCopyBoardLink = async () => {
|
||||||
return (
|
try {
|
||||||
<button
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
onClick={async () => {
|
showPopup({
|
||||||
try {
|
header: t`Link copied`,
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
icon: "success",
|
||||||
} catch (error) {
|
message: t`Board URL copied to clipboard`,
|
||||||
console.error(error);
|
});
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Link copied`,
|
header: t`Unable to copy link`,
|
||||||
icon: "success",
|
icon: "error",
|
||||||
message: t`Board URL copied to clipboard`,
|
message: t`Please try again.`,
|
||||||
});
|
});
|
||||||
}}
|
}
|
||||||
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 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 pathWithoutQuery = router.asPath.split("?")[0];
|
||||||
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
||||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ export default function PermissionsSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Refresh any relevant workspace data
|
// Refresh any relevant workspace data
|
||||||
await utils.workspace.byId.invalidate({
|
if (workspace.publicId && workspace.publicId.length >= 12) {
|
||||||
workspacePublicId: workspace.publicId,
|
await utils.workspace.byId.invalidate({
|
||||||
});
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ export default function WorkspaceSettings() {
|
|||||||
const { data } = api.user.getUser.useQuery();
|
const { data } = api.user.getUser.useQuery();
|
||||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||||
workspacePublicId: workspace.publicId,
|
{ workspacePublicId: workspace.publicId },
|
||||||
});
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
|
|||||||
@@ -58,28 +58,6 @@ export default function Avatar({
|
|||||||
const [crop, setCrop] = useState<PercentCrop>();
|
const [crop, setCrop] = useState<PercentCrop>();
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
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;
|
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
|
||||||
|
|
||||||
@@ -187,29 +165,32 @@ export default function Avatar({
|
|||||||
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
|
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
|
||||||
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
|
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
|
||||||
|
|
||||||
|
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
|
`${baseUrl}/api/upload/avatar`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
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 };
|
// User image is updated in the backend, refresh user data
|
||||||
|
await utils.user.getUser.refetch();
|
||||||
const uploadResponse = await fetch(url, {
|
|
||||||
method: "PUT",
|
showPopup({
|
||||||
body: blob,
|
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);
|
setCropDialogOpen(false);
|
||||||
resetCropState();
|
resetCropState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -227,7 +208,7 @@ export default function Avatar({
|
|||||||
resetCropState,
|
resetCropState,
|
||||||
selectedFile,
|
selectedFile,
|
||||||
showPopup,
|
showPopup,
|
||||||
updateUser,
|
utils.user.getUser,
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ export default function UpdateWorkspaceEmailVisibilityForm({
|
|||||||
|
|
||||||
const updateWorkspace = api.workspace.update.useMutation({
|
const updateWorkspace = api.workspace.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void utils.workspace.byId.invalidate({
|
if (workspacePublicId && workspacePublicId.length >= 12) {
|
||||||
workspacePublicId,
|
void utils.workspace.byId.invalidate({
|
||||||
});
|
workspacePublicId,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
|
|||||||
.min(3, {
|
.min(3, {
|
||||||
message: t`URL must be at least 3 characters long`,
|
message: t`URL must be at least 3 characters long`,
|
||||||
})
|
})
|
||||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
|
migrator:
|
||||||
|
image: ghcr.io/kanbn/kan-migrate:latest
|
||||||
|
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
target: migrate
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
environment:
|
||||||
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: ghcr.io/kanbn/kan:latest
|
||||||
container_name: ${CONTAINER_NAME:-kan-web}
|
container_name: ${CONTAINER_NAME:-kan-web}
|
||||||
@@ -9,6 +22,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: apps/web/Dockerfile
|
dockerfile: apps/web/Dockerfile
|
||||||
|
target: web
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-}
|
APP_VERSION: ${APP_VERSION:-}
|
||||||
env_file:
|
env_file:
|
||||||
@@ -79,6 +93,9 @@ services:
|
|||||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||||
|
depends_on:
|
||||||
|
migrator:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
services:
|
services:
|
||||||
|
migrate:
|
||||||
|
image: ghcr.io/kanbn/kan-migrate:latest
|
||||||
|
container_name: ${CONTAINER_NAME:-kan-migrate}
|
||||||
|
networks:
|
||||||
|
- kan-network
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./apps/web/Dockerfile
|
||||||
|
target: migrate
|
||||||
|
environment:
|
||||||
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: ghcr.io/kanbn/kan:latest
|
||||||
container_name: ${CONTAINER_NAME:-kan-web}
|
container_name: ${CONTAINER_NAME:-kan-web}
|
||||||
@@ -6,10 +22,10 @@ services:
|
|||||||
- "${WEB_PORT:-3000}:3000"
|
- "${WEB_PORT:-3000}:3000"
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
- dokploy-network
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./apps/web/Dockerfile.new
|
dockerfile: ./apps/web/Dockerfile
|
||||||
|
target: web
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -107,7 +123,8 @@ services:
|
|||||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
@@ -121,14 +138,17 @@ services:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
volumes:
|
volumes:
|
||||||
- kan_postgres_data:/var/lib/postgresql/data
|
- kan_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
kan-network:
|
kan-network:
|
||||||
dokploy-network:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
kan_postgres_data:
|
kan_postgres_data:
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
"./utils/rateLimit": {
|
"./utils/rateLimit": {
|
||||||
"types": "./dist/utils/rateLimit.d.ts",
|
"types": "./dist/utils/rateLimit.d.ts",
|
||||||
"default": "./src/utils/rateLimit.ts"
|
"default": "./src/utils/rateLimit.ts"
|
||||||
|
},
|
||||||
|
"./utils/permissions": {
|
||||||
|
"types": "./dist/utils/permissions.d.ts",
|
||||||
|
"default": "./src/utils/permissions.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
@@ -39,8 +43,6 @@
|
|||||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.802.0",
|
|
||||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
|
||||||
"@kan/auth": "workspace:*",
|
"@kan/auth": "workspace:*",
|
||||||
"@kan/db": "workspace:*",
|
"@kan/db": "workspace:*",
|
||||||
"@kan/email": "workspace:^",
|
"@kan/email": "workspace:^",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { assertPermission } from "../utils/permissions";
|
import { assertPermission } from "../utils/permissions";
|
||||||
import { deleteObject, generateUploadUrl } from "../utils/s3";
|
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const attachmentRouter = createTRPCRouter({
|
export const attachmentRouter = createTRPCRouter({
|
||||||
generateUploadUrl: protectedProcedure
|
generateUploadUrl: protectedProcedure
|
||||||
@@ -142,9 +142,18 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Failed to create attachment",
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await cardActivityRepo.create(ctx.db, {
|
await cardActivityRepo.create(ctx.db, {
|
||||||
type: "card.updated.attachment.added",
|
type: "card.updated.attachment.added",
|
||||||
cardId: card.id,
|
cardId: card.id,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
toTitle: input.originalFilename,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,6 +215,8 @@ export const attachmentRouter = createTRPCRouter({
|
|||||||
await cardActivityRepo.create(ctx.db, {
|
await cardActivityRepo.create(ctx.db, {
|
||||||
type: "card.updated.attachment.removed",
|
type: "card.updated.attachment.removed",
|
||||||
cardId: attachment.cardId,
|
cardId: attachment.cardId,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
fromTitle: attachment.originalFilename,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
import { colours } from "@kan/shared/constants";
|
import { colours } from "@kan/shared/constants";
|
||||||
import {
|
import {
|
||||||
convertDueDateFiltersToRanges,
|
convertDueDateFiltersToRanges,
|
||||||
|
generateAvatarUrl,
|
||||||
generateSlug,
|
generateSlug,
|
||||||
generateUID,
|
generateUID,
|
||||||
} from "@kan/shared/utils";
|
} from "@kan/shared/utils";
|
||||||
@@ -142,7 +143,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
|
bySlug: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -161,7 +218,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
workspaceSlug: z
|
workspaceSlug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(24)
|
.max(64)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||||
boardSlug: z
|
boardSlug: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { mergeActivities } from "../utils/activities";
|
import { mergeActivities } from "../utils/activities";
|
||||||
|
import { sendMentionEmails } from "../utils/notifications";
|
||||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||||
import { generateDownloadUrl } from "../utils/s3";
|
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const cardRouter = createTRPCRouter({
|
export const cardRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -153,6 +154,17 @@ export const cardRouter = createTRPCRouter({
|
|||||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return newCard;
|
return newCard;
|
||||||
}),
|
}),
|
||||||
addComment: protectedProcedure
|
addComment: protectedProcedure
|
||||||
@@ -215,6 +227,16 @@ export const cardRouter = createTRPCRouter({
|
|||||||
createdBy: userId,
|
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;
|
return newComment;
|
||||||
}),
|
}),
|
||||||
updateComment: protectedProcedure
|
updateComment: protectedProcedure
|
||||||
@@ -295,6 +317,16 @@ export const cardRouter = createTRPCRouter({
|
|||||||
createdBy: userId,
|
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;
|
return updatedComment;
|
||||||
}),
|
}),
|
||||||
deleteComment: protectedProcedure
|
deleteComment: protectedProcedure
|
||||||
@@ -631,45 +663,54 @@ export const cardRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Generate URLs for all attachments
|
// Generate URLs for all attachments
|
||||||
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
const attachmentsWithUrls = await Promise.all(
|
||||||
if (result.attachments && Array.isArray(result.attachments)) {
|
result.attachments.map(async (attachment) => {
|
||||||
const attachments = result.attachments as {
|
const url = await generateAttachmentUrl(attachment.s3Key);
|
||||||
publicId: string;
|
return {
|
||||||
contentType: string;
|
publicId: attachment.publicId,
|
||||||
s3Key: string;
|
contentType: attachment.contentType,
|
||||||
originalFilename: string | null;
|
s3Key: attachment.s3Key,
|
||||||
size?: number | null;
|
originalFilename: attachment.originalFilename,
|
||||||
}[];
|
size: attachment.size,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const attachmentsWithUrls = await Promise.all(
|
// Generate presigned URLs for workspace member avatars
|
||||||
attachments.map(async (attachment) => {
|
const workspaceWithAvatarUrls = result.list.board.workspace
|
||||||
const base = {
|
? {
|
||||||
publicId: attachment.publicId,
|
...result.list.board.workspace,
|
||||||
contentType: attachment.contentType,
|
members: await Promise.all(
|
||||||
s3Key: attachment.s3Key,
|
result.list.board.workspace.members.map(async (member) => {
|
||||||
originalFilename: attachment.originalFilename,
|
if (!member.user?.image) {
|
||||||
size: attachment.size,
|
return member;
|
||||||
};
|
}
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
getActivities: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -738,7 +779,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 {
|
return {
|
||||||
activities: mergedActivities,
|
activities: mergedActivities,
|
||||||
@@ -883,6 +956,15 @@ export const cardRouter = createTRPCRouter({
|
|||||||
fromDescription: existingCard.description ?? undefined,
|
fromDescription: existingCard.description ?? undefined,
|
||||||
toDescription: input.description,
|
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 (
|
if (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
import { createS3Client } from "../utils/s3";
|
import { createS3Client } from "@kan/shared/utils";
|
||||||
|
|
||||||
const checkDatabaseConnection = async (db: dbClient) => {
|
const checkDatabaseConnection = async (db: dbClient) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
getUser: protectedProcedure
|
getUser: protectedProcedure
|
||||||
@@ -55,8 +56,12 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const apiKey = result.apiKeys[0];
|
const apiKey = result.apiKeys[0];
|
||||||
|
|
||||||
|
// Generate presigned URL for avatar
|
||||||
|
const imageUrl = await generateAvatarUrl(result.image);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
image: imageUrl,
|
||||||
apiKey: apiKey ?? null,
|
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,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertPermission } from "../utils/permissions";
|
import { assertPermission } from "../utils/permissions";
|
||||||
|
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||||
|
|
||||||
export const workspaceRouter = createTRPCRouter({
|
export const workspaceRouter = createTRPCRouter({
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
@@ -86,9 +87,27 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
const shouldShowEmails =
|
const shouldShowEmails =
|
||||||
isAdmin || result.showEmailsToMembers === true;
|
isAdmin || result.showEmailsToMembers === true;
|
||||||
|
|
||||||
|
// Generate presigned URLs for member avatars
|
||||||
|
const membersWithAvatarUrls = await Promise.all(
|
||||||
|
result.members.map(async (member) => {
|
||||||
|
if (!member.user?.image) {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
user: {
|
||||||
|
...member.user,
|
||||||
|
image: avatarUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// If emails should be hidden, filter them out
|
// If emails should be hidden, filter them out
|
||||||
if (!shouldShowEmails) {
|
if (!shouldShowEmails) {
|
||||||
const sanitizedMembers = result.members.map((member) => {
|
const sanitizedMembers = membersWithAvatarUrls.map((member) => {
|
||||||
// If user doesn't have a display name, use anonymous identifier
|
// If user doesn't have a display name, use anonymous identifier
|
||||||
const displayName =
|
const displayName =
|
||||||
member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
|
member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
|
||||||
@@ -120,7 +139,10 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return {
|
||||||
|
...result,
|
||||||
|
members: membersWithAvatarUrls,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
bySlug: publicProcedure
|
bySlug: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -138,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
workspaceSlug: z
|
workspaceSlug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(24)
|
.max(64)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -185,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
slug: z
|
slug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(24)
|
.max(64)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
@@ -268,7 +290,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
slug: z
|
slug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(24)
|
.max(64)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
description: z.string().min(3).max(280).optional(),
|
description: z.string().min(3).max(280).optional(),
|
||||||
@@ -402,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
workspaceSlug: z
|
workspaceSlug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(24)
|
.max(64)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
133
packages/api/src/utils/notifications.ts
Normal file
133
packages/api/src/utils/notifications.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { env } from "next-runtime-env";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
|
import * as notificationRepo from "@kan/db/repository/notification.repo";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { sendEmail } from "@kan/email";
|
||||||
|
import { parseMentionsFromHTML } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends mention notification emails to mentioned members
|
||||||
|
* Only sends emails for new mentions (checks notification table to avoid duplicates)
|
||||||
|
*/
|
||||||
|
export async function sendMentionEmails({
|
||||||
|
db,
|
||||||
|
cardPublicId,
|
||||||
|
commentHtml,
|
||||||
|
commenterUserId,
|
||||||
|
commentId,
|
||||||
|
}: {
|
||||||
|
db: dbClient;
|
||||||
|
cardPublicId: string;
|
||||||
|
commentHtml: string;
|
||||||
|
commenterUserId: string;
|
||||||
|
commentId?: number;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Parse mentions from HTML
|
||||||
|
const mentionPublicIds = parseMentionsFromHTML(commentHtml);
|
||||||
|
if (mentionPublicIds.length === 0) return;
|
||||||
|
|
||||||
|
// Get card with board information
|
||||||
|
const card = await cardRepo.getWithListAndMembersByPublicId(db, cardPublicId);
|
||||||
|
if (!card?.list.board) return;
|
||||||
|
|
||||||
|
const board = card.list.board;
|
||||||
|
const boardName = board.name;
|
||||||
|
const cardTitle = card.title;
|
||||||
|
const cardId = card.id;
|
||||||
|
|
||||||
|
// Get workspace ID from workspace publicId
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
db,
|
||||||
|
board.workspace.publicId,
|
||||||
|
);
|
||||||
|
if (!workspace?.id) return;
|
||||||
|
|
||||||
|
const workspaceId = workspace.id;
|
||||||
|
|
||||||
|
// Get commenter information
|
||||||
|
const commenter = await userRepo.getById(db, commenterUserId);
|
||||||
|
if (!commenter) return;
|
||||||
|
|
||||||
|
const commenterName = commenter.name?.trim() || commenter.email;
|
||||||
|
|
||||||
|
// Get mentioned members with full details (filtered by workspace)
|
||||||
|
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(
|
||||||
|
db,
|
||||||
|
mentionPublicIds,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter out the commenter
|
||||||
|
const membersToNotify = membersWithDetails.filter(
|
||||||
|
(member) => member.user?.id !== commenterUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (membersToNotify.length === 0) return;
|
||||||
|
|
||||||
|
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||||
|
const cardUrl = `${baseUrl}/cards/${cardPublicId}`;
|
||||||
|
|
||||||
|
// Send emails to all mentioned members (only if notification doesn't exist)
|
||||||
|
await Promise.all(
|
||||||
|
membersToNotify.map(async (member) => {
|
||||||
|
const userId = member.user?.id;
|
||||||
|
const email = member.user?.email ?? member.email;
|
||||||
|
|
||||||
|
// Skip pending members (no userId) - they can be mentioned but won't receive emails
|
||||||
|
if (!userId || !email) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if notification already exists for this mention
|
||||||
|
const notificationExists = await notificationRepo.exists(db, {
|
||||||
|
userId,
|
||||||
|
cardId,
|
||||||
|
type: "mention",
|
||||||
|
});
|
||||||
|
|
||||||
|
// If notification already exists, skip sending email
|
||||||
|
if (notificationExists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification record
|
||||||
|
await notificationRepo.create(db, {
|
||||||
|
type: "mention",
|
||||||
|
userId,
|
||||||
|
cardId,
|
||||||
|
commentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
await sendEmail(
|
||||||
|
email,
|
||||||
|
`${commenterName} mentioned you in a comment on ${cardTitle}`,
|
||||||
|
"MENTION",
|
||||||
|
{
|
||||||
|
commenterName,
|
||||||
|
boardName,
|
||||||
|
cardTitle,
|
||||||
|
cardUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send mention email:", {
|
||||||
|
email,
|
||||||
|
cardPublicId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending mention emails:", {
|
||||||
|
cardPublicId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,15 +11,14 @@ import { createPlugins } from "./plugins";
|
|||||||
import { configuredProviders } from "./providers";
|
import { configuredProviders } from "./providers";
|
||||||
|
|
||||||
export const initAuth = (db: dbClient) => {
|
export const initAuth = (db: dbClient) => {
|
||||||
|
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
|
||||||
|
const trustedOrigins =
|
||||||
|
env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",").filter(Boolean) ?? [];
|
||||||
|
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
secret: env("BETTER_AUTH_SECRET"),
|
secret: env("BETTER_AUTH_SECRET"),
|
||||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
baseURL,
|
||||||
trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS")
|
trustedOrigins: [...(baseURL ? [baseURL] : []), ...trustedOrigins],
|
||||||
? [
|
|
||||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
|
||||||
...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []),
|
|
||||||
]
|
|
||||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema: {
|
schema: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
||||||
import { createAuthMiddleware } from "better-auth/api";
|
import { createAuthMiddleware } from "better-auth/api";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
@@ -7,7 +7,7 @@ import type { dbClient } from "@kan/db/client";
|
|||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import { notificationClient } from "@kan/email";
|
import { notificationClient } from "@kan/email";
|
||||||
import { createEmailUnsubscribeLink } from "@kan/shared";
|
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
|
||||||
|
|
||||||
import { downloadImage } from "./utils";
|
import { downloadImage } from "./utils";
|
||||||
|
|
||||||
@@ -61,20 +61,7 @@ export function createDatabaseHooks(db: dbClient) {
|
|||||||
!user.image.includes(storageDomain)
|
!user.image.includes(storageDomain)
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const credentials =
|
const client = createS3Client();
|
||||||
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 allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { magicLink } from "better-auth/plugins/magic-link";
|
|||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
|
||||||
import { sendEmail } from "@kan/email";
|
import { sendEmail } from "@kan/email";
|
||||||
|
import { generateUID } from "@kan/shared/utils";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
import { socialProvidersPlugin } from "./providers";
|
import { socialProvidersPlugin } from "./providers";
|
||||||
@@ -133,20 +134,21 @@ export function createPlugins(db: dbClient) {
|
|||||||
|
|
||||||
if (workspace?.id) {
|
if (workspace?.id) {
|
||||||
await memberRepo.pauseAllMembers(db, workspace.id);
|
await memberRepo.pauseAllMembers(db, workspace.id);
|
||||||
|
|
||||||
// Reset slug to publicId, or generate a UID if publicId is taken
|
// Reset slug to publicId, or generate a UID if publicId is taken
|
||||||
let newSlug = workspace.publicId;
|
let newSlug = workspace.publicId;
|
||||||
|
|
||||||
if (workspace.slug !== workspace.publicId) {
|
if (workspace.slug !== workspace.publicId) {
|
||||||
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
const isPublicIdAvailable =
|
||||||
db,
|
await workspaceRepo.isWorkspaceSlugAvailable(
|
||||||
workspace.publicId,
|
db,
|
||||||
);
|
workspace.publicId,
|
||||||
|
);
|
||||||
if (!isPublicIdAvailable) {
|
if (!isPublicIdAvailable) {
|
||||||
newSlug = generateUID();
|
newSlug = generateUID();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await workspaceRepo.update(db, subscription.referenceId, {
|
await workspaceRepo.update(db, subscription.referenceId, {
|
||||||
plan: "free",
|
plan: "free",
|
||||||
slug: newSlug,
|
slug: newSlug,
|
||||||
@@ -171,18 +173,76 @@ export function createPlugins(db: dbClient) {
|
|||||||
magicLink({
|
magicLink({
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
sendMagicLink: async ({ email, url }) => {
|
sendMagicLink: async ({ email, url }) => {
|
||||||
if (url.includes("type=invite")) {
|
try {
|
||||||
await sendEmail(
|
const decodedUrl = decodeURIComponent(url);
|
||||||
email,
|
console.log("Sending magic link to:", email, "URL:", url);
|
||||||
"Invitation to join workspace",
|
console.log(
|
||||||
"JOIN_WORKSPACE",
|
"Magic link contains invite:",
|
||||||
{
|
decodedUrl.includes("type=invite"),
|
||||||
magicLoginUrl: url,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} else {
|
if (decodedUrl.includes("type=invite")) {
|
||||||
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
|
let inviterName = "";
|
||||||
magicLoginUrl: url,
|
let workspaceName = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const callbackUrl = urlObj.searchParams.get("callbackURL");
|
||||||
|
if (callbackUrl) {
|
||||||
|
const callbackParams = new URL(
|
||||||
|
callbackUrl,
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
|
).searchParams;
|
||||||
|
const memberPublicId = callbackParams.get("memberPublicId");
|
||||||
|
|
||||||
|
if (memberPublicId) {
|
||||||
|
const member = await memberRepo.getByPublicId(
|
||||||
|
db,
|
||||||
|
memberPublicId,
|
||||||
|
);
|
||||||
|
if (member) {
|
||||||
|
const [workspace, inviter] = await Promise.all([
|
||||||
|
workspaceRepo.getById(db, member.workspaceId),
|
||||||
|
userRepo.getById(db, member.createdBy),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (workspace) workspaceName = workspace.name;
|
||||||
|
if (inviter) inviterName = inviter.name ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch invite details:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmail(
|
||||||
|
email,
|
||||||
|
workspaceName
|
||||||
|
? `Invitation to join the workspace ${workspaceName}`
|
||||||
|
: "Invitation to join workspace",
|
||||||
|
"JOIN_WORKSPACE",
|
||||||
|
{
|
||||||
|
magicLoginUrl: url,
|
||||||
|
inviterName,
|
||||||
|
workspaceName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await sendEmail(
|
||||||
|
email,
|
||||||
|
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY === "true"
|
||||||
|
? "Sign in to your account"
|
||||||
|
: "Sign in to Kan",
|
||||||
|
"MAGIC_LINK",
|
||||||
|
{
|
||||||
|
magicLoginUrl: url,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending magic link:", {
|
||||||
|
email,
|
||||||
|
url,
|
||||||
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "showEmailsToMembers" boolean NOT NULL DEFAULT true;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ CREATE TABLE IF NOT EXISTS "workspace_roles" (
|
|||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
|
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
|
||||||
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
|
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
EXCEPTION
|
EXCEPTION
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "workspace_webhooks" (
|
||||||
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
|
"publicId" varchar(12) NOT NULL,
|
||||||
|
"workspaceId" bigint NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"url" varchar(2048) NOT NULL,
|
||||||
|
"secret" text,
|
||||||
|
"events" text NOT NULL,
|
||||||
|
"active" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdBy" uuid NOT NULL,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updatedAt" timestamp,
|
||||||
|
CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
CREATE TYPE "public"."notification_type" AS ENUM('mention', 'workspace.member.added', 'workspace.member.removed', 'workspace.role.changed');--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "notification" (
|
||||||
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
|
"publicId" varchar(12) NOT NULL,
|
||||||
|
"type" "notification_type" NOT NULL,
|
||||||
|
"userId" uuid NOT NULL,
|
||||||
|
"cardId" bigint,
|
||||||
|
"commentId" bigint,
|
||||||
|
"workspaceId" bigint,
|
||||||
|
"metadata" text,
|
||||||
|
"readAt" timestamp,
|
||||||
|
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"deletedAt" timestamp,
|
||||||
|
CONSTRAINT "notification_publicId_unique" UNIQUE("publicId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "notification_user_deleted_idx" ON "notification" USING btree ("userId","deletedAt");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "notification_user_read_deleted_idx" ON "notification" USING btree ("userId","readAt","deletedAt");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "notification_user_type_card_idx" ON "notification" USING btree ("userId","type","cardId");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "notification_user_type_workspace_idx" ON "notification" USING btree ("userId","type","workspaceId");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "notification_user_created_idx" ON "notification" USING btree ("userId","createdAt");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "card_activity" ADD COLUMN "attachmentId" bigint;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_attachmentId_card_attachment_id_fk" FOREIGN KEY ("attachmentId") REFERENCES "public"."card_attachment"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
3687
packages/db/migrations/meta/20260207214056_snapshot.json
Normal file
3687
packages/db/migrations/meta/20260207214056_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3430
packages/db/migrations/meta/20260208033314_snapshot.json
Normal file
3430
packages/db/migrations/meta/20260208033314_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,27 @@
|
|||||||
"when": 1769983198190,
|
"when": 1769983198190,
|
||||||
"tag": "20260201215958_AddUserBoardFavouritesTable",
|
"tag": "20260201215958_AddUserBoardFavouritesTable",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770500457005,
|
||||||
|
"tag": "20260207214056_AddNotificationsTable",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770521594167,
|
||||||
|
"tag": "20260208033314_AddAttachmentToActivity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771192000000,
|
||||||
|
"tag": "20260129210000_AddWorkspaceWebhooks",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -212,6 +212,7 @@ export const getByPublicId = async (
|
|||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
status: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user