Compare commits
47 Commits
fix/openap
...
fix/pt-BR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63741907d5 | ||
|
|
60161ac25c | ||
|
|
d084d323bc | ||
|
|
6729c2e228 | ||
|
|
b18ef10313 | ||
|
|
03bd2d771b | ||
|
|
ff7944a4a2 | ||
|
|
0ca9c1d0e6 | ||
|
|
927bf2fe69 | ||
|
|
ccd84385a2 | ||
|
|
58c5e92155 | ||
|
|
885f119404 | ||
|
|
b17a24455a | ||
|
|
0c9561467d | ||
|
|
47b6f06be4 | ||
|
|
89c8961176 | ||
|
|
a5432c60b7 | ||
|
|
2b8fe3d3a2 | ||
|
|
73c9b326a1 | ||
|
|
a55c8cd52b | ||
|
|
cca8a9d424 | ||
|
|
57186b6b4d | ||
|
|
3cb40d0f9a | ||
|
|
6ebab28606 | ||
|
|
5765c71ebf | ||
|
|
86ecdca7f1 | ||
|
|
bcd20b58c6 | ||
|
|
36675604f9 | ||
|
|
94760f1e4c | ||
|
|
38f477bb5a | ||
|
|
d208952d15 | ||
|
|
88f1aa0ec4 | ||
|
|
06ca6e38b3 | ||
|
|
915877b8e6 | ||
|
|
bb8f3de0ad | ||
|
|
d3c8cbd6c0 | ||
|
|
6c3dbaefd3 | ||
|
|
f938723d9a | ||
|
|
0928709173 | ||
|
|
ade9aad4bf | ||
|
|
5fbaf26707 | ||
|
|
b69835f7de | ||
|
|
58ff6ad041 | ||
|
|
adbc945ed6 | ||
|
|
c5d85cdb2d | ||
|
|
56e4c43cfa | ||
|
|
07fc41761b |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
# .git
|
||||
@@ -17,6 +17,7 @@ SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||
SMTP_SECURE= # set to "false" to use port 587
|
||||
SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
||||
|
||||
# Switch email features off entirely (optional)
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
@@ -36,12 +37,17 @@ NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
|
||||
# API configuration (optional)
|
||||
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
|
||||
|
||||
# Integration providers (optional)
|
||||
TRELLO_APP_API_KEY=
|
||||
TRELLO_APP_SECRET=
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||
BETTER_AUTH_ALLOWED_DOMAINS=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
DISCORD_CLIENT_ID=
|
||||
@@ -79,4 +85,3 @@ TWITCH_CLIENT_SECRET=
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_CLIENT_SECRET=
|
||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||
|
||||
|
||||
40
.github/workflows/docker-publish.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
@@ -74,6 +76,42 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
# Remove 'v' prefix if present
|
||||
VERSION="${VERSION#v}"
|
||||
else
|
||||
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
|
||||
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
|
||||
if [[ -n "$GIT_DESCRIBE" ]]; then
|
||||
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
|
||||
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
|
||||
# Format as tag+commit (SemVer build metadata)
|
||||
TAG_VERSION="${BASH_REMATCH[1]}"
|
||||
COMMIT_HASH="${BASH_REMATCH[2]}"
|
||||
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
|
||||
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||
# Exactly on a tag
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# Fallback: just commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
else
|
||||
# No tags exist, use commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
@@ -86,6 +124,8 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
1
.gitignore
vendored
@@ -34,6 +34,7 @@ yarn-error.log*
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
78
README.md
@@ -34,7 +34,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Screenshot 👁️
|
||||
|
||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
|
||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/8490104a-cd5d-49de-afc2-152fd8a93119" />
|
||||
|
||||
## Made With 🛠️
|
||||
|
||||
@@ -138,42 +138,46 @@ pnpm dev
|
||||
|
||||
## Environment Variables 🔐
|
||||
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTATCHMENTS_BUCKET_NAME` | S3 bucket name for attatchments | For file uploads | `attatchments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ ARG PROJECT=@kan/web
|
||||
|
||||
# 1. Alpine image
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
RUN apk update && \
|
||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true
|
||||
|
||||
# Setup pnpm and turbo on the alpine base
|
||||
FROM alpine AS base
|
||||
@@ -18,43 +19,45 @@ RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
||||
# RUN export VERSION=$(npm run version)
|
||||
|
||||
ARG PROJECT
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# It might be the path to <ROOT> turborepo
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
|
||||
# Generate version from git (fallback if APP_VERSION not provided)
|
||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
||||
sed 's/^v//' || \
|
||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
|
||||
# Environment to skip .env validation on build
|
||||
ENV CI=true
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile and package.json's of isolated subworkspace
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
ENV CI=true
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
|
||||
RUN pnpm build --filter=${PROJECT}
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
|
||||
@@ -15,6 +15,7 @@ checksums:
|
||||
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
Actions/singular: c46571856723b03262fd33f511116298
|
||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
@@ -26,6 +27,7 @@ checksums:
|
||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
|
||||
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
||||
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
||||
@@ -37,6 +39,7 @@ checksums:
|
||||
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
||||
@@ -91,6 +94,7 @@ checksums:
|
||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
|
||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
||||
changed%20the%20due%20date%20to%20%3C0%3E%7BformattedDate%7D%3C%2F0%3E/singular: 32189aa87452ab18f90f02742bfa9910
|
||||
Check%20out%20some%20of%20our%20favorite%20open%20source%20projects./singular: b0d0be4f63c16552b3aa795af29240d1
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
@@ -109,6 +113,7 @@ checksums:
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
||||
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
|
||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
@@ -119,6 +124,7 @@ checksums:
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
|
||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
@@ -167,20 +173,30 @@ checksums:
|
||||
Display%20name%20must%20be%20at%20least%203%20characters%20long/singular: 84178b79845a70937af5e26020403052
|
||||
Display%20name%20updated/singular: 4e27503ca24ef5f009713951ca9e5b97
|
||||
Do%20you%20offer%20a%20free%20plan%3F/singular: 3ef16b0994f0d9ae17067782ca9bed1b
|
||||
Do%20you%20want%20to%20unsubscribe%3F/singular: 8173b82c731ba885939d223eeef2e40b
|
||||
docs/singular: 307bff5678ada918da1f1ec3edc76cac
|
||||
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
|
||||
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
Download%20failed/singular: 142b391cca3e05f2af9617092f7358c5
|
||||
Due%20date/singular: b098b52aff38516c5838c2ac4419f958
|
||||
Due%20next%20month/singular: 223ab6f1211e1dfdf39ea68d25fe380b
|
||||
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
|
||||
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
|
||||
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
|
||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
||||
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
@@ -210,6 +226,7 @@ checksums:
|
||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
||||
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
||||
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
||||
@@ -229,6 +246,7 @@ checksums:
|
||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
General/singular: b891e8f15579fc5d97bcaf3637f5ae59
|
||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
||||
@@ -240,6 +258,10 @@ checksums:
|
||||
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
|
||||
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
||||
Go%20to%20boards/singular: dc1362de207d67fe84fb0caf60262e73
|
||||
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
||||
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
@@ -283,6 +305,7 @@ checksums:
|
||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||
Keyboard%20Shortcuts/singular: ef00d7494b69def6841620bd6554d040
|
||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||
@@ -299,6 +322,8 @@ checksums:
|
||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
||||
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
@@ -316,6 +341,7 @@ checksums:
|
||||
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
|
||||
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
Navigation/singular: 0373afd8238db1c49f4be4fa6cdf5cd3
|
||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
||||
@@ -336,18 +362,23 @@ checksums:
|
||||
No%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
|
||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||
No%20dates/singular: bd0ec82b3b1f4a5fa89d362b71f8141d
|
||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||
Open%20command%20menu/singular: cf00563ea1a994e7c36a761d56664acc
|
||||
Open%20keyboard%20shortcuts/singular: 638ae0999fd03d2c611064274859422e
|
||||
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
||||
Open%20Source%20Friends/singular: ffe3d75e5d03b8469c9dcb019dceb164
|
||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
@@ -367,6 +398,7 @@ checksums:
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||
@@ -388,9 +420,11 @@ checksums:
|
||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
|
||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
||||
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
|
||||
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
||||
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||
@@ -415,12 +449,15 @@ checksums:
|
||||
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Set%20due%20date/singular: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
set%20the%20due%20date/singular: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
|
||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
|
||||
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
@@ -467,6 +504,7 @@ checksums:
|
||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||
Title/singular: 344e64395eaff6822a57d18623853e1a
|
||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
||||
@@ -477,6 +515,7 @@ checksums:
|
||||
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
@@ -488,6 +527,7 @@ checksums:
|
||||
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
|
||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||
@@ -495,6 +535,7 @@ checksums:
|
||||
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
|
||||
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
|
||||
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
||||
Unable%20to%20update%20due%20date/singular: af20d7482cb639a8c8264c681a18abed
|
||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||
@@ -508,10 +549,12 @@ checksums:
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
||||
Unlimited%20workspaces/singular: b504d01997bf908af1d486d9942fed36
|
||||
Unsubscribe/singular: d1a0b07295687d1f08d4df1c411072dd
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||
updated%20the%20due%20date/singular: 7636c200d50fed58a4295ed1e9b5c1e0
|
||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
||||
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
||||
@@ -533,6 +576,7 @@ checksums:
|
||||
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
|
||||
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
|
||||
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
|
||||
We%20couldn't%20update%20your%20preferences.%20Please%20try%20again./singular: 69d1d2575f2e73b08ac622bde0a45b4d
|
||||
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
|
||||
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
|
||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||
@@ -565,6 +609,7 @@ checksums:
|
||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
|
||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
||||
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
||||
@@ -576,7 +621,9 @@ checksums:
|
||||
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
||||
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
||||
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
|
||||
Your%20unsubscribe%20link%20is%20missing%20a%20token.%20Please%20open%20the%20latest%20email%20and%20try%20again./singular: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
Your%20workspace%20description%20has%20been%20updated./singular: 98de6f65c567c6654cc0adce3c9d5656
|
||||
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
||||
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
||||
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
||||
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -26,9 +26,11 @@ const config = {
|
||||
],
|
||||
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
// temporarily ignore eslint errors during build until we fix all the errors sigh
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
|
||||
images: {
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
@@ -48,6 +50,11 @@ const config = {
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
pathname: '/avatars/**',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
@@ -75,18 +82,25 @@ const config = {
|
||||
return patterns;
|
||||
})(),
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
return config;
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.svg": {
|
||||
loaders: ["@svgr/webpack"],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
// instrumentationHook: true,
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
},
|
||||
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
||||
},
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"build": "pnpm with-env next build --turbo",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"dev": "pnpm with-env next dev --turbo",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --",
|
||||
"lingui:extract": "lingui extract",
|
||||
@@ -29,6 +31,7 @@
|
||||
"@lingui/conf": "^5.3.2",
|
||||
"@lingui/macro": "^5.3.2",
|
||||
"@lingui/react": "^5.3.2",
|
||||
"@novu/api": "^3.11.0",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
@@ -43,15 +46,13 @@
|
||||
"@trpc/next": "^11.0.0-rc.660",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.3.1",
|
||||
"next": "^15.3.4",
|
||||
"next-logger": "^5.0.1",
|
||||
"jose": "^6.1.2",
|
||||
"next": "15.5.9",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-js": "^1.254.0",
|
||||
"react": "catalog:react18",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
@@ -85,10 +86,10 @@
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "catalog:",
|
||||
"jiti": "^1.21.6",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -155,7 +155,7 @@ export default function Dashboard({
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ email: session?.user.email, image: session?.user.image }}
|
||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
|
||||
136
apps/web/src/components/DateSelector.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameDay,
|
||||
isToday,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subMonths,
|
||||
} from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface DateSelectorProps {
|
||||
selectedDate?: Date | null;
|
||||
onDateSelect?: (date: Date | undefined) => void;
|
||||
}
|
||||
|
||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||
});
|
||||
|
||||
const monthName = format(currentMonth, "MMMM");
|
||||
const year = format(currentMonth, "yyyy");
|
||||
|
||||
const dayHeaders = useMemo(() => {
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
||||
return eachDayOfInterval({
|
||||
start: weekStart,
|
||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||
}, []);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
||||
|
||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||
(date) => {
|
||||
const dateString = format(date, "yyyy-MM-dd");
|
||||
return {
|
||||
date: dateString,
|
||||
isToday: isToday(date),
|
||||
isSelected: selectedDate ? isSameDay(date, selectedDate) : false,
|
||||
isCurrentMonth: date >= monthStart && date <= monthEnd,
|
||||
dateObj: date,
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [currentMonth, selectedDate]);
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setCurrentMonth(subMonths(currentMonth, 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentMonth(addMonths(currentMonth, 1));
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// If clicking the same date that's already selected, unselect it
|
||||
if (selectedDate && isSameDay(date, selectedDate)) {
|
||||
onDateSelect?.(undefined);
|
||||
} else {
|
||||
onDateSelect?.(date);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[250px] p-4">
|
||||
<div className="flex items-center text-light-1000 dark:text-dark-1000">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreviousMonth}
|
||||
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
||||
>
|
||||
<span className="sr-only">Previous month</span>
|
||||
<HiChevronLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1 text-center text-sm font-semibold">
|
||||
{monthName} {year}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextMonth}
|
||||
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
||||
>
|
||||
<span className="sr-only">Next month</span>
|
||||
<HiChevronRight aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-7 text-center text-xs/6 text-light-950 dark:text-dark-950">
|
||||
{dayHeaders.map((day, index) => (
|
||||
<div key={index}>{day}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="isolate mt-2 grid grid-cols-7 text-sm">
|
||||
{days.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={(e) => handleDateClick(day.dateObj, e)}
|
||||
className={twMerge(
|
||||
"flex aspect-square items-center justify-center rounded-lg focus:z-10",
|
||||
day.isSelected
|
||||
? "bg-light-1000 hover:bg-light-1000 dark:bg-dark-1000 dark:hover:bg-dark-1000"
|
||||
: "bg-transparent hover:bg-light-200 dark:bg-transparent dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
<time
|
||||
dateTime={day.date}
|
||||
className={twMerge(
|
||||
"mx-auto flex size-7 items-center justify-center rounded-full text-light-900 dark:text-dark-900",
|
||||
day.isCurrentMonth
|
||||
? "text-light-900 dark:text-dark-900"
|
||||
: "text-light-700 dark:text-dark-600",
|
||||
day.isSelected && "text-light-50 dark:text-dark-50",
|
||||
)}
|
||||
>
|
||||
{day.date.split("-").pop()?.replace(/^0/, "")}
|
||||
</time>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateSelector;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Range as TiptapRange } from "@tiptap/core";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
@@ -44,6 +45,7 @@ import { Markdown } from "tiptap-markdown";
|
||||
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Avatar from "./Avatar";
|
||||
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -56,7 +58,7 @@ declare module "@tiptap/core" {
|
||||
export interface SlashCommandItem {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -431,12 +433,14 @@ export default function Editor({
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -484,10 +488,17 @@ export default function Editor({
|
||||
}),
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||
return all.filter(
|
||||
(u) =>
|
||||
u.label &&
|
||||
typeof u.label === "string" &&
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
},
|
||||
command: ({ editor, range, props }: any) => {
|
||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||
command: ({ editor, range, props }) => {
|
||||
const id = props.id ?? "";
|
||||
const label = props.label ?? "";
|
||||
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span> `;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
@@ -503,6 +514,7 @@ export default function Editor({
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||
@@ -559,6 +571,9 @@ export default function Editor({
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tiptap [data-youtube] {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
`}</style>
|
||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
|
||||
@@ -2,8 +2,10 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
|
||||
const Button: React.FC<{
|
||||
href: string;
|
||||
@@ -12,10 +14,20 @@ const Button: React.FC<{
|
||||
json: object;
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
|
||||
keyboardShortcut: KeyboardShortcut;
|
||||
}> = ({
|
||||
href,
|
||||
current,
|
||||
name,
|
||||
json,
|
||||
isCollapsed = false,
|
||||
keyboardShortcut,
|
||||
onCloseSideNav,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const isMobile = useIsMobile();
|
||||
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
@@ -35,17 +47,27 @@ const Button: React.FC<{
|
||||
onClick={handleClick}
|
||||
className={twMerge(
|
||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||
isCollapsed ? "md:justify-center" : "justify-between",
|
||||
current
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||
isCollapsed
|
||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
||||
: "gap-x-3",
|
||||
)}
|
||||
title={isCollapsed ? name : undefined}
|
||||
>
|
||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isCollapsed
|
||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
||||
: "gap-x-3",
|
||||
)}
|
||||
>
|
||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import boardsIconDark from "~/assets/boards-dark.json";
|
||||
import boardsIconLight from "~/assets/boards-light.json";
|
||||
import membersIconDark from "~/assets/members-dark.json";
|
||||
@@ -38,6 +39,7 @@ interface SideNavigationProps {
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
displayName?: string | null | undefined;
|
||||
email?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
}
|
||||
@@ -86,26 +88,59 @@ export default function SideNavigation({
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
const navigation = [
|
||||
const navigation: {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: object;
|
||||
keyboardShortcut: KeyboardShortcut;
|
||||
}[] = [
|
||||
{
|
||||
name: t`Boards`,
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "B" }],
|
||||
action: () => router.push("/boards"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to boards`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t`Templates`,
|
||||
href: "/templates",
|
||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "T" }],
|
||||
action: () => router.push("/templates"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to templates`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t`Members`,
|
||||
href: "/members",
|
||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "M" }],
|
||||
action: () => router.push("/members"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to members`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t`Settings`,
|
||||
href: "/settings",
|
||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "S" }],
|
||||
action: () => router.push("/settings"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to settings`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -163,6 +198,7 @@ export default function SideNavigation({
|
||||
json={item.icon}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
keyboardShortcut={item.keyboardShortcut}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -171,7 +207,8 @@ export default function SideNavigation({
|
||||
|
||||
<div className="space-y-2">
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
displayName={user.displayName ?? undefined}
|
||||
email={user.email ?? "Email not provided?"}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
51
apps/web/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
import type { Placement } from "tippy.js";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
placement?: Placement;
|
||||
delay?: number | [number, number];
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
children,
|
||||
content,
|
||||
placement = "bottom",
|
||||
delay = [500, 0],
|
||||
}: TooltipProps) {
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<Root | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
rootRef.current = root;
|
||||
root.render(content);
|
||||
|
||||
const instance = tippy(triggerRef.current, {
|
||||
content: container,
|
||||
placement,
|
||||
delay,
|
||||
interactive: false,
|
||||
theme: "tooltip",
|
||||
});
|
||||
|
||||
return () => {
|
||||
instance.destroy();
|
||||
rootRef.current?.unmount();
|
||||
};
|
||||
}, [content, placement, delay]);
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="inline-flex">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,15 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
displayName: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
isCollapsed?: boolean;
|
||||
@@ -24,6 +27,7 @@ interface UserMenuProps {
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
displayName,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
@@ -31,6 +35,7 @@ export default function UserMenu({
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { openLegend } = useKeyboardShortcuts();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -72,7 +77,7 @@ export default function UserMenu({
|
||||
) : (
|
||||
<Menu.Button
|
||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||
title={isCollapsed ? email : undefined}
|
||||
title={isCollapsed ? displayName ?? email : undefined}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
@@ -99,7 +104,7 @@ export default function UserMenu({
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
{displayName ?? email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
@@ -169,6 +174,19 @@ export default function UserMenu({
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
openLegend();
|
||||
}}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Shortcuts`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
@@ -210,6 +228,25 @@ export default function UserMenu({
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_APP_VERSION && (
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href={
|
||||
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
||||
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
||||
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
||||
>
|
||||
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import CommandPallette from "./CommandPallette";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export default function WorkspaceMenu({
|
||||
isCollapsed = false,
|
||||
@@ -18,17 +20,17 @@ export default function WorkspaceMenu({
|
||||
const { openModal } = useModal();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: {
|
||||
key: "k",
|
||||
modifiers: ["META"],
|
||||
},
|
||||
action: () => setIsOpen(true),
|
||||
description: t`Open command menu`,
|
||||
group: "GENERAL",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -84,15 +86,17 @@ export default function WorkspaceMenu({
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
<Button
|
||||
className={twMerge(
|
||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||
isCollapsed && "md:mb-2 md:h-9 md:w-9",
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Tooltip content={commandPaletteShortcutTooltipContent}>
|
||||
<Button
|
||||
className={twMerge(
|
||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||
isCollapsed && "md:mb-2 md:h-9 md:w-9",
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
189
apps/web/src/components/YouTubeEmbed/EditYouTubeModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||
|
||||
interface EditYouTubeFormInput {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EditYouTubeModalState {
|
||||
url: string;
|
||||
title: string;
|
||||
onSave: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
export function EditYouTubeModal() {
|
||||
const { closeModal, getModalState } = useModal();
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
|
||||
// Get initial values and callback from modal state
|
||||
const modalState = getModalState("EDIT_YOUTUBE") as
|
||||
| EditYouTubeModalState
|
||||
| undefined;
|
||||
const initialUrl = modalState?.url ?? "";
|
||||
const initialTitle = modalState?.title ?? "";
|
||||
const onSave = modalState?.onSave;
|
||||
|
||||
const { register, handleSubmit, watch, reset } =
|
||||
useForm<EditYouTubeFormInput>({
|
||||
defaultValues: {
|
||||
url: initialUrl,
|
||||
title: initialTitle,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when modal state changes (when modal opens with new values)
|
||||
useEffect(() => {
|
||||
if (modalState) {
|
||||
reset({
|
||||
url: modalState.url,
|
||||
title: modalState.title,
|
||||
});
|
||||
}
|
||||
}, [modalState, reset]);
|
||||
|
||||
const currentUrl = watch("url");
|
||||
|
||||
const onSubmit = async (values: EditYouTubeFormInput) => {
|
||||
// Validate URL
|
||||
if (!isYouTubeUrl(values.url)) {
|
||||
setUrlError(t`Please enter a valid YouTube URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlError(null);
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
// If title is empty and URL changed, fetch new title
|
||||
let finalTitle = values.title;
|
||||
if (!finalTitle.trim() && values.url !== initialUrl) {
|
||||
const metadata = await fetchYouTubeMetadata(values.url);
|
||||
finalTitle = metadata?.title ?? "YouTube Video";
|
||||
} else if (!finalTitle.trim()) {
|
||||
// Keep existing title if no new title provided and URL unchanged
|
||||
finalTitle = initialTitle || "YouTube Video";
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(values.url, finalTitle);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUrlError(t`Failed to fetch video information`);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-focus on title input (more useful for editing)
|
||||
useEffect(() => {
|
||||
const titleElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#youtube-title");
|
||||
if (titleElement) titleElement.focus();
|
||||
}, []);
|
||||
|
||||
// Validate URL on change
|
||||
useEffect(() => {
|
||||
if (currentUrl && !isYouTubeUrl(currentUrl)) {
|
||||
setUrlError(t`Please enter a valid YouTube URL`);
|
||||
} else {
|
||||
setUrlError(null);
|
||||
}
|
||||
}, [currentUrl]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="youtube-title"
|
||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Title`}
|
||||
</label>
|
||||
<Input
|
||||
id="youtube-title"
|
||||
placeholder={t`Enter a custom title`}
|
||||
{...register("title")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="youtube-url"
|
||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`YouTube URL`}
|
||||
</label>
|
||||
<Input
|
||||
id="youtube-url"
|
||||
placeholder={t`https://www.youtube.com/watch?v=...`}
|
||||
{...register("url", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{urlError && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{urlError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isValidating}
|
||||
disabled={!watch("url") || !!urlError}
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import YouTubeDropdown from "./YouTubeDropdown";
|
||||
|
||||
interface YouTubeCardProps {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
showEmbed?: boolean;
|
||||
onConvertToLink: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
const YouTubeCard = ({
|
||||
videoId,
|
||||
url,
|
||||
title,
|
||||
showEmbed = true,
|
||||
onConvertToLink,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: YouTubeCardProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
|
||||
<div className="flex items-center justify-between gap-6 p-0 px-6">
|
||||
<h3 className="truncate text-sm font-medium">{title}</h3>
|
||||
<div className="mt-3">
|
||||
<YouTubeDropdown
|
||||
url={url}
|
||||
title={title}
|
||||
onConvertToLink={onConvertToLink}
|
||||
onDelete={onDelete}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEmbed && videoId && (
|
||||
<div className="p-6 pt-1">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={title}
|
||||
className="aspect-video w-full rounded-lg"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeCard;
|
||||
63
apps/web/src/components/YouTubeEmbed/YouTubeDropdown.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiPencil,
|
||||
HiTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import Dropdown from "../Dropdown";
|
||||
|
||||
interface YouTubeDropdownProps {
|
||||
url: string;
|
||||
title: string;
|
||||
onConvertToLink: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
const YouTubeDropdown = ({
|
||||
url,
|
||||
title,
|
||||
onConvertToLink,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: YouTubeDropdownProps) => {
|
||||
const { openModal, setModalState } = useModal();
|
||||
|
||||
const handleEdit = () => {
|
||||
setModalState("EDIT_YOUTUBE", {
|
||||
url,
|
||||
title,
|
||||
onSave: onUpdate,
|
||||
});
|
||||
openModal("EDIT_YOUTUBE");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit`,
|
||||
action: handleEdit,
|
||||
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Convert to link`,
|
||||
action: onConvertToLink,
|
||||
icon: <HiLink className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Delete`,
|
||||
action: onDelete,
|
||||
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeDropdown;
|
||||
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||
import YouTubeNodeView from "./YouTubeNodeView";
|
||||
|
||||
export interface YouTubeOptions {
|
||||
inline: boolean;
|
||||
HTMLAttributes: Record<string, undefined>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
youTube: {
|
||||
setYouTubeEmbed: (options: {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
thumbnailUrl?: string;
|
||||
showEmbed?: boolean;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const YouTubeNode = Node.create<YouTubeOptions>({
|
||||
name: "youtube",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addOptions() {
|
||||
return {
|
||||
inline: false,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
videoId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-video-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.videoId) return {};
|
||||
return { "data-video-id": attributes.videoId as string };
|
||||
},
|
||||
},
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url as string };
|
||||
},
|
||||
},
|
||||
title: {
|
||||
default: "YouTube Video",
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes) => {
|
||||
return { "data-title": attributes.title as string };
|
||||
},
|
||||
},
|
||||
thumbnailUrl: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-thumbnail"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.thumbnailUrl) return {};
|
||||
return { "data-thumbnail": attributes.thumbnailUrl as string };
|
||||
},
|
||||
},
|
||||
showEmbed: {
|
||||
default: true,
|
||||
parseHTML: (element) => element.getAttribute("data-show-embed"),
|
||||
renderHTML: (attributes) => {
|
||||
return { "data-show-embed": attributes.showEmbed as string };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "div[data-youtube]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-youtube": "" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(YouTubeNodeView);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const nodeType = this.type;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("youtubePaste"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
// Check if the pasted text is a YouTube URL
|
||||
const youtubeRegex =
|
||||
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
|
||||
const match = youtubeRegex.exec(text);
|
||||
|
||||
if (!match || !isYouTubeUrl(text)) return false;
|
||||
|
||||
const videoId = extractVideoId(text);
|
||||
if (!videoId) return false;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
const { tr } = state;
|
||||
|
||||
const node = nodeType.create({
|
||||
videoId,
|
||||
url: text,
|
||||
title: "Loading...",
|
||||
showEmbed: true,
|
||||
});
|
||||
|
||||
tr.replaceSelectionWith(node);
|
||||
dispatch(tr);
|
||||
|
||||
// Fetch metadata asynchronously and update the node
|
||||
void fetchYouTubeMetadata(text).then((metadata) => {
|
||||
const { state: newState, dispatch: newDispatch } = view;
|
||||
const { tr: newTr } = newState;
|
||||
|
||||
newState.doc.descendants((n, pos) => {
|
||||
if (
|
||||
n.type.name === "youtube" &&
|
||||
n.attrs.videoId === videoId &&
|
||||
n.attrs.title === "Loading..."
|
||||
) {
|
||||
newTr.setNodeMarkup(pos, undefined, {
|
||||
...n.attrs,
|
||||
title: metadata?.title ?? "YouTube Video",
|
||||
thumbnailUrl: metadata?.thumbnail_url ?? null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newTr.docChanged) {
|
||||
newDispatch(newTr);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
// commands for the YouTube embed node
|
||||
addCommands() {
|
||||
return {
|
||||
setYouTubeEmbed:
|
||||
(options: {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
showEmbed?: boolean;
|
||||
}) =>
|
||||
({ editor }) => {
|
||||
return editor.commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
videoId: options.videoId,
|
||||
url: options.url,
|
||||
title: options.title ?? "YouTube Video",
|
||||
thumbnailUrl: options.thumbnailUrl,
|
||||
showEmbed: options.showEmbed ?? true,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
82
apps/web/src/components/YouTubeEmbed/YouTubeNodeView.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
|
||||
import { extractVideoId } from "./utils";
|
||||
import YouTubeCard from "./YouTubeCard";
|
||||
|
||||
export default function YouTubeNodeView({
|
||||
node,
|
||||
editor,
|
||||
getPos,
|
||||
deleteNode,
|
||||
updateAttributes,
|
||||
}: NodeViewProps) {
|
||||
const { videoId, title, url, showEmbed } = node.attrs as {
|
||||
videoId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
showEmbed: boolean;
|
||||
};
|
||||
|
||||
const handleConvertToLink = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== "number") return;
|
||||
|
||||
const linkContent = {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: url,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: url,
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
||||
.insertContentAt(pos, linkContent)
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteNode();
|
||||
};
|
||||
|
||||
const handleUpdate = (newUrl: string, newTitle: string) => {
|
||||
if (newUrl !== url) {
|
||||
const newVideoId = extractVideoId(newUrl);
|
||||
updateAttributes({
|
||||
url: newUrl,
|
||||
title: newTitle,
|
||||
videoId: newVideoId,
|
||||
});
|
||||
} else {
|
||||
updateAttributes({ title: newTitle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<YouTubeCard
|
||||
videoId={videoId}
|
||||
url={url}
|
||||
title={title}
|
||||
showEmbed={showEmbed}
|
||||
onConvertToLink={handleConvertToLink}
|
||||
onDelete={handleDelete}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/components/YouTubeEmbed/utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export function extractVideoId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
url = url.trim();
|
||||
|
||||
// youtube.com/watch?v=VIDEO_ID
|
||||
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (watchMatch) return watchMatch[1] ?? null;
|
||||
|
||||
// youtu.be/VIDEO_ID
|
||||
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (shortMatch) return shortMatch[1] ?? null;
|
||||
|
||||
// youtube.com/embed/VIDEO_ID
|
||||
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (embedMatch) return embedMatch[1] ?? null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid YouTube link
|
||||
*/
|
||||
export function isYouTubeUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
const videoId = extractVideoId(url);
|
||||
return videoId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch YouTube video metadata using oEmbed API
|
||||
* Returns title, author, thumbnail URL, etc.
|
||||
* No API key required
|
||||
*/
|
||||
export async function fetchYouTubeMetadata(url: string): Promise<{
|
||||
title: string;
|
||||
author_name: string;
|
||||
thumbnail_url: string;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||
const response = await fetch(oembedUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
title: string;
|
||||
author_name: string;
|
||||
thumbnail_url: string;
|
||||
};
|
||||
return {
|
||||
title: data.title || "YouTube Video",
|
||||
author_name: data.author_name || "",
|
||||
thumbnail_url: data.thumbnail_url || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch YouTube metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const env = createEnv({
|
||||
* This way you can ensure the app isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
KAN_ADMIN_API_KEY: z.string().optional(),
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||
.string()
|
||||
@@ -51,6 +52,8 @@ export const env = createEnv({
|
||||
VK_CLIENT_SECRET: z.string().optional(),
|
||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||
NOVU_API_KEY: z.string().optional(),
|
||||
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
||||
// Generic OIDC Provider
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -87,11 +90,12 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
@@ -129,6 +133,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||
|
||||
101
apps/web/src/hooks/useDragToScroll.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UseDragToScrollOptions {
|
||||
/**
|
||||
* Whether drag-to-scroll is enabled
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* The direction to scroll
|
||||
*/
|
||||
direction?: "horizontal" | "vertical" | "both";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable drag-to-scroll functionality on a scrollable element
|
||||
* @param options Configuration options
|
||||
* @returns Ref to attach to the scrollable element and mouse event handlers
|
||||
*/
|
||||
export function useDragToScroll({
|
||||
enabled = true,
|
||||
direction = "horizontal",
|
||||
}: UseDragToScrollOptions = {}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startPosRef = useRef({ x: 0, y: 0 });
|
||||
const scrollStartRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const deltaX = e.clientX - startPosRef.current.x;
|
||||
const deltaY = e.clientY - startPosRef.current.y;
|
||||
|
||||
if (direction === "horizontal" || direction === "both") {
|
||||
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
|
||||
}
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "grabbing";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [enabled, isDragging, direction]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const container = scrollRef.current;
|
||||
|
||||
// Check if the click is on an interactive or draggable element
|
||||
// We need to be careful not to interfere with react-beautiful-dnd dragging
|
||||
const isInteractiveElement =
|
||||
target.closest("a") ||
|
||||
target.closest("button") ||
|
||||
target.closest("input") ||
|
||||
target.closest("textarea") ||
|
||||
target.closest("[role='button']") ||
|
||||
target.closest("[draggable='true']") ||
|
||||
target.closest(".react-beautiful-dnd-drag-handle") ||
|
||||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
|
||||
target.closest("[data-rbd-draggable-id]");
|
||||
|
||||
// Don't start dragging if clicking on interactive elements
|
||||
if (isInteractiveElement) return;
|
||||
|
||||
// Enable drag-to-scroll for any non-interactive element within the container
|
||||
if (container.contains(target)) {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
scrollStartRef.current = {
|
||||
x: container.scrollLeft,
|
||||
y: container.scrollTop,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ref: scrollRef,
|
||||
onMouseDown: handleMouseDown,
|
||||
isDragging,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { useLinguiContext } from "~/providers/lingui";
|
||||
@@ -8,6 +10,20 @@ export function useLocalisation() {
|
||||
const { i18n } = useLingui();
|
||||
const { locale, setLocale, availableLocales } = useLinguiContext();
|
||||
|
||||
const dateLocaleMap: Partial<Record<Locale, DateFnsLocale>> = {
|
||||
en: enGB,
|
||||
fr,
|
||||
de,
|
||||
es,
|
||||
it,
|
||||
nl,
|
||||
ru,
|
||||
pl,
|
||||
"pt-BR": ptBR,
|
||||
};
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||
|
||||
const handleSetLocale = async (newLocale: Locale) => {
|
||||
await activateLocale(newLocale);
|
||||
setLocale(newLocale);
|
||||
@@ -15,9 +31,9 @@ export function useLocalisation() {
|
||||
|
||||
return {
|
||||
locale,
|
||||
dateLocale: currentDateLocale,
|
||||
setLocale: handleSetLocale,
|
||||
availableLocales,
|
||||
formatDate: i18n.date,
|
||||
formatNumber: i18n.number,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await require("pino");
|
||||
await require("next-logger");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const locales = [
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"pt-BR"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -22,4 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
"pt-BR": "Português",
|
||||
};
|
||||
|
||||
2778
apps/web/src/locales/pt-BR/messages.po
Normal file
1
apps/web/src/locales/pt-BR/messages.ts
Normal file
@@ -12,6 +12,7 @@ import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||
import { ModalProvider } from "~/providers/modal";
|
||||
import { PopupProvider } from "~/providers/popup";
|
||||
@@ -80,21 +81,23 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
)}
|
||||
<script src="/__ENV.js" />
|
||||
<main className="font-sans">
|
||||
<LinguiProviderWrapper>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
getLayout(<Component {...pageProps} />)
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</LinguiProviderWrapper>
|
||||
<KeyboardShortcutProvider>
|
||||
<LinguiProviderWrapper>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</PostHogProvider>
|
||||
) : (
|
||||
getLayout(<Component {...pageProps} />)
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</LinguiProviderWrapper>
|
||||
</KeyboardShortcutProvider>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,12 +17,11 @@ export default async function handler(
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadUrl = decodeURIComponent(url);
|
||||
const downloadFilename =
|
||||
(typeof filename === "string" ? decodeURIComponent(filename) : null) ??
|
||||
"attachment";
|
||||
const downloadFilename = typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(downloadUrl);
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res.status(upstream.status).json({
|
||||
@@ -36,7 +35,7 @@ export default async function handler(
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"`,
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
|
||||
@@ -88,6 +88,7 @@ export default async function handler(
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_collection: "always",
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||
|
||||
104
apps/web/src/pages/api/unsubscribe.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Novu } from "@novu/api";
|
||||
import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const requestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
const tokenPayloadSchema = z.object({
|
||||
subscriberId: z.string(),
|
||||
});
|
||||
|
||||
type ResponseData =
|
||||
| { success: true }
|
||||
| { success: false; error: string; code?: string };
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>,
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Novu preferences", error);
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
@@ -43,18 +43,22 @@ export default async function handler(
|
||||
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: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
credentials,
|
||||
});
|
||||
|
||||
const signedUrl = await getSignedUrl(
|
||||
// @ts-ignore
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
|
||||
110
apps/web/src/pages/unsubscribe/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
type UnsubscribeStatus = "idle" | "processing" | "success" | "error";
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState("");
|
||||
const [status, setStatus] = useState<UnsubscribeStatus>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const value = router.query.token;
|
||||
if (typeof value === "string") {
|
||||
setToken(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
setToken(value[0] ?? "");
|
||||
} else {
|
||||
setToken("");
|
||||
}
|
||||
}, [router.isReady, router.query.token]);
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
t`Your unsubscribe link is missing a token. Please open the latest email and try again.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("processing");
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
throw new Error(
|
||||
payload?.error ??
|
||||
"We couldn't update your preferences. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
setStatus("success");
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
t`We couldn't update your preferences. Please try again.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const title = t`Unsubscribe`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${title} | kan.bn`} />
|
||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 w-full max-w-md space-y-6">
|
||||
<div>
|
||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{t`Do you want to unsubscribe?`}
|
||||
</h1>
|
||||
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
|
||||
{t`Confirm your email preferences:`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleUnsubscribe}
|
||||
disabled={status === "success"}
|
||||
isLoading={status === "processing"}
|
||||
variant="primary"
|
||||
size="md"
|
||||
>
|
||||
{t`Unsubscribe`}
|
||||
</Button>
|
||||
</div>
|
||||
{status === "success" && (
|
||||
<p className="text-center text-sm text-light-900 dark:text-dark-800">
|
||||
{t`You have been unsubscribed!`}
|
||||
</p>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<p className="mx-auto max-w-[300px] text-center text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
592
apps/web/src/providers/keyboard-shortcuts.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { useEventListener } from "~/hooks/useEventListener";
|
||||
|
||||
const ModifierKey = {
|
||||
CONTROL: "CONTROL",
|
||||
META: "META",
|
||||
ALT: "ALT",
|
||||
SHIFT: "SHIFT",
|
||||
} as const;
|
||||
type ModifierKey = (typeof ModifierKey)[keyof typeof ModifierKey];
|
||||
|
||||
const ModifierKeyInfo: Record<
|
||||
ModifierKey,
|
||||
{
|
||||
macSymbol: string;
|
||||
winName: string;
|
||||
linuxName: string;
|
||||
}
|
||||
> = {
|
||||
CONTROL: { macSymbol: "⌃", winName: "Ctrl", linuxName: "Ctrl" },
|
||||
META: { macSymbol: "⌘", winName: "Win", linuxName: "Super" },
|
||||
ALT: { macSymbol: "⌥", winName: "Alt", linuxName: "Alt" },
|
||||
SHIFT: { macSymbol: "⇧", winName: "Shift", linuxName: "Shift" },
|
||||
};
|
||||
|
||||
const ShortcutGroup = {
|
||||
GENERAL: "GENERAL",
|
||||
NAVIGATION: "NAVIGATION",
|
||||
ACTIONS: "ACTIONS",
|
||||
} as const;
|
||||
type ShortcutGroup = (typeof ShortcutGroup)[keyof typeof ShortcutGroup];
|
||||
|
||||
const getShortcutGroupInfo = (): Record<ShortcutGroup, { label: string }> => ({
|
||||
GENERAL: { label: t`General` },
|
||||
NAVIGATION: { label: t`Navigation` },
|
||||
ACTIONS: { label: t`Actions` },
|
||||
});
|
||||
|
||||
interface KeyStroke {
|
||||
key: string;
|
||||
modifiers?: ModifierKey[];
|
||||
}
|
||||
|
||||
interface Press {
|
||||
type: "PRESS";
|
||||
stroke: KeyStroke;
|
||||
}
|
||||
|
||||
interface Sequence {
|
||||
type: "SEQUENCE";
|
||||
strokes: KeyStroke[];
|
||||
}
|
||||
|
||||
export type KeyboardShortcut = {
|
||||
action: () => void;
|
||||
description: string;
|
||||
group: ShortcutGroup;
|
||||
} & (Press | Sequence);
|
||||
|
||||
interface ShortcutTreeStepNode {
|
||||
type: "STEP";
|
||||
children: ShortcutTreeLevel;
|
||||
}
|
||||
interface ShortcutTreeActionNode {
|
||||
type: "ACTION";
|
||||
shortcut: KeyboardShortcut;
|
||||
}
|
||||
type ShortcutTreeNode = ShortcutTreeStepNode | ShortcutTreeActionNode;
|
||||
type ShortcutTreeLevel = Record<string, ShortcutTreeNode>;
|
||||
|
||||
const SEQUENCE_TIMEOUT_MS = 1000;
|
||||
|
||||
const ShortcutConflictCode = {
|
||||
ACTION_ON_SEQUENCE: "ACTION_ON_SEQUENCE",
|
||||
DUPLICATE_ACTION: "DUPLICATE_ACTION",
|
||||
SEQUENCE_ON_ACTION: "SEQUENCE_ON_ACTION",
|
||||
} as const;
|
||||
type ShortcutConflictCode = keyof typeof ShortcutConflictCode;
|
||||
|
||||
interface ShortcutConflictErrorOptions {
|
||||
code: ShortcutConflictCode;
|
||||
shortcut: KeyboardShortcut;
|
||||
conflictPath: string;
|
||||
existingShortcut?: KeyboardShortcut;
|
||||
}
|
||||
|
||||
class ShortcutConflictError extends Error {
|
||||
public readonly code: ShortcutConflictCode;
|
||||
public readonly shortcut: KeyboardShortcut;
|
||||
public readonly conflictPath: string;
|
||||
public readonly existingShortcut?: KeyboardShortcut;
|
||||
|
||||
constructor(options: ShortcutConflictErrorOptions) {
|
||||
super(ShortcutConflictError.formatMessage(options));
|
||||
this.name = "ShortcutConflictError";
|
||||
this.code = options.code;
|
||||
this.shortcut = options.shortcut;
|
||||
this.conflictPath = options.conflictPath;
|
||||
this.existingShortcut = options.existingShortcut;
|
||||
}
|
||||
|
||||
private static stringifyShortcut(shortcut: KeyboardShortcut): string {
|
||||
if (shortcut.type === "SEQUENCE") {
|
||||
return shortcut.strokes.map(serializeKeyStroke).join(" → ");
|
||||
}
|
||||
return serializeKeyStroke(shortcut.stroke);
|
||||
}
|
||||
|
||||
private static formatMessage(options: ShortcutConflictErrorOptions): string {
|
||||
const formatted = ShortcutConflictError.stringifyShortcut(options.shortcut);
|
||||
switch (options.code) {
|
||||
case ShortcutConflictCode.ACTION_ON_SEQUENCE:
|
||||
return `Cannot register "${formatted}": desired action conflicts with existing sequence at "${options.conflictPath}"`;
|
||||
case ShortcutConflictCode.DUPLICATE_ACTION:
|
||||
return `Cannot register "${formatted}": action already registered`;
|
||||
case ShortcutConflictCode.SEQUENCE_ON_ACTION:
|
||||
return `Cannot register "${formatted}": desired sequence conflicts with existing action at "${options.conflictPath}"`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyboardShortcutContextType {
|
||||
registerShortcut: (shortcut: KeyboardShortcut) => () => void;
|
||||
openLegend: () => void;
|
||||
openLegendKeys: ReactNode;
|
||||
}
|
||||
|
||||
const KeyboardShortcutContext = createContext<
|
||||
KeyboardShortcutContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function KeyboardShortcutProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const treeRootRef = useRef<ShortcutTreeLevel>({});
|
||||
const currentNodeRef = useRef<ShortcutTreeLevel>(treeRootRef.current);
|
||||
const shortcutsRef = useRef<Map<string, KeyboardShortcut>>(new Map());
|
||||
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
||||
|
||||
const openLegendShortcut: KeyboardShortcut = useMemo(
|
||||
() => ({
|
||||
type: "PRESS",
|
||||
stroke: {
|
||||
key: "/",
|
||||
modifiers: ["META"],
|
||||
},
|
||||
action: () => setIsLegendOpen(true),
|
||||
description: t`Open keyboard shortcuts`,
|
||||
group: ShortcutGroup.GENERAL,
|
||||
}),
|
||||
[setIsLegendOpen],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
if (isTypingInInput(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sequenceTimeoutRef.current) {
|
||||
clearTimeout(sequenceTimeoutRef.current);
|
||||
sequenceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const serializedKey = serializeEvent(event);
|
||||
const node = currentNodeRef.current[serializedKey];
|
||||
|
||||
if (!node) {
|
||||
currentNodeRef.current = treeRootRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case "STEP":
|
||||
event.preventDefault();
|
||||
currentNodeRef.current = node.children;
|
||||
sequenceTimeoutRef.current = setTimeout(() => {
|
||||
currentNodeRef.current = treeRootRef.current;
|
||||
}, SEQUENCE_TIMEOUT_MS);
|
||||
return;
|
||||
case "ACTION":
|
||||
event.preventDefault();
|
||||
node.shortcut.action();
|
||||
currentNodeRef.current = treeRootRef.current;
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const registerShortcut = useCallback(
|
||||
(shortcut: KeyboardShortcut): (() => void) => {
|
||||
const strokes =
|
||||
shortcut.type === "SEQUENCE" ? shortcut.strokes : [shortcut.stroke];
|
||||
|
||||
const path = strokes.map(serializeKeyStroke);
|
||||
const pathKey = path.join(" → ");
|
||||
|
||||
path.reduce((currentLevel, key, i) => {
|
||||
const isLast = i === path.length - 1;
|
||||
const existingNode = currentLevel[key];
|
||||
|
||||
if (env.NODE_ENV === "development" && existingNode) {
|
||||
const conflictPath = path.slice(0, i + 1).join(" → ");
|
||||
validateNoConflict(existingNode, isLast, shortcut, conflictPath);
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
currentLevel[key] = {
|
||||
type: "ACTION",
|
||||
shortcut,
|
||||
};
|
||||
return currentLevel;
|
||||
}
|
||||
|
||||
if (existingNode?.type === "STEP") {
|
||||
return existingNode.children;
|
||||
}
|
||||
|
||||
const newNode: ShortcutTreeStepNode = {
|
||||
type: "STEP",
|
||||
children: {},
|
||||
};
|
||||
currentLevel[key] = newNode;
|
||||
return newNode.children;
|
||||
}, treeRootRef.current);
|
||||
|
||||
shortcutsRef.current.set(pathKey, shortcut);
|
||||
|
||||
return () => {
|
||||
removePathAndPrune(treeRootRef.current, path);
|
||||
shortcutsRef.current.delete(pathKey);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Register built-in shortcut to open legend
|
||||
useEffect(() => {
|
||||
const cleanup = registerShortcut(openLegendShortcut);
|
||||
return cleanup;
|
||||
}, [registerShortcut, openLegendShortcut]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sequenceTimeoutRef.current) {
|
||||
clearTimeout(sequenceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shortcutsArray = Array.from(shortcutsRef.current.values());
|
||||
const groupedShortcuts = shortcutsArray.reduce<
|
||||
Partial<Record<ShortcutGroup, KeyboardShortcut[]>>
|
||||
>((acc, shortcut) => {
|
||||
const group = shortcut.group;
|
||||
acc[group] ??= [];
|
||||
acc[group].push(shortcut);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const openLegend = useCallback(() => {
|
||||
setIsLegendOpen(true);
|
||||
}, []);
|
||||
|
||||
const openLegendKeys = useMemo(
|
||||
() => <FormattedShortcut shortcut={openLegendShortcut} />,
|
||||
[openLegendShortcut],
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardShortcutContext.Provider
|
||||
value={{ registerShortcut, openLegend, openLegendKeys }}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Shortcut Legend */}
|
||||
<Dialog
|
||||
className="relative z-50"
|
||||
open={isLegendOpen}
|
||||
onClose={() => setIsLegendOpen(false)}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex min-h-full w-screen items-center justify-center overflow-y-auto p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative w-full max-w-sm transform overflow-hidden rounded-lg border border-light-600 bg-white shadow-3xl-light dark:border-dark-600 dark:bg-dark-100 dark:shadow-3xl-dark"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-light-300 px-6 py-4 dark:border-dark-300">
|
||||
<DialogTitle className="text-[14px] font-semibold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Keyboard Shortcuts`}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => setIsLegendOpen(false)}
|
||||
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiXMark className="h-5 w-5 text-neutral-700 dark:text-dark-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-6">
|
||||
{shortcutsArray.length === 0 ? (
|
||||
<p className="text-center text-sm text-neutral-600 dark:text-dark-600">
|
||||
{t`No keyboard shortcuts registered.`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.values(ShortcutGroup).map((group, idx) => {
|
||||
const shortcuts = groupedShortcuts[group];
|
||||
if (!shortcuts?.length) return null;
|
||||
const groupInfo = getShortcutGroupInfo();
|
||||
return (
|
||||
<div key={`${group}-${idx}`}>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-light-1000 dark:text-dark-1000">
|
||||
{groupInfo[group].label}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<ShortcutListItem
|
||||
key={shortcut.description}
|
||||
shortcut={shortcut}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</KeyboardShortcutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register a keyboard shortcut. Cleanup is handled automatically.
|
||||
* Returns both formatted keys and pre-built tooltip content.
|
||||
*
|
||||
* IMPORTANT: The shortcut object reference must be stable. If it changes
|
||||
* on every render, the shortcut will be continuously registered and unregistered.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { keys, tooltipContent } = useKeyboardShortcut({
|
||||
* type: "PRESS",
|
||||
* stroke: { key: "k", modifiers: ["META"] },
|
||||
* action: () => openCommandPalette(),
|
||||
* description: "Open command palette",
|
||||
* group: "GENERAL"
|
||||
* });
|
||||
*
|
||||
* // Use tooltip for Tooltip component
|
||||
* <Tooltip content={tooltipContent}>
|
||||
* <button>Search</button>
|
||||
* </Tooltip>
|
||||
*
|
||||
* // Or use keys directly for custom formatting
|
||||
* <span>Press {keys} to search</span>
|
||||
* ```
|
||||
*/
|
||||
export function useKeyboardShortcuts(): KeyboardShortcutContextType {
|
||||
const context = useContext(KeyboardShortcutContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useKeyboardShortcuts must be used within KeyboardShortcutProvider",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcut(shortcut: KeyboardShortcut): {
|
||||
keys: ReactNode;
|
||||
tooltipContent: ReactNode;
|
||||
} {
|
||||
const context = useContext(KeyboardShortcutContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useKeyboardShortcut must be used within KeyboardShortcutProvider",
|
||||
);
|
||||
}
|
||||
|
||||
const { registerShortcut } = context;
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = registerShortcut(shortcut);
|
||||
return cleanup;
|
||||
}, [shortcut, registerShortcut]);
|
||||
|
||||
const keys = <FormattedShortcut shortcut={shortcut} />;
|
||||
const tooltipContent = (
|
||||
<div className="flex flex-row items-center gap-2 text-[11px]">
|
||||
{shortcut.description} {keys}
|
||||
</div>
|
||||
);
|
||||
|
||||
return { keys, tooltipContent };
|
||||
}
|
||||
|
||||
function ShortcutListItem({ shortcut }: { shortcut: KeyboardShortcut }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-dark-50 dark:text-dark-900">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<FormattedShortcut shortcut={shortcut} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
||||
const kbdClassName =
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-center text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950";
|
||||
|
||||
const stringifyModifier = (modifier: ModifierKey): string => {
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
|
||||
const isLinux =
|
||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
|
||||
|
||||
const info = ModifierKeyInfo[modifier];
|
||||
if (isMac) return info.macSymbol;
|
||||
if (isLinux) return info.linuxName;
|
||||
return info.winName;
|
||||
};
|
||||
|
||||
const formatStroke = (stroke: KeyStroke): ReactNode[] => {
|
||||
const parts: ReactNode[] = [];
|
||||
const modifierStrings = stroke.modifiers
|
||||
? stroke.modifiers.map(stringifyModifier)
|
||||
: [];
|
||||
|
||||
modifierStrings.forEach((mod) => {
|
||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
||||
});
|
||||
|
||||
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
if (shortcut.type === "SEQUENCE") {
|
||||
const parts: ReactNode[] = [];
|
||||
shortcut.strokes.forEach((stroke) => {
|
||||
parts.push(...formatStroke(stroke));
|
||||
});
|
||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-1 text-[11px]">
|
||||
{formatStroke(shortcut.stroke)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Checks for conflicts given existing nodes and current path
|
||||
* Throws an error if conflict is found
|
||||
*
|
||||
*/
|
||||
function validateNoConflict(
|
||||
existingNode: ShortcutTreeNode,
|
||||
isLastKey: boolean,
|
||||
shortcut: KeyboardShortcut,
|
||||
conflictPath: string,
|
||||
): void {
|
||||
if (isLastKey) {
|
||||
if (existingNode.type === "STEP") {
|
||||
throw new ShortcutConflictError({
|
||||
code: ShortcutConflictCode.ACTION_ON_SEQUENCE,
|
||||
shortcut,
|
||||
conflictPath,
|
||||
});
|
||||
} else {
|
||||
throw new ShortcutConflictError({
|
||||
code: ShortcutConflictCode.DUPLICATE_ACTION,
|
||||
shortcut,
|
||||
conflictPath,
|
||||
existingShortcut: existingNode.shortcut,
|
||||
});
|
||||
}
|
||||
} else if (existingNode.type === "ACTION") {
|
||||
throw new ShortcutConflictError({
|
||||
code: ShortcutConflictCode.SEQUENCE_ON_ACTION,
|
||||
shortcut,
|
||||
conflictPath,
|
||||
existingShortcut: existingNode.shortcut,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is currently typing in an input field
|
||||
*/
|
||||
function isTypingInInput(event: KeyboardEvent): boolean {
|
||||
if (!event.target) return false;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
const isInput = tagName === "input" || tagName === "textarea";
|
||||
const isContentEditable = target.isContentEditable;
|
||||
|
||||
return isInput || isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a KeyStroke to a consistent string format
|
||||
* Returns lowercase string like "ctrl+shift+k" with alphabetically sorted modifiers
|
||||
*/
|
||||
function serializeKeyStroke(stroke: KeyStroke): string {
|
||||
const key = stroke.key.toLowerCase();
|
||||
if (!stroke.modifiers || stroke.modifiers.length === 0) return key;
|
||||
const sorted = [...stroke.modifiers].sort();
|
||||
return `${sorted.join("+")}+${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a keyboard event to a KeyStroke
|
||||
*/
|
||||
function eventToKeyStroke(event: KeyboardEvent): KeyStroke {
|
||||
const modifiers: ModifierKey[] = [];
|
||||
if (event.altKey) modifiers.push(ModifierKey.ALT);
|
||||
if (event.ctrlKey) modifiers.push(ModifierKey.CONTROL);
|
||||
if (event.metaKey) modifiers.push(ModifierKey.META);
|
||||
// Only include shift if the key is a letter (shift wasn't consumed to produce the character)
|
||||
if (event.shiftKey && /^[a-zA-Z]$/.test(event.key))
|
||||
modifiers.push(ModifierKey.SHIFT);
|
||||
return { key: event.key, modifiers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a keyboard event to a consistent string format
|
||||
*/
|
||||
function serializeEvent(event: KeyboardEvent): string {
|
||||
return serializeKeyStroke(eventToKeyStroke(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a path from the tree and prunes empty branches
|
||||
*/
|
||||
function removePathAndPrune(tree: ShortcutTreeLevel, path: string[]): void {
|
||||
const [first, ...rest] = path;
|
||||
if (!first) return;
|
||||
|
||||
if (rest.length === 0) {
|
||||
delete tree[first];
|
||||
return;
|
||||
}
|
||||
|
||||
const node = tree[first];
|
||||
if (!node || node.type !== "STEP") return;
|
||||
|
||||
removePathAndPrune(node.children, rest);
|
||||
|
||||
if (Object.keys(node.children).length === 0) {
|
||||
delete tree[first];
|
||||
}
|
||||
}
|
||||
@@ -39,3 +39,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Tippy.js tooltip theme */
|
||||
.tippy-box[data-theme~="tooltip"] {
|
||||
@apply rounded-md border border-light-600 bg-white px-2 py-1 text-sm text-neutral-900 shadow-lg dark:border-dark-600 dark:bg-dark-100 dark:text-dark-1000;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"] > .tippy-arrow::before {
|
||||
@apply border-t-light-600 dark:border-t-dark-600;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"][data-placement^="bottom"]
|
||||
> .tippy-arrow::before {
|
||||
@apply border-b-light-600 dark:border-b-dark-600;
|
||||
}
|
||||
|
||||
16
apps/web/src/utils/cardInvalidation.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { api } from "~/utils/api";
|
||||
|
||||
/**
|
||||
* Invalidates all card-related queries for a given card.
|
||||
* Use this after any mutation that affects card data or activities.
|
||||
*/
|
||||
export async function invalidateCard(
|
||||
utils: ReturnType<typeof api.useUtils>,
|
||||
cardPublicId: string,
|
||||
) {
|
||||
await Promise.all([
|
||||
utils.card.byId.invalidate({ cardPublicId }),
|
||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
||||
]);
|
||||
}
|
||||
|
||||
69
apps/web/src/utils/helpers.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next-runtime-env", () => ({
|
||||
env: vi.fn(),
|
||||
}));
|
||||
|
||||
import { env } from "next-runtime-env";
|
||||
import { getAvatarUrl } from "./helpers";
|
||||
|
||||
const mockEnv = env as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("getAvatarUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty string for null input", () => {
|
||||
expect(getAvatarUrl(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty string input", () => {
|
||||
expect(getAvatarUrl("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns URL unchanged if already absolute http", () => {
|
||||
expect(getAvatarUrl("http://example.com/avatar.jpg")).toBe(
|
||||
"http://example.com/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns URL unchanged if already absolute https", () => {
|
||||
expect(getAvatarUrl("https://example.com/avatar.jpg")).toBe(
|
||||
"https://example.com/avatar.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
describe("path-style URLs (MinIO/LocalStack)", () => {
|
||||
it("constructs path-style URL when STORAGE_DOMAIN is not set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_STORAGE_URL: "http://s3.localtest.me:9000",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"http://s3.localtest.me:9000/kan/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual-hosted URLs (Tigris/AWS S3)", () => {
|
||||
it("constructs virtual-hosted URL when STORAGE_DOMAIN is set", () => {
|
||||
mockEnv.mockImplementation((key: string) => {
|
||||
const vars: Record<string, string> = {
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||
};
|
||||
return vars[key];
|
||||
});
|
||||
|
||||
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||
"https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,5 +52,13 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||
|
||||
if (storageDomain) {
|
||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ const loadMessages = async (locale: Locale) => {
|
||||
return (await import("~/locales/ru/messages")).messages;
|
||||
case "pl":
|
||||
return (await import("~/locales/pl/messages")).messages;
|
||||
case "pt-BR":
|
||||
return (await import("~/locales/pt-BR/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { format, isBefore, isSameYear, startOfDay } from "date-fns";
|
||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
|
||||
import {
|
||||
HiBars3BottomLeft,
|
||||
HiChatBubbleLeft,
|
||||
HiOutlineClock,
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import CircularProgress from "~/components/CircularProgress";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
const Card = ({
|
||||
@@ -15,6 +22,7 @@ const Card = ({
|
||||
description,
|
||||
comments,
|
||||
attachments,
|
||||
dueDate,
|
||||
}: {
|
||||
title: string;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
@@ -36,7 +44,11 @@ const Card = ({
|
||||
description: string | null;
|
||||
comments: { publicId: string }[];
|
||||
attachments?: { publicId: string }[];
|
||||
dueDate?: Date | null;
|
||||
}) => {
|
||||
const { dateLocale } = useLocalisation();
|
||||
const showYear = dueDate ? !isSameYear(dueDate, new Date()) : false;
|
||||
const isOverdue = dueDate ? isBefore(dueDate, startOfDay(new Date())) : false;
|
||||
const completedItems = checklists.reduce((acc, checklist) => {
|
||||
return acc + checklist.items.filter((item) => item.completed).length;
|
||||
}, 0);
|
||||
@@ -51,6 +63,7 @@ const Card = ({
|
||||
const hasDescription =
|
||||
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
const hasDueDate = !!dueDate;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
@@ -60,6 +73,7 @@ const Card = ({
|
||||
checklists.length > 0 ||
|
||||
hasDescription ||
|
||||
comments.length > 0 ||
|
||||
hasDueDate ||
|
||||
hasAttachments ? (
|
||||
<div className="mt-2 flex flex-col justify-end">
|
||||
<div className="space-x-0.5">
|
||||
@@ -77,6 +91,23 @@ const Card = ({
|
||||
<HiBars3BottomLeft className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
{hasDueDate && dueDate && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center gap-1",
|
||||
isOverdue
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-light-800 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
<HiOutlineClock className="h-4 w-4" />
|
||||
<span className="text-[11px]">
|
||||
{format(dueDate, showYear ? "do MMM yyyy" : "do MMM", {
|
||||
locale: dateLocale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{comments.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
|
||||
<HiChatBubbleLeft className="h-4 w-4" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiMiniXMark,
|
||||
HiOutlineClock,
|
||||
HiOutlineSquare3Stack3D,
|
||||
HiOutlineTag,
|
||||
HiOutlineUserCircle,
|
||||
@@ -60,7 +61,13 @@ const Filters = ({
|
||||
try {
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, members: [], labels: [], lists: [] },
|
||||
query: {
|
||||
...router.query,
|
||||
members: [],
|
||||
labels: [],
|
||||
lists: [],
|
||||
dueDate: [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -99,6 +106,39 @@ const Filters = ({
|
||||
selected: !!router.query.lists?.includes(list.publicId),
|
||||
}));
|
||||
|
||||
const dueDateItems = [
|
||||
{
|
||||
key: "overdue",
|
||||
value: t`Overdue`,
|
||||
selected: !!router.query.dueDate?.includes("overdue"),
|
||||
},
|
||||
{
|
||||
key: "today",
|
||||
value: t`Due today`,
|
||||
selected: !!router.query.dueDate?.includes("today"),
|
||||
},
|
||||
{
|
||||
key: "tomorrow",
|
||||
value: t`Due tomorrow`,
|
||||
selected: !!router.query.dueDate?.includes("tomorrow"),
|
||||
},
|
||||
{
|
||||
key: "next-week",
|
||||
value: t`Due next week`,
|
||||
selected: !!router.query.dueDate?.includes("next-week"),
|
||||
},
|
||||
{
|
||||
key: "next-month",
|
||||
value: t`Due next month`,
|
||||
selected: !!router.query.dueDate?.includes("next-month"),
|
||||
},
|
||||
{
|
||||
key: "no-due-date",
|
||||
value: t`No dates`,
|
||||
selected: !!router.query.dueDate?.includes("no-due-date"),
|
||||
},
|
||||
];
|
||||
|
||||
const groups = [
|
||||
...(formattedMembers.length
|
||||
? [
|
||||
@@ -126,6 +166,12 @@ const Filters = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "dueDate",
|
||||
label: t`Due date`,
|
||||
icon: <HiOutlineClock size={16} />,
|
||||
items: dueDateItems,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = async (
|
||||
@@ -156,6 +202,7 @@ const Filters = ({
|
||||
...formatToArray(router.query.members),
|
||||
...formatToArray(router.query.labels),
|
||||
...formatToArray(router.query.lists),
|
||||
...formatToArray(router.query.dueDate),
|
||||
].length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
HiOutlineBarsArrowDown,
|
||||
@@ -15,6 +16,7 @@ import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import DateSelector from "~/components/DateSelector";
|
||||
import Editor from "~/components/Editor";
|
||||
import Input from "~/components/Input";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
@@ -27,6 +29,7 @@ import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
type NewCardFormInput = NewCardInput & {
|
||||
isCreateAnotherEnabled: boolean;
|
||||
dueDate?: Date | null;
|
||||
};
|
||||
|
||||
interface QueryParams {
|
||||
@@ -65,6 +68,7 @@ export function NewCardForm({
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled: false,
|
||||
position: "start",
|
||||
dueDate: null,
|
||||
},
|
||||
resetOnClose: true,
|
||||
});
|
||||
@@ -80,6 +84,8 @@ export function NewCardForm({
|
||||
const position = watch("position");
|
||||
const title = watch("title");
|
||||
const description = watch("description");
|
||||
const dueDate = watch("dueDate");
|
||||
const [isDateSelectorOpen, setIsDateSelectorOpen] = useState(false);
|
||||
|
||||
// saving form state whenever form values change
|
||||
useEffect(() => {
|
||||
@@ -137,6 +143,7 @@ export function NewCardForm({
|
||||
title: args.title,
|
||||
listId: 2,
|
||||
description: "",
|
||||
dueDate: args.dueDate ?? null,
|
||||
labels: oldBoard.labels.filter((label) =>
|
||||
args.labelPublicIds.includes(label.publicId),
|
||||
),
|
||||
@@ -193,6 +200,7 @@ export function NewCardForm({
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
dueDate: null,
|
||||
};
|
||||
reset(newFormState);
|
||||
saveFormState(newFormState);
|
||||
@@ -242,7 +250,7 @@ export function NewCardForm({
|
||||
),
|
||||
})) ?? [];
|
||||
|
||||
const onSubmit = (data: NewCardInput) => {
|
||||
const onSubmit = (data: NewCardFormInput) => {
|
||||
createCard.mutate({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
@@ -250,6 +258,7 @@ export function NewCardForm({
|
||||
labelPublicIds: data.labelPublicIds,
|
||||
memberPublicIds: data.memberPublicIds,
|
||||
position: data.position,
|
||||
dueDate: data.dueDate ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -326,7 +335,7 @@ export function NewCardForm({
|
||||
saveFormState({ ...formState, description: value });
|
||||
}}
|
||||
workspaceMembers={
|
||||
boardData?.workspace.members?.map(
|
||||
boardData?.workspace.members.map(
|
||||
(member): WorkspaceMember => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
@@ -340,6 +349,7 @@ export function NewCardForm({
|
||||
}),
|
||||
) ?? []
|
||||
}
|
||||
enableYouTubeEmbed={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,6 +458,44 @@ export function NewCardForm({
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
<div className="relative w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDateSelectorOpen(!isDateSelectorOpen)}
|
||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500"
|
||||
>
|
||||
{dueDate ? (
|
||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||
) : (
|
||||
<>{t`Due date`}</>
|
||||
)}
|
||||
</button>
|
||||
{isDateSelectorOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsDateSelectorOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DateSelector
|
||||
selectedDate={dueDate ?? undefined}
|
||||
onDateSelect={(date) => {
|
||||
setValue("dueDate", date ?? null);
|
||||
setIsDateSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -23,6 +23,10 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -53,6 +57,20 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => boardId && openNewListForm(boardId),
|
||||
description: t`Create new list`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
|
||||
const boardId = params?.boardId
|
||||
? Array.isArray(params.boardId)
|
||||
@@ -76,18 +94,26 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
});
|
||||
};
|
||||
|
||||
const queryParams: {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
type: "regular" | "template";
|
||||
} = {
|
||||
const semanticFilters = formatToArray(router.query.dueDate) as (
|
||||
| "overdue"
|
||||
| "today"
|
||||
| "tomorrow"
|
||||
| "next-week"
|
||||
| "next-month"
|
||||
| "no-due-date"
|
||||
)[];
|
||||
|
||||
const boardType: "regular" | "template" = isTemplate ? "template" : "regular";
|
||||
|
||||
const queryParams = {
|
||||
boardPublicId: boardId ?? "",
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
lists: formatToArray(router.query.lists),
|
||||
type: isTemplate ? "template" : "regular",
|
||||
...(semanticFilters.length > 0 && {
|
||||
dueDateFilters: semanticFilters,
|
||||
}),
|
||||
type: boardType,
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -353,6 +379,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
sourceBoardName={boardData?.name ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -427,20 +460,22 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
className="-mr-0.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!boardData}
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
<Tooltip content={createListShortcutTooltipContent}>
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
className="-mr-0.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!boardData}
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<BoardDropdown
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
@@ -450,7 +485,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
@@ -554,6 +593,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
}
|
||||
comments={card.comments ?? []}
|
||||
attachments={card.attachments}
|
||||
dueDate={card.dueDate ?? null}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { BoardsList } from "./components/BoardsList";
|
||||
@@ -16,6 +18,15 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => openModal("NEW_BOARD"),
|
||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
@@ -39,16 +50,18 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{t`Import`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`New`}
|
||||
</Button>
|
||||
<Tooltip content={createModalShortcutTooltipContent}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`New`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||
import { format, formatDistanceToNow, isSameYear } from "date-fns";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineClock,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
@@ -14,25 +16,21 @@ import {
|
||||
HiOutlineUserPlus,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||
import type {
|
||||
GetCardActivitiesOutput,
|
||||
GetCardByIdOutput,
|
||||
} from "@kan/api/types";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
const dateLocaleMap = {
|
||||
en: enGB,
|
||||
fr: fr,
|
||||
de: de,
|
||||
es: es,
|
||||
it: it,
|
||||
nl: nl,
|
||||
} as const;
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
@@ -47,6 +45,9 @@ const getActivityText = ({
|
||||
isSelf,
|
||||
label,
|
||||
fromTitle,
|
||||
toDueDate,
|
||||
dateLocale,
|
||||
mergedLabels,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -56,7 +57,45 @@ const getActivityText = ({
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
fromTitle?: string | null;
|
||||
fromDueDate?: Date | null;
|
||||
toDueDate?: Date | null;
|
||||
dateLocale: DateFnsLocale;
|
||||
mergedLabels?: string[];
|
||||
}) => {
|
||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (
|
||||
type === "card.updated.label.added" &&
|
||||
mergedLabels &&
|
||||
mergedLabels.length > 1
|
||||
) {
|
||||
const labelList = mergedLabels.join(", ");
|
||||
return (
|
||||
<Trans>
|
||||
added {mergedLabels.length} labels:{" "}
|
||||
<TextHighlight>{labelList}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
type === "card.updated.label.removed" &&
|
||||
mergedLabels &&
|
||||
mergedLabels.length > 1
|
||||
) {
|
||||
const labelList = mergedLabels.join(", ");
|
||||
return (
|
||||
<Trans>
|
||||
removed {mergedLabels.length} labels:{" "}
|
||||
<TextHighlight>{labelList}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": t`created the card`,
|
||||
"card.updated.title": t`updated the title`,
|
||||
@@ -74,17 +113,14 @@ const getActivityText = ({
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
"card.updated.dueDate.added": t`set the due date`,
|
||||
"card.updated.dueDate.updated": t`updated the due date`,
|
||||
"card.updated.dueDate.removed": t`removed the due date`,
|
||||
} as const;
|
||||
|
||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
|
||||
|
||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (type === "card.updated.title" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
@@ -209,6 +245,38 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
toDueDate,
|
||||
showYear ? "do MMM yyyy" : "do MMM",
|
||||
{ locale: dateLocale },
|
||||
);
|
||||
return (
|
||||
<Trans>
|
||||
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.updated" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
toDueDate,
|
||||
showYear ? "do MMM yyyy" : "do MMM",
|
||||
{ locale: dateLocale },
|
||||
);
|
||||
return (
|
||||
<Trans>
|
||||
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.removed") {
|
||||
return <Trans>removed the due date</Trans>;
|
||||
}
|
||||
|
||||
return baseText;
|
||||
};
|
||||
|
||||
@@ -229,6 +297,9 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||
} as const;
|
||||
|
||||
const getActivityIcon = (
|
||||
@@ -246,36 +317,151 @@ const getActivityIcon = (
|
||||
return ACTIVITY_ICON_MAP[type] ?? null;
|
||||
};
|
||||
|
||||
const ACTIVITIES_PAGE_SIZE = 20;
|
||||
|
||||
const ActivityList = ({
|
||||
activities,
|
||||
cardPublicId,
|
||||
isLoading,
|
||||
isLoading: cardIsLoading,
|
||||
isAdmin,
|
||||
isViewOnly,
|
||||
}: {
|
||||
activities: NonNullable<GetCardByIdOutput>["activities"];
|
||||
cardPublicId: string;
|
||||
isLoading: boolean;
|
||||
isAdmin?: boolean;
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
const { locale } = useLocalisation();
|
||||
const { dateLocale, locale } = useLocalisation();
|
||||
const { data: sessionData } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const [allActivities, setAllActivities] = useState<
|
||||
GetCardActivitiesOutput["activities"]
|
||||
>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] || enGB;
|
||||
const isFullyExpandedRef = useRef(false);
|
||||
const lastDataUpdatedAtRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
data: firstPageData,
|
||||
isFetching: isFetchingFirst,
|
||||
dataUpdatedAt,
|
||||
} = api.card.getActivities.useQuery(
|
||||
{
|
||||
cardPublicId,
|
||||
limit: ACTIVITIES_PAGE_SIZE,
|
||||
},
|
||||
{
|
||||
enabled: !!cardPublicId,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstPageData && dataUpdatedAt !== lastDataUpdatedAtRef.current) {
|
||||
lastDataUpdatedAtRef.current = dataUpdatedAt;
|
||||
|
||||
if (isFullyExpandedRef.current && firstPageData.hasMore) {
|
||||
setAllActivities(firstPageData.activities);
|
||||
setHasMore(firstPageData.hasMore);
|
||||
|
||||
const fetchAllRemaining = async () => {
|
||||
let currentActivities = [...firstPageData.activities];
|
||||
let currentHasMore = firstPageData.hasMore;
|
||||
|
||||
while (currentHasMore) {
|
||||
const lastActivity =
|
||||
currentActivities[currentActivities.length - 1];
|
||||
if (!lastActivity) break;
|
||||
|
||||
const nextCursor = new Date(lastActivity.createdAt).toISOString();
|
||||
const nextPage = await utils.card.getActivities.fetch({
|
||||
cardPublicId,
|
||||
limit: ACTIVITIES_PAGE_SIZE,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setAllActivities(currentActivities);
|
||||
setHasMore(false);
|
||||
};
|
||||
|
||||
fetchAllRemaining();
|
||||
} else {
|
||||
setAllActivities(firstPageData.activities);
|
||||
setHasMore(firstPageData.hasMore);
|
||||
|
||||
if (!firstPageData.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [firstPageData, dataUpdatedAt, cardPublicId, utils.card.getActivities]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (isLoadingMore || !hasMore || allActivities.length === 0) return;
|
||||
|
||||
const lastActivity = allActivities[allActivities.length - 1];
|
||||
if (!lastActivity) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const nextCursor = new Date(lastActivity.createdAt).toISOString();
|
||||
const nextPage = await utils.card.getActivities.fetch({
|
||||
cardPublicId,
|
||||
limit: ACTIVITIES_PAGE_SIZE,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isFetching = isFetchingFirst || isLoadingMore;
|
||||
const isLoading =
|
||||
cardIsLoading || (isFetchingFirst && allActivities.length === 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
{activities.map((activity, index) => {
|
||||
{allActivities.map((activity, index) => {
|
||||
const activityText = getActivityText({
|
||||
type: activity.type,
|
||||
toTitle: activity.toTitle,
|
||||
fromList: activity.fromList?.name ?? null,
|
||||
toList: activity.toList?.name ?? null,
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
isSelf: activity.member?.user?.id === data?.user.id,
|
||||
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
fromTitle: activity.fromTitle ?? null,
|
||||
fromDueDate: activity.fromDueDate ?? null,
|
||||
toDueDate: activity.toDueDate ?? null,
|
||||
dateLocale: dateLocale,
|
||||
mergedLabels: (activity as any).mergedLabels,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
@@ -286,11 +472,12 @@ const ActivityList = ({
|
||||
cardPublicId={cardPublicId}
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
image={activity.user?.image ?? null}
|
||||
isLoading={isLoading}
|
||||
createdAt={activity.createdAt.toISOString()}
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === data?.user.id}
|
||||
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
isViewOnly={!!isViewOnly}
|
||||
/>
|
||||
@@ -308,6 +495,7 @@ const ActivityList = ({
|
||||
size="sm"
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
imageUrl={getAvatarUrl(activity.user?.image ?? null) || undefined}
|
||||
icon={getActivityIcon(
|
||||
activity.type,
|
||||
activity.fromList?.index,
|
||||
@@ -315,11 +503,9 @@ const ActivityList = ({
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{index !== activities.length - 1 &&
|
||||
activities[index + 1]?.type !==
|
||||
"card.updated.comment.added" && (
|
||||
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
|
||||
)}
|
||||
{index !== allActivities.length - 1 && (
|
||||
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
|
||||
@@ -330,13 +516,24 @@ const ActivityList = ({
|
||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||
{formatDistanceToNow(new Date(activity.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: currentDateLocale,
|
||||
locale: dateLocale,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isFetching}
|
||||
className="text-sm font-medium text-light-900 hover:text-light-1000 disabled:opacity-50 dark:text-dark-800 dark:hover:text-dark-1000"
|
||||
>
|
||||
{isFetching ? t`Loading...` : t`Load more activities`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface Attachment {
|
||||
publicId: string;
|
||||
@@ -81,7 +82,7 @@ export function AttachmentThumbnails({
|
||||
},
|
||||
onSettled: async () => {
|
||||
if (isReadOnly) return;
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
const { openModal } = useModal();
|
||||
@@ -20,7 +21,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
showPopup({
|
||||
header: t`Attachment uploaded`,
|
||||
message: t`Your file has been uploaded successfully.`,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { DraggableProvided } from "react-beautiful-dnd";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { RiDraggable } from "react-icons/ri";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface ChecklistItemRowProps {
|
||||
item: {
|
||||
@@ -14,13 +17,19 @@ interface ChecklistItemRowProps {
|
||||
completed: boolean;
|
||||
};
|
||||
cardPublicId: string;
|
||||
onCreateNewItem?: () => void;
|
||||
viewOnly?: boolean;
|
||||
dragHandleProps?: DraggableProvided["dragHandleProps"];
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export default function ChecklistItemRow({
|
||||
item,
|
||||
cardPublicId,
|
||||
onCreateNewItem,
|
||||
viewOnly = false,
|
||||
dragHandleProps,
|
||||
isDragging = false,
|
||||
}: ChecklistItemRowProps) {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -62,7 +71,7 @@ export default function ChecklistItemRow({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,7 +99,7 @@ export default function ChecklistItemRow({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -138,7 +147,23 @@ export default function ChecklistItemRow({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
|
||||
<div
|
||||
className={twMerge(
|
||||
"group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100",
|
||||
isDragging && "opacity-80",
|
||||
)}
|
||||
>
|
||||
{!viewOnly && (
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="absolute left-0 top-1/2 flex h-[20px] w-[20px] -translate-x-full -translate-y-1/2 cursor-grab items-center justify-center pr-1 opacity-0 transition-opacity group-hover:opacity-75 hover:opacity-100 active:cursor-grabbing"
|
||||
>
|
||||
<RiDraggable className="h-4 w-4 text-light-700 dark:text-dark-700" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewOnly && <div className="w-[20px] flex-shrink-0" />}
|
||||
|
||||
<label
|
||||
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
|
||||
>
|
||||
@@ -164,7 +189,10 @@ export default function ChecklistItemRow({
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
// @ts-expect-error - valid event
|
||||
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)}
|
||||
onBlur={(e: Event) => {
|
||||
const innerHTML = (e.target as HTMLElement).innerHTML;
|
||||
commitTitle(innerHTML);
|
||||
}}
|
||||
className={twMerge(
|
||||
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
|
||||
viewOnly && "cursor-default",
|
||||
@@ -174,7 +202,9 @@ export default function ChecklistItemRow({
|
||||
if (viewOnly) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitTitle(title);
|
||||
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
|
||||
commitTitle(innerHTML);
|
||||
onCreateNewItem?.();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
export default function ChecklistNameInput({
|
||||
checklistPublicId,
|
||||
@@ -48,7 +49,7 @@ export default function ChecklistNameInput({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { DropResult } from "react-beautiful-dnd";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { HiPlus, HiXMark } from "react-icons/hi2";
|
||||
|
||||
import CircularProgress from "~/components/CircularProgress";
|
||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import ChecklistItemRow from "./ChecklistItemRow";
|
||||
import ChecklistNameInput from "./ChecklistNameInput";
|
||||
import NewChecklistItemForm from "./NewChecklistItemForm";
|
||||
@@ -34,109 +40,206 @@ export default function Checklists({
|
||||
viewOnly = false,
|
||||
}: ChecklistsProps) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
if (!checklists || checklists.length === 0) return null;
|
||||
const utils = api.useUtils();
|
||||
|
||||
const reorderItemMutation = api.checklist.updateItem.useMutation({
|
||||
onMutate: async (vars) => {
|
||||
await utils.card.byId.cancel({ cardPublicId });
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old;
|
||||
|
||||
const updatedChecklists = old.checklists.map((cl) => {
|
||||
const itemIndex = cl.items.findIndex(
|
||||
(item) => item.publicId === vars.checklistItemPublicId,
|
||||
);
|
||||
|
||||
if (itemIndex === -1 || vars.index === undefined) return cl;
|
||||
|
||||
const newIndex = vars.index;
|
||||
const items = Array.from(cl.items);
|
||||
const [movedItem] = items.splice(itemIndex, 1);
|
||||
if (!movedItem) return cl;
|
||||
items.splice(newIndex, 0, movedItem);
|
||||
|
||||
return { ...cl, items };
|
||||
});
|
||||
|
||||
return { ...old, checklists: updatedChecklists } as typeof old;
|
||||
});
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
showPopup({
|
||||
header: t`Unable to reorder checklist item`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const { source, destination, draggableId } = result;
|
||||
|
||||
if (source.droppableId !== destination.droppableId) return;
|
||||
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
reorderItemMutation.mutate({
|
||||
checklistItemPublicId: draggableId,
|
||||
index: destination.index,
|
||||
});
|
||||
};
|
||||
|
||||
if (checklists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-light-300 pb-4 dark:border-dark-300">
|
||||
<div>
|
||||
{checklists.map((checklist) => {
|
||||
const completedItems = checklist.items.filter(
|
||||
(item) => item.completed,
|
||||
);
|
||||
const progress =
|
||||
checklist.items.length > 0 && completedItems.length > 0
|
||||
? (completedItems.length / checklist.items.length) * 100
|
||||
: 2;
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="border-light-300 pb-4 dark:border-dark-300">
|
||||
<div>
|
||||
{checklists.map((checklist) => {
|
||||
const completedItems = checklist.items.filter(
|
||||
(item) => item.completed,
|
||||
);
|
||||
const progress =
|
||||
checklist.items.length > 0 && completedItems.length > 0
|
||||
? (completedItems.length / checklist.items.length) * 100
|
||||
: 2;
|
||||
|
||||
return (
|
||||
<div key={checklist.publicId} className="mb-4">
|
||||
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChecklistNameInput
|
||||
checklistPublicId={checklist.publicId}
|
||||
initialName={checklist.name}
|
||||
cardPublicId={cardPublicId}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
{!viewOnly && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||
<CircularProgress
|
||||
progress={progress}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||
{completedItems.length}/{checklist.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||
onClick={() =>
|
||||
openModal("DELETE_CHECKLIST", checklist.publicId)
|
||||
}
|
||||
>
|
||||
<HiXMark size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setActiveChecklistForm?.(checklist.publicId)
|
||||
}
|
||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||
>
|
||||
<HiPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<div key={checklist.publicId} className="mb-4">
|
||||
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChecklistNameInput
|
||||
checklistPublicId={checklist.publicId}
|
||||
initialName={checklist.name}
|
||||
cardPublicId={cardPublicId}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{viewOnly && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||
<CircularProgress
|
||||
progress={progress}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||
{completedItems.length}/{checklist.items.length}
|
||||
</span>
|
||||
{!viewOnly && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||
<CircularProgress
|
||||
progress={progress}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||
{completedItems.length}/{checklist.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||
onClick={() =>
|
||||
openModal("DELETE_CHECKLIST", checklist.publicId)
|
||||
}
|
||||
>
|
||||
<HiXMark size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setActiveChecklistForm?.(checklist.publicId)
|
||||
}
|
||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||
>
|
||||
<HiPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewOnly && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||
<CircularProgress
|
||||
progress={progress}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||
{completedItems.length}/{checklist.items.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Droppable
|
||||
droppableId={checklist.publicId}
|
||||
type="CHECKLIST_ITEM"
|
||||
isDropDisabled={viewOnly}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className="ml-1"
|
||||
>
|
||||
{checklist.items.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.publicId}
|
||||
draggableId={item.publicId}
|
||||
index={index}
|
||||
isDragDisabled={viewOnly}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
opacity: snapshot.isDragging ? 0.8 : 1,
|
||||
}}
|
||||
>
|
||||
<ChecklistItemRow
|
||||
item={{
|
||||
publicId: item.publicId,
|
||||
title: item.title,
|
||||
completed: item.completed,
|
||||
}}
|
||||
cardPublicId={cardPublicId}
|
||||
onCreateNewItem={() =>
|
||||
setActiveChecklistForm?.(checklist.publicId)
|
||||
}
|
||||
viewOnly={viewOnly}
|
||||
dragHandleProps={provided.dragHandleProps}
|
||||
isDragging={snapshot.isDragging}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
{activeChecklistForm === checklist.publicId && !viewOnly && (
|
||||
<div className="ml-1">
|
||||
<NewChecklistItemForm
|
||||
checklistPublicId={checklist.publicId}
|
||||
cardPublicId={cardPublicId}
|
||||
onCancel={() => setActiveChecklistForm?.(null)}
|
||||
readOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-1">
|
||||
{checklist.items.map((item) => (
|
||||
<ChecklistItemRow
|
||||
key={item.publicId}
|
||||
item={{
|
||||
publicId: item.publicId,
|
||||
title: item.title,
|
||||
completed: item.completed,
|
||||
}}
|
||||
cardPublicId={cardPublicId}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeChecklistForm === checklist.publicId && !viewOnly && (
|
||||
<div className="ml-1">
|
||||
<NewChecklistItemForm
|
||||
checklistPublicId={checklist.publicId}
|
||||
cardPublicId={cardPublicId}
|
||||
onCancel={() => setActiveChecklistForm?.(null)}
|
||||
readOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface FormValues {
|
||||
comment: string;
|
||||
@@ -21,6 +23,7 @@ const Comment = ({
|
||||
cardPublicId,
|
||||
name,
|
||||
email,
|
||||
image,
|
||||
isLoading,
|
||||
createdAt,
|
||||
comment,
|
||||
@@ -33,6 +36,7 @@ const Comment = ({
|
||||
cardPublicId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
isLoading: boolean;
|
||||
createdAt: string;
|
||||
comment: string | undefined;
|
||||
@@ -55,7 +59,7 @@ const Comment = ({
|
||||
|
||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.card.byId.refetch();
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
setIsEditing(false);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -107,6 +111,7 @@ const Comment = ({
|
||||
size="sm"
|
||||
name={name ?? ""}
|
||||
email={email ?? ""}
|
||||
imageUrl={getAvatarUrl(image) || undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
export function DeleteChecklistConfirmation({
|
||||
cardPublicId,
|
||||
@@ -40,7 +41,7 @@ export function DeleteChecklistConfirmation({
|
||||
},
|
||||
onSettled: async () => {
|
||||
closeModal();
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface DeleteCommentConfirmationProps {
|
||||
cardPublicId: string;
|
||||
@@ -47,7 +48,7 @@ export function DeleteCommentConfirmation({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate(queryParams);
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
142
apps/web/src/views/card/components/DueDateSelector.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import DateSelector from "~/components/DateSelector";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface DueDateSelectorProps {
|
||||
cardPublicId: string;
|
||||
dueDate: Date | null | undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DueDateSelector({
|
||||
cardPublicId,
|
||||
dueDate,
|
||||
isLoading = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
|
||||
dueDate,
|
||||
);
|
||||
|
||||
// Sync pendingDate with dueDate when it changes externally
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPendingDate(dueDate);
|
||||
}
|
||||
}, [dueDate, isOpen]);
|
||||
|
||||
const updateDueDate = api.card.update.useMutation({
|
||||
onMutate: async (update) => {
|
||||
await utils.card.byId.cancel();
|
||||
|
||||
const previousCard = utils.card.byId.getData({ cardPublicId });
|
||||
|
||||
utils.card.byId.setData({ cardPublicId }, (oldCard) => {
|
||||
if (!oldCard) return oldCard;
|
||||
|
||||
return {
|
||||
...oldCard,
|
||||
dueDate:
|
||||
update.dueDate !== undefined
|
||||
? (update.dueDate as Date | null)
|
||||
: oldCard.dueDate,
|
||||
};
|
||||
});
|
||||
|
||||
return { previousCard };
|
||||
},
|
||||
onError: (_error, _update, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: t`Unable to update due date`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
// Only update local state, don't fire mutation
|
||||
setPendingDate(date ?? null);
|
||||
};
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
// Only fire mutation if date actually changed
|
||||
const pendingIsNull = pendingDate === null || pendingDate === undefined;
|
||||
const dueIsNull = dueDate === null || dueDate === undefined;
|
||||
|
||||
let dateChanged = false;
|
||||
if (pendingIsNull && !dueIsNull) {
|
||||
dateChanged = true;
|
||||
} else if (!pendingIsNull && dueIsNull) {
|
||||
dateChanged = true;
|
||||
} else if (!pendingIsNull && !dueIsNull) {
|
||||
// Both are non-null at this point
|
||||
if (pendingDate instanceof Date && dueDate instanceof Date) {
|
||||
dateChanged = pendingDate.getTime() !== dueDate.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
// Close popover immediately
|
||||
setIsOpen(false);
|
||||
|
||||
// Fire mutation if date changed (optimistic update will handle UI)
|
||||
if (dateChanged) {
|
||||
updateDueDate.mutate({
|
||||
cardPublicId,
|
||||
dueDate: pendingDate ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full items-center text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{dueDate ? (
|
||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||
) : (
|
||||
<>
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Set due date`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
||||
<div
|
||||
className="absolute -left-8 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DateSelector
|
||||
selectedDate={pendingDate ?? undefined}
|
||||
onDateSelect={handleDateSelect}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface LabelSelectorProps {
|
||||
cardPublicId: string;
|
||||
@@ -74,7 +75,7 @@ export default function LabelSelector({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface ListSelectorProps {
|
||||
cardPublicId: string;
|
||||
@@ -54,7 +55,7 @@ export default function ListSelector({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,7 +79,7 @@ export default function ListSelector({
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedList?.value}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -7,6 +7,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface MemberSelectorProps {
|
||||
cardPublicId: string;
|
||||
@@ -81,7 +82,7 @@ export default function MemberSelector({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,7 +112,7 @@ export default function MemberSelector({
|
||||
createNewItemLabel={t`Invite member`}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface NewChecklistFormInput {
|
||||
name: string;
|
||||
@@ -72,7 +73,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
|
||||
});
|
||||
},
|
||||
onSettled: async (_data, _error, vars) => {
|
||||
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
|
||||
await invalidateCard(utils, vars.cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface FormValues {
|
||||
title: string;
|
||||
@@ -91,7 +92,7 @@ const NewChecklistItemForm = ({
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
interface FormValues {
|
||||
comment: string;
|
||||
@@ -34,10 +35,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
},
|
||||
onSettled: async () => {
|
||||
reset();
|
||||
await utils.card.byId.invalidate(queryParams);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await utils.card.byId.refetch();
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ import LabelIcon from "~/components/LabelIcon";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
||||
import ActivityList from "./components/ActivityList";
|
||||
@@ -27,6 +29,7 @@ import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
||||
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
||||
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
||||
import Dropdown from "./components/Dropdown";
|
||||
import { DueDateSelector } from "./components/DueDateSelector";
|
||||
import LabelSelector from "./components/LabelSelector";
|
||||
import ListSelector from "./components/ListSelector";
|
||||
import MemberSelector from "./components/MemberSelector";
|
||||
@@ -124,7 +127,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
@@ -133,6 +136,14 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
|
||||
<DueDateSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
dueDate={card?.dueDate}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -147,6 +158,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
getModalState,
|
||||
clearModalState,
|
||||
isOpen,
|
||||
modalStates,
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
@@ -168,7 +180,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
const board = card?.list.board;
|
||||
const boardId = board?.publicId;
|
||||
const activities = card?.activities;
|
||||
|
||||
const updateCard = api.card.update.useMutation({
|
||||
onError: () => {
|
||||
@@ -179,7 +190,22 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId: cardId });
|
||||
if (cardId) await invalidateCard(utils, cardId);
|
||||
},
|
||||
});
|
||||
|
||||
const addOrRemoveLabel = api.card.addOrRemoveLabel.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to add label`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
if (cardId) {
|
||||
await utils.card.byId.invalidate({ cardPublicId: cardId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -199,6 +225,24 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
});
|
||||
};
|
||||
|
||||
// this adds the new created label to selected labels
|
||||
useEffect(() => {
|
||||
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||
if (newLabelId && cardId) {
|
||||
const isAlreadyAdded = card?.labels.some(
|
||||
(label) => label.publicId === newLabelId,
|
||||
);
|
||||
|
||||
if (!isAlreadyAdded) {
|
||||
addOrRemoveLabel.mutate({
|
||||
cardPublicId: cardId,
|
||||
labelPublicId: newLabelId,
|
||||
});
|
||||
}
|
||||
clearModalState("NEW_LABEL_CREATED");
|
||||
}
|
||||
}, [modalStates.NEW_LABEL_CREATED, card, cardId]);
|
||||
|
||||
// Open the new item form after creating a new checklist
|
||||
useEffect(() => {
|
||||
if (!card) return;
|
||||
@@ -345,7 +389,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={!card}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
@@ -441,6 +484,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
checklistPublicId={entityId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { FC, SVGProps } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import AirbusLogo from "/public/logos/airbus.svg";
|
||||
import BitwardenLogo from "/public/logos/bitwarden.svg";
|
||||
import CouchbaseLogo from "/public/logos/couchbase.svg";
|
||||
import DeloitteLogo from "/public/logos/deloitte.svg";
|
||||
import FastCompanyLogo from "/public/logos/fast_company.svg";
|
||||
import LegoLogo from "/public/logos/lego.svg";
|
||||
import LinkedinLogo from "/public/logos/linkedin.svg";
|
||||
import SanaLogo from "/public/logos/sana.svg";
|
||||
import WakamLogo from "/public/logos/wakam.svg";
|
||||
import AirbusLogo from "~/assets/logos/airbus.svg";
|
||||
import CouchbaseLogo from "~/assets/logos/couchbase.svg";
|
||||
import DeloitteLogo from "~/assets/logos/deloitte.svg";
|
||||
import FastCompanyLogo from "~/assets/logos/fast_company.svg";
|
||||
import LegoLogo from "~/assets/logos/lego.svg";
|
||||
import LinkedinLogo from "~/assets/logos/linkedin.svg";
|
||||
import SanaLogo from "~/assets/logos/sana.svg";
|
||||
import WakamLogo from "~/assets/logos/wakam.svg";
|
||||
|
||||
type LogoComponent = FC<SVGProps<SVGSVGElement>>;
|
||||
|
||||
@@ -31,36 +30,31 @@ export default function Logos() {
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
component: BitwardenLogo as LogoComponent,
|
||||
alt: "Bitwarden Logo",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
component: CouchbaseLogo as LogoComponent,
|
||||
alt: "Couchbase Logo",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
id: 4,
|
||||
component: LegoLogo as LogoComponent,
|
||||
alt: "Lego Logo",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
id: 5,
|
||||
component: AirbusLogo as LogoComponent,
|
||||
alt: "Airbus Logo",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
id: 6,
|
||||
component: DeloitteLogo as LogoComponent,
|
||||
alt: "Deloitte Logo",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
id: 7,
|
||||
component: WakamLogo as LogoComponent,
|
||||
alt: "Wakam Logo",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
id: 8,
|
||||
component: LinkedinLogo as LogoComponent,
|
||||
alt: "Linked In Logo",
|
||||
},
|
||||
@@ -82,7 +76,7 @@ export default function Logos() {
|
||||
<div className="absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-white/60 to-transparent dark:from-dark-50" />
|
||||
<div className="absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-white/60 to-transparent dark:from-dark-50" />
|
||||
|
||||
<div className="animate-scroll flex" style={{ width: "max-content" }}>
|
||||
<div className="flex animate-scroll" style={{ width: "max-content" }}>
|
||||
<div className="flex flex-shrink-0 items-center space-x-12">
|
||||
{logos.map((logo) => {
|
||||
const LogoComponent: LogoComponent = logo.component;
|
||||
|
||||
@@ -54,6 +54,7 @@ export default function MembersPage() {
|
||||
memberStatus,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
showPendingIcon,
|
||||
}: {
|
||||
memberPublicId?: string;
|
||||
memberId?: string | null | undefined;
|
||||
@@ -64,6 +65,7 @@ export default function MembersPage() {
|
||||
memberStatus?: string;
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
showPendingIcon?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
@@ -82,6 +84,7 @@ export default function MembersPage() {
|
||||
name={memberName ?? ""}
|
||||
email={memberEmail ?? ""}
|
||||
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
|
||||
icon={showPendingIcon ? "?" : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -93,20 +96,26 @@ export default function MembersPage() {
|
||||
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
showPendingIcon &&
|
||||
"italic text-neutral-500 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,19 +255,24 @@ export default function MembersPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
data?.members.map((member, index) => (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
/>
|
||||
))}
|
||||
data?.members.map((member, index) => {
|
||||
const isPendingInvite = member.status === "invited";
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
showPendingIcon={isPendingInvite}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
|
||||
@@ -67,22 +67,23 @@ export function CardModal({
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await router.replace(
|
||||
`/${workspaceSlug}/${boardSlug}`,
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
setTimeout(() => {
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
workspaceSlug,
|
||||
boardSlug: [boardSlug],
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
@@ -160,7 +161,6 @@ export function CardModal({
|
||||
{cardPublicId && (
|
||||
<ActivityList
|
||||
cardPublicId={cardPublicId}
|
||||
activities={data?.activities ?? []}
|
||||
isLoading={isLoading}
|
||||
isViewOnly={true}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import Popup from "~/components/Popup";
|
||||
import ThemeToggle from "~/components/ThemeToggle";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -29,6 +30,11 @@ export default function PublicBoardView() {
|
||||
const { showPopup } = usePopup();
|
||||
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const boardSlug = Array.isArray(router.query.boardSlug)
|
||||
? router.query.boardSlug[0]
|
||||
@@ -38,6 +44,15 @@ export default function PublicBoardView() {
|
||||
? router.query.workspaceSlug[0]
|
||||
: router.query.workspaceSlug;
|
||||
|
||||
const dueDateFilters = formatToArray(router.query.dueDate) as (
|
||||
| "overdue"
|
||||
| "today"
|
||||
| "tomorrow"
|
||||
| "next-week"
|
||||
| "next-month"
|
||||
| "no-due-date"
|
||||
)[];
|
||||
|
||||
const { data, isLoading } = api.board.bySlug.useQuery(
|
||||
{
|
||||
boardSlug: boardSlug ?? "",
|
||||
@@ -45,6 +60,9 @@ export default function PublicBoardView() {
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
lists: formatToArray(router.query.lists),
|
||||
...(dueDateFilters.length > 0 && {
|
||||
dueDateFilters: dueDateFilters,
|
||||
}),
|
||||
},
|
||||
{
|
||||
enabled: router.isReady && !!boardSlug,
|
||||
@@ -76,7 +94,8 @@ export default function PublicBoardView() {
|
||||
);
|
||||
};
|
||||
|
||||
const splitPath = router.asPath.split("/");
|
||||
const pathWithoutQuery = router.asPath.split("?")[0];
|
||||
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,7 +157,11 @@ export default function PublicBoardView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"
|
||||
>
|
||||
{isLoading || !router.isReady ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
@@ -172,27 +195,37 @@ export default function PublicBoardView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||
{list.cards.map((card) => (
|
||||
<Link
|
||||
key={card.publicId}
|
||||
href={`/${data.workspace.slug}/${data.slug}/${card.publicId}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col`}
|
||||
shallow={true}
|
||||
onClick={() => {
|
||||
openModal("CARD");
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
labels={card.labels}
|
||||
checklists={card.checklists ?? []}
|
||||
members={[]}
|
||||
description={card.description}
|
||||
comments={card.comments ?? []}
|
||||
attachments={card.attachments}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{list.cards.map((card) => {
|
||||
return (
|
||||
<Link
|
||||
key={card.publicId}
|
||||
href={{
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
workspaceSlug: data.workspace.slug,
|
||||
boardSlug: [data.slug, card.publicId],
|
||||
},
|
||||
}}
|
||||
className={`mb-2 flex !cursor-pointer flex-col`}
|
||||
shallow={true}
|
||||
onClick={() => {
|
||||
openModal("CARD");
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
labels={card.labels}
|
||||
checklists={card.checklists ?? []}
|
||||
members={[]}
|
||||
description={card.description}
|
||||
comments={card.comments ?? []}
|
||||
attachments={card.attachments}
|
||||
dueDate={card.dueDate ?? null}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -37,6 +37,13 @@ export default function AccountSettings() {
|
||||
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email`}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-700 dark:text-dark-900">{data?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Language`}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
@@ -79,6 +80,14 @@ export default function WorkspaceSettings() {
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email visibility`}
|
||||
</h2>
|
||||
<UpdateWorkspaceEmailVisibilityForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasActiveSubscription(subscriptions, "team") && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function UpdateWorkspaceEmailVisibilityForm({
|
||||
workspacePublicId,
|
||||
showEmailsToMembers,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
showEmailsToMembers: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isChecked, setIsChecked] = useState(showEmailsToMembers);
|
||||
|
||||
useEffect(() => {
|
||||
setIsChecked(showEmailsToMembers);
|
||||
}, [showEmailsToMembers]);
|
||||
|
||||
const updateWorkspace = api.workspace.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
const newValue = !isChecked;
|
||||
setIsChecked(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
showEmailsToMembers: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Allow workspace members to see each other's email addresses`}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
isChecked={isChecked}
|
||||
onChange={handleToggle}
|
||||
label=""
|
||||
disabled={updateWorkspace.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,13 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-}
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
|
||||
- NEXT_PUBLIC_KAN_ENV=${NEXT_PUBLIC_KAN_ENV}
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
@@ -35,10 +38,12 @@ services:
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- SMTP_REJECT_UNAUTHORIZED=${SMTP_REJECT_UNAUTHORIZED}
|
||||
|
||||
# Notifications
|
||||
- NOVU_API_KEY=${NOVU_API_KEY}
|
||||
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
|
||||
- EMAIL_UNSUBSCRIBE_SECRET=${EMAIL_UNSUBSCRIBE_SECRET}
|
||||
|
||||
# S3 storage
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
@@ -51,10 +56,13 @@ services:
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
|
||||
# Auth config
|
||||
# Auth config (optional)
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
|
||||
# API configuration (optional)
|
||||
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
|
||||
|
||||
# Integration providers
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
@@ -63,6 +71,7 @@ services:
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- BETTER_AUTH_ALLOWED_DOMAINS=${BETTER_AUTH_ALLOWED_DOMAINS}
|
||||
|
||||
# Analytics
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
|
||||
@@ -18,12 +18,16 @@ services:
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
|
||||
# Admin API key (optional)
|
||||
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
|
||||
|
||||
# SMTP (optional)
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- SMTP_REJECT_UNAUTHORIZED=${SMTP_REJECT_UNAUTHORIZED}
|
||||
|
||||
# Disable email features entirely (optional)
|
||||
- NEXT_PUBLIC_DISABLE_EMAIL=${NEXT_PUBLIC_DISABLE_EMAIL}
|
||||
@@ -46,6 +50,9 @@ services:
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
|
||||
# API configuration (optional)
|
||||
- NEXT_API_BODY_SIZE_LIMIT=${NEXT_API_BODY_SIZE_LIMIT}
|
||||
|
||||
# Integration providers (optional)
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
@@ -59,6 +66,7 @@ services:
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||
- BETTER_AUTH_ALLOWED_DOMAINS=${BETTER_AUTH_ALLOWED_DOMAINS}
|
||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||
- DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
||||
|
||||