Compare commits
165 Commits
feat/faqs-
...
fix/api-ke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3687cfb10 | ||
|
|
0b45ceff7f | ||
|
|
76b2c58461 | ||
|
|
b8d1fe230d | ||
|
|
bd652ba6c9 | ||
|
|
1fb56aafd0 | ||
|
|
91deaca6ec | ||
|
|
ab81db0f56 | ||
|
|
29080fa851 | ||
|
|
2360c5075d | ||
|
|
79c2b5f3d5 | ||
|
|
8e7a95ff2b | ||
|
|
94fd2cb11f | ||
|
|
2a8af514d1 | ||
|
|
0b405e0456 | ||
|
|
f1f3a00c17 | ||
|
|
0fb2bac102 | ||
|
|
a7b71ae764 | ||
|
|
a246514b0b | ||
|
|
53397d8e81 | ||
|
|
1f9f07df20 | ||
|
|
1d5e3a936c | ||
|
|
0b49f502a9 | ||
|
|
400dcec56d | ||
|
|
dfcdc5e47e | ||
|
|
32e77e0291 | ||
|
|
a6780e3e28 | ||
|
|
2c5195a2b8 | ||
|
|
793fa30162 | ||
|
|
af63719de5 | ||
|
|
52fd624b81 | ||
|
|
b4d4810f45 | ||
|
|
239d152340 | ||
|
|
f294760747 | ||
|
|
280d8f66dd | ||
|
|
eeae23a24c | ||
|
|
d81950b8bd | ||
|
|
e530f39360 | ||
|
|
bd25fb33f7 | ||
|
|
93f2816b37 | ||
|
|
2292b972c4 | ||
|
|
af36fb133a | ||
|
|
9cb1fb5218 | ||
|
|
5f190b92de | ||
|
|
48a1f39588 | ||
|
|
c7fce10fa7 | ||
|
|
094e1d139d | ||
|
|
fae169c691 | ||
|
|
91bc319cf1 | ||
|
|
d42540b9ef | ||
|
|
71ecf2e6d3 | ||
|
|
8a9cb8fabf | ||
|
|
37f6be52ee | ||
|
|
b72449bef1 | ||
|
|
5cb47a588f | ||
|
|
d5c2404715 | ||
|
|
0276c51ac7 | ||
|
|
1eee819c9d | ||
|
|
2113cc7873 | ||
|
|
32714ccedf | ||
|
|
e72b34b541 | ||
|
|
b44a1296a0 | ||
|
|
3bf132286b | ||
|
|
c9c3365c0a | ||
|
|
7c2937a7b7 | ||
|
|
f27a031ea8 | ||
|
|
05a0245988 | ||
|
|
b6b9423823 | ||
|
|
1696aab43b | ||
|
|
33d2f23955 | ||
|
|
961219835e | ||
|
|
031a42b9eb | ||
|
|
8a1189d011 | ||
|
|
53f6ada4d3 | ||
|
|
6ea02b1db1 | ||
|
|
737b1383d0 | ||
|
|
38d0e9495c | ||
|
|
19798ddbc9 | ||
|
|
08ba965921 | ||
|
|
008b5fd1a0 | ||
|
|
97cfd60b59 | ||
|
|
24e283e22c | ||
|
|
07db7409cb | ||
|
|
346ddc16be | ||
|
|
f9eee53ed0 | ||
|
|
1ab4e24c96 | ||
|
|
34165b390f | ||
|
|
ef0d53db8f | ||
|
|
78b9de869f | ||
|
|
81c03b51e2 | ||
|
|
0d3d9a358f | ||
|
|
2269d23c94 | ||
|
|
e437d075e3 | ||
|
|
b422907b53 | ||
|
|
672dfe6540 | ||
|
|
ef5bf87fdf | ||
|
|
ec37f5480a | ||
|
|
7f5a1ab513 | ||
|
|
5f2d409773 | ||
|
|
210e44db5c | ||
|
|
befe7ab7f4 | ||
|
|
53a33c68fc | ||
|
|
3bae03613d | ||
|
|
c42ee7cf2c | ||
|
|
07f997c3ad | ||
|
|
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 | ||
|
|
50382e9186 | ||
|
|
00be5fb058 | ||
|
|
fc65f53e0c | ||
|
|
64aace6e3b | ||
|
|
1651faae14 | ||
|
|
b472c5ce93 | ||
|
|
af6490896a | ||
|
|
c0a3c37d95 | ||
|
|
c30da618ec | ||
|
|
735db21227 | ||
|
|
e8d52de5e4 | ||
|
|
a93a8c5f43 | ||
|
|
8ffadd0181 | ||
|
|
5909b3ea87 | ||
|
|
81e9fe777c |
@@ -1,18 +1,62 @@
|
|||||||
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
docker-compose.override.yml
|
# Docker
|
||||||
|
docker-compose*.yml
|
||||||
Dockerfile
|
|
||||||
./**/*/Dockerfile
|
|
||||||
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
# Dependencies (rebuilt in Docker)
|
||||||
node_modules
|
node_modules
|
||||||
./**/*/node_modules
|
**/node_modules
|
||||||
|
|
||||||
|
# Build outputs (rebuilt in Docker)
|
||||||
|
**/.next
|
||||||
|
**/dist
|
||||||
|
**/out
|
||||||
|
**/.turbo
|
||||||
|
**/.cache
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github
|
||||||
|
.husky
|
||||||
|
.changeset
|
||||||
|
|
||||||
|
# Documentation (not needed in build)
|
||||||
|
*.md
|
||||||
|
!packages/db/migrations/**
|
||||||
|
LICENSE
|
||||||
|
CONTRIBUTING.md
|
||||||
|
AGENTS.md
|
||||||
|
CHANGELOG.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.test.tsx
|
||||||
|
**/*.spec.ts
|
||||||
|
**/*.spec.tsx
|
||||||
|
**/__tests__
|
||||||
|
**/coverage
|
||||||
|
|
||||||
|
# Logs
|
||||||
pnpm-debug.log
|
pnpm-debug.log
|
||||||
./**/*/pnpm-debug.log
|
**/pnpm-debug.log
|
||||||
|
|
||||||
README.md
|
# Cloud compose (separate deployment)
|
||||||
.next
|
cloud/
|
||||||
.git
|
|
||||||
|
|||||||
20
.env.example
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Required environment variables
|
# Required environment variables
|
||||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||||
|
|
||||||
# Fill if you want to use an external database
|
# Fill if you want to use an external database
|
||||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||||
@@ -17,6 +17,7 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||||
SMTP_SECURE= # set to "false" to use port 587
|
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)
|
# Switch email features off entirely (optional)
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||||
@@ -27,20 +28,36 @@ S3_ENDPOINT=
|
|||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
S3_FORCE_PATH_STYLE=
|
S3_FORCE_PATH_STYLE=
|
||||||
|
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
|
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||||
|
|
||||||
|
|
||||||
# Auth config (optional)
|
# Auth config (optional)
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
|
|
||||||
|
# API configuration (optional)
|
||||||
|
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
|
||||||
|
|
||||||
# Integration providers (optional)
|
# Integration providers (optional)
|
||||||
TRELLO_APP_API_KEY=
|
TRELLO_APP_API_KEY=
|
||||||
TRELLO_APP_SECRET=
|
TRELLO_APP_SECRET=
|
||||||
|
|
||||||
|
# Redis (optional - for rate limiting)
|
||||||
|
# If not provided, rate limiting will use in-memory storage
|
||||||
|
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
||||||
|
|
||||||
|
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||||
|
LOG_LEVEL=
|
||||||
|
|
||||||
# OAuth providers (optional)
|
# OAuth providers (optional)
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
|
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||||
|
BETTER_AUTH_ALLOWED_DOMAINS=
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
@@ -78,4 +95,3 @@ TWITCH_CLIENT_SECRET=
|
|||||||
APPLE_CLIENT_ID=
|
APPLE_CLIENT_ID=
|
||||||
APPLE_CLIENT_SECRET=
|
APPLE_CLIENT_SECRET=
|
||||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||||
|
|
||||||
|
|||||||
96
.github/workflows/docker-publish.yml
vendored
@@ -6,23 +6,31 @@ name: Docker
|
|||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
|
||||||
- cron: "21 21 * * *"
|
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
# Only trigger on tags (releases), not main branch pushes
|
||||||
# Publish semver tags as releases.
|
# Main branch builds are handled by workflow_run after Translate completes
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Translate"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
# Docker builds after Translate completes (success or failure)
|
||||||
|
# This ensures Docker always has the latest translations
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Use docker.io for Docker Hub if empty
|
# Use docker.io for Docker Hub if empty
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
# github.repository as <account>/<repo>
|
# github.repository as <account>/<repo>
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -34,6 +42,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
# Install the cosign tool except on PR
|
||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
@@ -72,7 +82,58 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||||
|
type=raw,value=edge,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata (migrate)
|
||||||
|
id: meta-migrate
|
||||||
|
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||||
|
type=raw,value=edge,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
# Extract version from git tag or ref
|
||||||
|
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||||
|
- 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)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
@@ -86,6 +147,24 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build and push migrate Docker image
|
||||||
|
id: build-and-push-migrate
|
||||||
|
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/web/Dockerfile
|
||||||
|
target: migrate
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-migrate.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.version }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
@@ -103,3 +182,10 @@ jobs:
|
|||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|
||||||
|
- name: Sign the published migrate Docker image
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
env:
|
||||||
|
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
||||||
|
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
||||||
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|||||||
56
.github/workflows/translate.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Translate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
translate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --filter @kan/web...
|
||||||
|
|
||||||
|
- name: Extract translations
|
||||||
|
working-directory: apps/web
|
||||||
|
run: pnpm lingui:extract
|
||||||
|
|
||||||
|
- name: Lingo.dev
|
||||||
|
uses: lingodotdev/lingo.dev@main
|
||||||
|
with:
|
||||||
|
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
|
||||||
|
commit-message: "chore: update translations"
|
||||||
|
working-directory: apps/web
|
||||||
|
commit-author-name: "${{ github.actor }}"
|
||||||
|
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
|
||||||
|
parallel: true
|
||||||
|
|
||||||
|
- name: Compile translations
|
||||||
|
working-directory: apps/web
|
||||||
|
run: pnpm lingui:compile
|
||||||
|
|
||||||
|
- name: Commit compiled translations
|
||||||
|
run: |
|
||||||
|
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
|
||||||
|
git config --local user.name "${{ github.actor }}"
|
||||||
|
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
|
||||||
|
git add apps/web/src/locales/*/messages.ts
|
||||||
|
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
|
||||||
|
fi
|
||||||
3
.gitignore
vendored
@@ -34,6 +34,7 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
@@ -53,3 +54,5 @@ i18n.cache
|
|||||||
|
|
||||||
# pgdata
|
# pgdata
|
||||||
/apps/web/pgdata
|
/apps/web/pgdata
|
||||||
|
|
||||||
|
.claude
|
||||||
333
AGENTS.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Kan is an open-source project management tool (Trello alternative) built with:
|
||||||
|
|
||||||
|
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
|
||||||
|
- **Backend**: tRPC, Node.js
|
||||||
|
- **Database**: PostgreSQL with Drizzle ORM
|
||||||
|
- **Monorepo**: pnpm workspaces with Turbo
|
||||||
|
- **Auth**: Better Auth
|
||||||
|
- **Internationalization**: Lingui
|
||||||
|
|
||||||
|
## Setup Commands
|
||||||
|
|
||||||
|
- Install deps: `pnpm install`
|
||||||
|
- Start dev server: `pnpm dev`
|
||||||
|
- Create migrations: `cd packages/db && pnpm drizzle-kit generate --name "AddFieldToTable"`
|
||||||
|
- Run database migrations: `pnpm db:migrate`
|
||||||
|
- Run linter: `pnpm lint`
|
||||||
|
- Run type check: `pnpm typecheck`
|
||||||
|
- Format code: `pnpm format:fix`
|
||||||
|
- Extract i18n strings: `pnpm lingui:extract`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `apps/web/` - Next.js web application
|
||||||
|
- `packages/api/` - tRPC API routers
|
||||||
|
- `packages/db/` - Database schema, migrations, and repositories
|
||||||
|
- `packages/auth/` - Authentication package
|
||||||
|
- `packages/shared/` - Shared utilities
|
||||||
|
- `packages/email/` - Email templates and sending
|
||||||
|
- `packages/stripe/` - Stripe integration
|
||||||
|
- `tooling/` - Shared tooling configs (ESLint, Prettier, TypeScript)
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- Use TypeScript strictly - avoid `any` types
|
||||||
|
- Prefer explicit types over inference when it improves clarity
|
||||||
|
- Use `as const` for literal types when appropriate
|
||||||
|
- Follow existing patterns for type definitions
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Files**: kebab-case for files (e.g., `card-repo.ts`)
|
||||||
|
- **Components**: PascalCase for React components
|
||||||
|
- **Functions**: camelCase for functions
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE for constants
|
||||||
|
- **Types/Interfaces**: PascalCase
|
||||||
|
|
||||||
|
### Database Layer (`packages/db/`)
|
||||||
|
|
||||||
|
- **Schema**: Define schemas in `src/schema/` using Drizzle ORM
|
||||||
|
- **Migrations**: Create migrations with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
||||||
|
- **Repositories**: Put database queries in `src/repository/` files
|
||||||
|
- **Soft Deletes**: Use `deletedAt` timestamp for soft deletion (not hard deletes)
|
||||||
|
- **Index Management**: Cards have `index` fields that must be maintained sequentially per list
|
||||||
|
- **Activity Logging**: Use `card_activity` table to track all card changes
|
||||||
|
|
||||||
|
### API Layer (`packages/api/`)
|
||||||
|
|
||||||
|
- **Routers**: Create tRPC routers in `src/routers/`
|
||||||
|
- **Procedures**: Use `protectedProcedure` for authenticated endpoints, `publicProcedure` for public
|
||||||
|
- **Validation**: Use Zod schemas for input validation
|
||||||
|
- **Error Handling**: Use `TRPCError` with appropriate error codes
|
||||||
|
- **OpenAPI**: Add OpenAPI metadata for all endpoints
|
||||||
|
- **Authorization**: Always check workspace membership with `assertUserInWorkspace`
|
||||||
|
|
||||||
|
### Frontend (`apps/web/`)
|
||||||
|
|
||||||
|
- **Components**: React components in `src/components/`
|
||||||
|
- **Views**: Page-level components in `src/views/`
|
||||||
|
- **Hooks**: Custom hooks in `src/hooks/`
|
||||||
|
- **i18n**: Use `t` template literal for translations (Lingui)
|
||||||
|
- **Styling**: Use Tailwind CSS classes
|
||||||
|
- **State Management**: Use tRPC React Query hooks for server state
|
||||||
|
- **Modals**: Use `useModal` hook for modal management
|
||||||
|
- **Popups**: Use `usePopup` hook for toast notifications
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
- Cards are the main entity in Kan
|
||||||
|
- Cards belong to Lists, which belong to Boards
|
||||||
|
- Cards have: title, description, labels, members, checklists, comments, attachments, due dates
|
||||||
|
- Cards use soft deletion (`deletedAt` field)
|
||||||
|
- Cards have an `index` field that must be maintained sequentially per list
|
||||||
|
- All card changes are tracked in `card_activity` table
|
||||||
|
|
||||||
|
### Activity Tracking
|
||||||
|
|
||||||
|
- Every significant card change creates an activity record
|
||||||
|
- Activity types include: created, updated (various fields), etc.
|
||||||
|
- Activities are displayed in card activity feeds
|
||||||
|
|
||||||
|
### Workspaces & Boards
|
||||||
|
|
||||||
|
- Users belong to Workspaces
|
||||||
|
- Boards belong to Workspaces
|
||||||
|
- Workspace members have different permission levels
|
||||||
|
- Boards can be public or private
|
||||||
|
|
||||||
|
### Soft Deletion Pattern
|
||||||
|
|
||||||
|
- Entities use `deletedAt` timestamp for soft deletion
|
||||||
|
- Queries filter with `isNull(table.deletedAt)` to exclude deleted items
|
||||||
|
|
||||||
|
## File Locations Reference
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
- Schema: `packages/db/src/schema/*.ts`
|
||||||
|
- Repositories: `packages/db/src/repository/*.repo.ts`
|
||||||
|
- Migrations: `packages/db/migrations/`
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- Routers: `packages/api/src/routers/*.ts`
|
||||||
|
- Utils: `packages/api/src/utils/`
|
||||||
|
- Types: `packages/api/src/types/`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Components: `apps/web/src/components/`
|
||||||
|
- Views: `apps/web/src/views/`
|
||||||
|
- Pages: `apps/web/src/pages/`
|
||||||
|
- Hooks: `apps/web/src/hooks/`
|
||||||
|
- Utils: `apps/web/src/utils/`
|
||||||
|
- Locales: `apps/web/src/locales/`
|
||||||
|
|
||||||
|
## Database Patterns
|
||||||
|
|
||||||
|
- **Soft Deletes**: Always filter with `isNull(table.deletedAt)` in queries
|
||||||
|
- **Public IDs**: Use 12-character public IDs (`publicId`) for all user-facing entities
|
||||||
|
- **Internal IDs**: Never expose internal database IDs (e.g., `id`, `cardId`, `listId`) in API responses or URLs - always use `publicId` externally
|
||||||
|
- **Transactions**: Use database transactions for multi-step operations
|
||||||
|
- **Index Management**: When deleting/moving cards, maintain sequential indices
|
||||||
|
- **Activity Tracking**: Create activity records for all significant changes
|
||||||
|
|
||||||
|
## API Patterns
|
||||||
|
|
||||||
|
- **Input Validation**: Always validate inputs with Zod
|
||||||
|
- **Error Messages**: Provide clear, user-friendly error messages
|
||||||
|
- **Optimistic Updates**: Use tRPC's `onMutate` for optimistic UI updates
|
||||||
|
- **Cache Invalidation**: Properly invalidate queries after mutations
|
||||||
|
- **ID Exposure**: Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` for external communication
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### Card Index Management
|
||||||
|
|
||||||
|
When cards are created, moved, or deleted, their indices must be maintained:
|
||||||
|
|
||||||
|
- New cards: Append to end (max index + 1) or insert at position
|
||||||
|
- Moving cards: Adjust indices of affected cards
|
||||||
|
- Deleting cards: Decrement indices of cards after deleted one
|
||||||
|
- Always use transactions for index updates
|
||||||
|
|
||||||
|
### Activity Logging
|
||||||
|
|
||||||
|
Create activity records for:
|
||||||
|
|
||||||
|
- Card creation
|
||||||
|
- Card updates (title, description, list, etc.)
|
||||||
|
- Label/member additions/removals
|
||||||
|
- Comments
|
||||||
|
- Checklists and items
|
||||||
|
- Attachments
|
||||||
|
- Due dates
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
Always check:
|
||||||
|
|
||||||
|
1. User is authenticated
|
||||||
|
2. User has access to workspace
|
||||||
|
3. User has permission for the operation
|
||||||
|
|
||||||
|
Use `assertUserInWorkspace` helper for workspace checks.
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
||||||
|
- Provide user-friendly error messages
|
||||||
|
- Log errors appropriately using the `@kan/logger` package
|
||||||
|
- Show popup notifications for user-facing errors
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
|
||||||
|
- Create a module-scoped logger: `const logger = createLogger("module-name")`
|
||||||
|
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
|
||||||
|
- Defaults to `debug` in development, `info` in production
|
||||||
|
- Never use `console.log` — always use the logger
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Creating a Card
|
||||||
|
|
||||||
|
1. Create card in repository with proper index management
|
||||||
|
2. Create `card.created` activity
|
||||||
|
3. Handle label/member relationships if provided
|
||||||
|
4. Return the created card
|
||||||
|
|
||||||
|
### Updating a Card
|
||||||
|
|
||||||
|
1. Validate user has workspace access
|
||||||
|
2. Update card fields
|
||||||
|
3. Create appropriate activity records
|
||||||
|
4. Invalidate relevant queries
|
||||||
|
|
||||||
|
### Querying Cards
|
||||||
|
|
||||||
|
- Always filter by `isNull(cards.deletedAt)` in queries
|
||||||
|
- Include related data (labels, members, checklists) via Drizzle relations
|
||||||
|
- Order by `index` for proper card ordering
|
||||||
|
|
||||||
|
## Adding a New Feature
|
||||||
|
|
||||||
|
1. **Database**: Update schema in `packages/db/src/schema/`
|
||||||
|
2. **Migration**: Create migration with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
||||||
|
3. **Repository**: Add repository functions in `packages/db/src/repository/`
|
||||||
|
4. **API**: Add tRPC router procedures in `packages/api/src/routers/`
|
||||||
|
5. **Frontend**: Add UI components in `apps/web/src/`
|
||||||
|
6. **i18n**: Add translations for new strings
|
||||||
|
|
||||||
|
## Adding a New Environment Variable
|
||||||
|
|
||||||
|
Update all of the following:
|
||||||
|
|
||||||
|
1. `.env.example` — add the variable with an empty value and a comment explaining it
|
||||||
|
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
|
||||||
|
3. `docker-compose.yml` — add to the `web` service `environment` section
|
||||||
|
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
|
||||||
|
5. `README.md` — add a row to the Environment Variables table
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
- Always create migrations (never modify existing migrations)
|
||||||
|
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
|
||||||
|
- Run migrations with: `pnpm db:migrate`
|
||||||
|
- Update schema files in `packages/db/src/schema/`
|
||||||
|
- Test migrations on development database first
|
||||||
|
- Update TypeScript types after schema changes
|
||||||
|
- Consider index management for card operations
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- Use tRPC procedures (not REST)
|
||||||
|
- Add OpenAPI metadata for documentation
|
||||||
|
- Validate inputs with Zod
|
||||||
|
- Check workspace permissions
|
||||||
|
- Create activity records for significant changes
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
- Use Tailwind for styling
|
||||||
|
- Follow existing component patterns
|
||||||
|
- Use tRPC hooks for data fetching
|
||||||
|
- Implement optimistic updates where appropriate
|
||||||
|
- Add proper loading and error states
|
||||||
|
|
||||||
|
## Testing Instructions
|
||||||
|
|
||||||
|
- Test database operations in transactions that rollback
|
||||||
|
- Test authorization checks
|
||||||
|
- Test index management when moving/deleting cards
|
||||||
|
- Test activity logging
|
||||||
|
- Test UI interactions
|
||||||
|
- Run `pnpm lint` and `pnpm typecheck` before committing
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Use database indexes appropriately
|
||||||
|
- Batch operations when possible
|
||||||
|
- Avoid N+1 queries
|
||||||
|
- Use transactions for related operations
|
||||||
|
- Implement optimistic updates in UI
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Always check workspace membership before operations
|
||||||
|
- Validate all inputs
|
||||||
|
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
|
||||||
|
- Sanitize user input
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
- All user-facing strings must use `t` template literal
|
||||||
|
- Add translations to locale files in `apps/web/src/locales/`
|
||||||
|
- Run `pnpm lingui:extract` to update translation files
|
||||||
|
- Update locale files for all languages
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Use workspace dependencies (`workspace:*`) for internal packages
|
||||||
|
- Keep dependencies up to date
|
||||||
|
- Use catalog for shared dependency versions
|
||||||
|
|
||||||
|
## When Implementing Features
|
||||||
|
|
||||||
|
### Adding New Card Fields
|
||||||
|
|
||||||
|
1. Update schema in `packages/db/src/schema/cards.ts`
|
||||||
|
2. Create migration
|
||||||
|
3. Update repository functions
|
||||||
|
4. Add API endpoints
|
||||||
|
5. Update frontend components
|
||||||
|
6. Add activity tracking if needed
|
||||||
|
|
||||||
|
### Adding New Activity Types
|
||||||
|
|
||||||
|
1. Add to `activityTypes` array in schema
|
||||||
|
2. Create migration to update enum
|
||||||
|
3. Use in activity creation code
|
||||||
|
4. Update activity display components if needed
|
||||||
|
|
||||||
|
## Git & Commits
|
||||||
|
|
||||||
|
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
|
||||||
|
- Keep commits focused on single changes
|
||||||
|
- Reference issue numbers when applicable
|
||||||
|
|
||||||
|
## PR Instructions
|
||||||
|
|
||||||
|
- Title format: `feat: description` or `fix: description`
|
||||||
|
- Always run `pnpm lint` and `pnpm typecheck` before committing
|
||||||
|
- Provide clear description of changes
|
||||||
|
- Include screenshots for UI changes
|
||||||
|
- Keep PRs focused on a single feature/fix
|
||||||
147
README.md
@@ -34,7 +34,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
|
|
||||||
## Screenshot 👁️
|
## 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 🛠️
|
## Made With 🛠️
|
||||||
|
|
||||||
@@ -47,39 +47,71 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
|
|
||||||
## Self Hosting 🐳
|
## Self Hosting 🐳
|
||||||
|
|
||||||
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
### One-click Deployments
|
||||||
|
|
||||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
|
||||||
|
|
||||||
|
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||||
|
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
||||||
|
|
||||||
|
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
||||||
|
|
||||||
|
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
migrate:
|
||||||
|
image: ghcr.io/kanbn/kan-migrate:latest
|
||||||
|
container_name: kan-migrate
|
||||||
|
networks:
|
||||||
|
- kan-network
|
||||||
|
environment:
|
||||||
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: ghcr.io/kanbn/kan:latest
|
||||||
container_name: kan-web
|
container_name: kan-web
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "${WEB_PORT:-3000}:3000"
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||||
BETTER_AUTH_SECRET: your_auth_secret
|
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||||
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
container_name: kan-db
|
container_name: kan-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: kan_db
|
- POSTGRES_DB=kan_db
|
||||||
POSTGRES_USER: kan
|
- POSTGRES_USER=kan
|
||||||
POSTGRES_PASSWORD: your_postgres_password
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
volumes:
|
volumes:
|
||||||
- kan_postgres_data:/var/lib/postgresql/data
|
- kan_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
@@ -91,23 +123,23 @@ volumes:
|
|||||||
kan_postgres_data:
|
kan_postgres_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Start the containers in detached mode:
|
3. Start the containers in detached mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Access Kan at http://localhost:3000
|
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
|
||||||
|
|
||||||
The application will be running in the background. You can manage the containers using these commands:
|
**Managing containers:**
|
||||||
|
|
||||||
- To stop the containers: `docker compose down`
|
- To stop the containers: `docker compose down`
|
||||||
- To view logs: `docker compose logs -f`
|
- To view logs: `docker compose logs -f`
|
||||||
|
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
||||||
- To restart the containers: `docker compose restart`
|
- To restart the containers: `docker compose restart`
|
||||||
|
- To rebuild after code changes: `docker compose up -d --build`
|
||||||
|
|
||||||
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||||
|
|
||||||
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
|
||||||
|
|
||||||
## Local Development 🧑💻
|
## Local Development 🧑💻
|
||||||
|
|
||||||
@@ -138,41 +170,50 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
| `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>"` |
|
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `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_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||||
|
| `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` |
|
||||||
|
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ https://kan.bn/api/v1
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
|
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request.
|
||||||
|
|
||||||
```
|
```
|
||||||
'x-api-key': kan_123456789
|
'Authorization': 'Bearer kan_123456789'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response codes
|
## Response codes
|
||||||
|
|||||||
@@ -1,84 +1,121 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
ARG NODE_VERSION=20
|
ARG NODE_VERSION=20
|
||||||
ARG APP_DIRNAME=web
|
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
||||||
ARG PROJECT=@kan/web
|
|
||||||
|
|
||||||
# 1. Alpine image
|
# ============================================
|
||||||
|
# Stage 1: Alpine base with pnpm and turbo
|
||||||
|
# ============================================
|
||||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update
|
RUN apk update && \
|
||||||
RUN apk add --no-cache libc6-compat python3 make g++
|
apk add --no-cache libc6-compat && \
|
||||||
|
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||||
|
corepack enable && \
|
||||||
|
npm install turbo@2.3.1 --global && \
|
||||||
|
pnpm config set store-dir ~/.pnpm-store
|
||||||
|
|
||||||
# Setup pnpm and turbo on the alpine base
|
# ============================================
|
||||||
FROM alpine AS base
|
# Stage 2: Prune the monorepo
|
||||||
RUN corepack enable
|
# ============================================
|
||||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
FROM alpine AS pruner
|
||||||
# RUN npm install turbo@2.1.3 --global
|
|
||||||
RUN npm install turbo@2.3.1 --global
|
|
||||||
|
|
||||||
RUN pnpm config set store-dir ~/.pnpm-store
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# It might be the path to <ROOT> turborepo
|
|
||||||
COPY . .
|
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" }
|
|
||||||
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
|
# Generate version from git (fallback if APP_VERSION not provided)
|
||||||
ENV CI=true
|
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=@kan/web --scope=@kan/db --docker
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 3: Install dependencies
|
||||||
|
# ============================================
|
||||||
|
FROM alpine AS deps
|
||||||
WORKDIR /app
|
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-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
|
||||||
# First install the dependencies (as they change less often)
|
ENV CI=true
|
||||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code of isolated subworkspace
|
|
||||||
COPY --from=pruner /app/out/full/ .
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
RUN pnpm build --filter=${PROJECT}
|
# Stage 4: Build the web application
|
||||||
|
# ============================================
|
||||||
# # Copy static files to standalone directory
|
FROM alpine AS builder
|
||||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
ARG APP_VERSION
|
||||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
|
||||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
|
||||||
|
|
||||||
# 4. Final image - runner stage to run the application
|
|
||||||
FROM base AS runner
|
|
||||||
ARG APP_DIRNAME
|
|
||||||
|
|
||||||
# Don't run production as root
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/ ./
|
||||||
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||||
|
|
||||||
|
# Force standalone output for production Docker image
|
||||||
|
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||||
|
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 5: Migration image (run-once container)
|
||||||
|
# ============================================
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS migrate
|
||||||
|
WORKDIR /db
|
||||||
|
|
||||||
|
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
||||||
|
COPY packages/db/migrations/ ./migrations/
|
||||||
|
|
||||||
|
RUN npm init -y && \
|
||||||
|
npm install drizzle-kit drizzle-orm pg --save-exact && \
|
||||||
|
# Strip unnecessary files from node_modules
|
||||||
|
find node_modules -type f \( \
|
||||||
|
-name '*.d.ts' -o \
|
||||||
|
-name '*.d.mts' -o \
|
||||||
|
-name '*.d.cts' -o \
|
||||||
|
-name '*.map' -o \
|
||||||
|
-name '*.md' -o \
|
||||||
|
-name '*.txt' -o \
|
||||||
|
-name 'LICENSE*' -o \
|
||||||
|
-name 'CHANGELOG*' -o \
|
||||||
|
-name 'README*' -o \
|
||||||
|
-name '.eslint*' -o \
|
||||||
|
-name '.prettier*' -o \
|
||||||
|
-name 'tsconfig*.json' \
|
||||||
|
\) -delete && \
|
||||||
|
find node_modules -type d -empty -delete && \
|
||||||
|
rm -rf /root/.npm /tmp/*
|
||||||
|
|
||||||
|
CMD ["npx", "drizzle-kit", "migrate"]
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 6: Production web image (distroless)
|
||||||
|
# ============================================
|
||||||
|
FROM ${DISTROLESS_NODE_IMAGE} AS web
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
# Copy the standalone Next.js server
|
||||||
WORKDIR /app/apps/${APP_DIRNAME}
|
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||||
|
# Copy static assets and public files
|
||||||
|
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||||
|
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||||
|
|
||||||
ARG PORT=3000
|
# Copy bootstrap script for runtime env var injection
|
||||||
ENV PORT=${PORT}
|
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||||
EXPOSE ${PORT}
|
|
||||||
|
|
||||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
EXPOSE 3000
|
||||||
|
CMD ["bootstrap.cjs"]
|
||||||
|
|||||||
44
apps/web/bootstrap.cjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Bootstrap script for the distroless production image.
|
||||||
|
*
|
||||||
|
* Distroless images have no shell, so this Node.js script handles two tasks
|
||||||
|
* that would normally be done in an entrypoint.sh:
|
||||||
|
*
|
||||||
|
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
||||||
|
* environment variables. The file was originally created at build time by
|
||||||
|
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
||||||
|
* env vars are provided at *run* time via docker-compose / docker run.
|
||||||
|
*
|
||||||
|
* 2. Start the Next.js standalone server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const publicDir = path.join(__dirname, "apps", "web", "public");
|
||||||
|
|
||||||
|
if (!existsSync(publicDir)) {
|
||||||
|
mkdirSync(publicDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const envVars = {};
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (key.startsWith("NEXT_PUBLIC_")) {
|
||||||
|
envVars[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(publicDir, "__ENV.js"),
|
||||||
|
`self.__ENV = ${JSON.stringify(envVars)};`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Start the Next.js standalone server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
require("./apps/web/server.js");
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": 0,
|
"version": "1.10",
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"json": {
|
||||||
"include": ["src/locales/[locale]/messages.po"]
|
"include": ["src/locales/[locale]/messages.json"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||||
|
|||||||
3396
apps/web/i18n.lock
@@ -1,7 +1,9 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf";
|
import { defineConfig } from "@lingui/cli";
|
||||||
|
import { formatter } from "@lingui/format-json";
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
export default defineConfig({
|
||||||
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
|
|||||||
exclude: ["**/node_modules/**"],
|
exclude: ["**/node_modules/**"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
format: formatter({ style: "lingui" }),
|
||||||
|
});
|
||||||
export default config;
|
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ const config = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
/** Exclude build tools and dev-only packages from the standalone output */
|
||||||
|
outputFileTracingExcludes: {
|
||||||
|
"**/*": [
|
||||||
|
"@esbuild/**",
|
||||||
|
"esbuild/**",
|
||||||
|
"typescript/**",
|
||||||
|
"webpack/**",
|
||||||
|
"uglify-js/**",
|
||||||
|
"terser/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@kan/api",
|
"@kan/api",
|
||||||
@@ -26,34 +38,32 @@ const config = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
eslint: { ignoreDuringBuilds: true },
|
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
|
// temporarily ignore eslint errors during build until we fix all the errors sigh
|
||||||
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: (() => {
|
||||||
{
|
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||||
protocol: "https",
|
const patterns = [
|
||||||
hostname:
|
{ protocol: "https", hostname: "**" },
|
||||||
env("S3_FORCE_PATH_STYLE") === "true"
|
{
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
protocol: "http",
|
||||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
hostname: "localhost",
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
protocol: "http",
|
|
||||||
hostname: "localhost",
|
return patterns;
|
||||||
},
|
})(),
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "*.googleusercontent.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
webpack(config) {
|
turbopack: {
|
||||||
config.module.rules.push({
|
rules: {
|
||||||
test: /\.svg$/,
|
"*.svg": {
|
||||||
use: ["@svgr/webpack"],
|
loaders: ["@svgr/webpack"],
|
||||||
});
|
as: "*.js",
|
||||||
return config;
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
// instrumentationHook: true,
|
// instrumentationHook: true,
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build",
|
"build": "pnpm with-env next build --turbo",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"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",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --",
|
"with-env": "dotenv -e ../../.env --",
|
||||||
"lingui:extract": "lingui extract",
|
"lingui:extract": "lingui extract",
|
||||||
@@ -18,23 +20,27 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.802.0",
|
"@aws-sdk/client-s3": "^3.802.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.802.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@kan/api": "workspace:*",
|
"@kan/api": "workspace:*",
|
||||||
"@kan/auth": "workspace:*",
|
"@kan/auth": "workspace:*",
|
||||||
"@kan/db": "workspace:^",
|
"@kan/db": "workspace:^",
|
||||||
|
"@kan/logger": "workspace:^",
|
||||||
"@kan/shared": "workspace:^",
|
"@kan/shared": "workspace:^",
|
||||||
"@lingui/babel-preset-react": "^2.9.2",
|
"@lingui/babel-preset-react": "^2.9.2",
|
||||||
"@lingui/conf": "^5.3.2",
|
"@lingui/conf": "^5.3.2",
|
||||||
"@lingui/macro": "^5.3.2",
|
"@lingui/macro": "^5.3.2",
|
||||||
"@lingui/react": "^5.3.2",
|
"@lingui/react": "^5.3.2",
|
||||||
|
"@novu/api": "^3.11.0",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tiptap/extension-link": "^2.22.2",
|
"@tiptap/extension-link": "^2.22.2",
|
||||||
"@tiptap/extension-mention": "^3.0.9",
|
"@tiptap/extension-mention": "^3.0.9",
|
||||||
"@tiptap/extension-placeholder": "^2.14.0",
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
|
"@tiptap/extension-typography": "^3.18.0",
|
||||||
"@tiptap/pm": "^2.14.0",
|
"@tiptap/pm": "^2.14.0",
|
||||||
"@tiptap/react": "^2.14.0",
|
"@tiptap/react": "^2.14.0",
|
||||||
"@tiptap/starter-kit": "^2.14.0",
|
"@tiptap/starter-kit": "^2.14.0",
|
||||||
@@ -43,15 +49,14 @@
|
|||||||
"@trpc/next": "^11.0.0-rc.660",
|
"@trpc/next": "^11.0.0-rc.660",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"aws-sdk": "^2.1692.0",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.26.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"next": "^15.3.4",
|
"jose": "^6.1.2",
|
||||||
"next-logger": "^5.0.1",
|
"next": "15.5.9",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-cors": "^2.2.0",
|
"nextjs-cors": "^2.2.0",
|
||||||
"pino": "^9.6.0",
|
|
||||||
"posthog-js": "^1.254.0",
|
"posthog-js": "^1.254.0",
|
||||||
"react": "catalog:react18",
|
"react": "catalog:react18",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
@@ -65,6 +70,7 @@
|
|||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
|
"tiptap-markdown": "^0.8",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -74,6 +80,7 @@
|
|||||||
"@kan/tailwind-config": "workspace:*",
|
"@kan/tailwind-config": "workspace:*",
|
||||||
"@kan/tsconfig": "workspace:*",
|
"@kan/tsconfig": "workspace:*",
|
||||||
"@lingui/cli": "^5.3.2",
|
"@lingui/cli": "^5.3.2",
|
||||||
|
"@lingui/format-json": "^5.9.1",
|
||||||
"@lingui/loader": "^5.3.2",
|
"@lingui/loader": "^5.3.2",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.5.2",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
@@ -84,10 +91,10 @@
|
|||||||
"dotenv-cli": "^7.4.4",
|
"dotenv-cli": "^7.4.4",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^1.21.6",
|
"jiti": "^1.21.6",
|
||||||
"pino-pretty": "^13.0.0",
|
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config"
|
"prettier": "@kan/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/web/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
39
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"background_color": "#000000",
|
||||||
|
"categories": [
|
||||||
|
"business",
|
||||||
|
"productivity",
|
||||||
|
"utilities"
|
||||||
|
],
|
||||||
|
"description": "The open source Trello alternative.",
|
||||||
|
"display": "standalone",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"sizes": "512x512",
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "/",
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"sizes": "1624x1561",
|
||||||
|
"src": "/screenshot-wide.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sizes": "1290x2796",
|
||||||
|
"src": "/screenshot-narrow.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"name": "Kan.bn | The open source alternative to Trello",
|
||||||
|
"scope": "/",
|
||||||
|
"short_name": "Kan.bn",
|
||||||
|
"start_url": "/",
|
||||||
|
"theme_color": "#000000"
|
||||||
|
}
|
||||||
BIN
apps/web/public/screenshot-narrow.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/web/public/screenshot-wide.png
Normal file
|
After Width: | Height: | Size: 99 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 |
@@ -303,6 +303,10 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
|
|
||||||
const password = watch("password");
|
const password = watch("password");
|
||||||
|
|
||||||
|
const isMagicLinkAvailable = useMemo(() => {
|
||||||
|
return isCloudEnv || (isEmailSendingEnabled && !isSignUp);
|
||||||
|
}, [isCloudEnv, isEmailSendingEnabled, isSignUp]);
|
||||||
|
|
||||||
// Determine if we should operate in magic link mode for current form state (login only)
|
// Determine if we should operate in magic link mode for current form state (login only)
|
||||||
const isMagicLinkMode = useMemo(() => {
|
const isMagicLinkMode = useMemo(() => {
|
||||||
// Magic link only viable when email sending enabled AND not sign up.
|
// Magic link only viable when email sending enabled AND not sign up.
|
||||||
@@ -357,71 +361,88 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
||||||
{socialProviders?.length !== 0 && (
|
socialProviders?.length === 0 && (
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
<div className="flex w-full items-center gap-4">
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||||
{t`or`}
|
{t`No authentication methods are currently available`}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||||
{isSignUp && isCredentialsEnabled && (
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
{socialProviders?.length !== 0 && (
|
||||||
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{t`or`}
|
||||||
|
</span>
|
||||||
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isSignUp && isCredentialsEnabled && (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
{...register("name", { required: true })}
|
||||||
|
placeholder={t`Enter your name`}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
{t`Please enter a valid name`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
{...register("name", { required: true })}
|
{...register("email", { required: true })}
|
||||||
placeholder={t`Enter your name`}
|
placeholder={t`Enter your email address`}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.email && (
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{t`Please enter a valid name`}
|
{t`Please enter a valid email address`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div>
|
{isCredentialsEnabled && (
|
||||||
<Input
|
<div>
|
||||||
{...register("email", { required: true })}
|
<Input
|
||||||
placeholder={t`Enter your email address`}
|
type="password"
|
||||||
/>
|
{...register("password", { required: true })}
|
||||||
{errors.email && (
|
placeholder={t`Enter your password`}
|
||||||
<p className="mt-2 text-xs text-red-400">
|
/>
|
||||||
{t`Please enter a valid email address`}
|
{errors.password && (
|
||||||
</p>
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
{errors.password.message ??
|
||||||
|
t`Please enter a valid password`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loginError && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCredentialsEnabled && (
|
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||||
<div>
|
<Button
|
||||||
<Input
|
isLoading={isLoginWithEmailPending}
|
||||||
type="password"
|
fullWidth
|
||||||
{...register("password", { required: true })}
|
size="lg"
|
||||||
placeholder={t`Enter your password`}
|
variant="secondary"
|
||||||
/>
|
>
|
||||||
{errors.password && (
|
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||||
<p className="mt-2 text-xs text-red-400">
|
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||||
{errors.password.message ?? t`Please enter a valid password`}
|
</Button>
|
||||||
</p>
|
</div>
|
||||||
)}
|
</form>
|
||||||
</div>
|
)}
|
||||||
)}
|
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
||||||
{loginError && (
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
isLoading={isLoginWithEmailPending}
|
|
||||||
fullWidth
|
|
||||||
size="lg"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
|
||||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const initials = name
|
const initials = name?.trim()
|
||||||
? getInitialsFromName(name)
|
? getInitialsFromName(name)
|
||||||
: inferInitialsFromEmail(email);
|
: inferInitialsFromEmail(email);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
href?: string;
|
href?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
openInNewTab?: boolean;
|
openInNewTab?: boolean;
|
||||||
|
iconOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = ({
|
const Button = ({
|
||||||
@@ -24,6 +25,7 @@ const Button = ({
|
|||||||
href,
|
href,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
openInNewTab,
|
openInNewTab,
|
||||||
|
iconOnly,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) => {
|
}: ButtonProps) => {
|
||||||
const classes = twMerge(
|
const classes = twMerge(
|
||||||
@@ -32,6 +34,15 @@ const Button = ({
|
|||||||
size === "sm" && "text-xs",
|
size === "sm" && "text-xs",
|
||||||
size === "lg" && "py-[0.65rem]",
|
size === "lg" && "py-[0.65rem]",
|
||||||
fullWidth && "w-full",
|
fullWidth && "w-full",
|
||||||
|
iconOnly && "p-0",
|
||||||
|
iconOnly &&
|
||||||
|
(size === "xs"
|
||||||
|
? "h-6 w-6"
|
||||||
|
: size === "sm"
|
||||||
|
? "h-8 w-8"
|
||||||
|
: size === "lg"
|
||||||
|
? "h-10 w-10"
|
||||||
|
: "h-9 w-9"),
|
||||||
variant === "primary" &&
|
variant === "primary" &&
|
||||||
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
||||||
variant === "secondary" &&
|
variant === "secondary" &&
|
||||||
@@ -47,19 +58,60 @@ const Button = ({
|
|||||||
<span className="relative flex items-center justify-center">
|
<span className="relative flex items-center justify-center">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="absolute">
|
<span className="absolute">
|
||||||
<LoadingSpinner size={size} />
|
<LoadingSpinner size={size === "xs" ? "sm" : size} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div
|
{iconOnly ? (
|
||||||
className={twMerge(
|
<div
|
||||||
"flex items-center",
|
className={twMerge(
|
||||||
isLoading ? "invisible" : "visible",
|
"flex items-center",
|
||||||
)}
|
isLoading ? "invisible" : "visible",
|
||||||
>
|
)}
|
||||||
{iconLeft && <span className="mr-2">{iconLeft}</span>}
|
>
|
||||||
{children}
|
{iconLeft ?? iconRight}
|
||||||
{iconRight && <span className="ml-1">{iconRight}</span>}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
fullWidth
|
||||||
|
? "grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2"
|
||||||
|
: "flex items-center",
|
||||||
|
isLoading ? "invisible" : "visible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fullWidth && !iconLeft && iconRight && (
|
||||||
|
<span className="col-start-1 opacity-0">{iconRight}</span>
|
||||||
|
)}
|
||||||
|
{iconLeft && (
|
||||||
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
fullWidth ? "col-start-1 justify-self-start" : "mr-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{iconLeft}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
fullWidth ? "col-start-2 justify-self-center text-center" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
{iconRight && (
|
||||||
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
fullWidth ? "col-start-3 justify-self-end" : "ml-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{iconRight}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{fullWidth && !iconRight && iconLeft && (
|
||||||
|
<span className="col-start-3 opacity-0">{iconLeft}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
|
|||||||
handleEdit?: (key: string) => void;
|
handleEdit?: (key: string) => void;
|
||||||
handleCreate?: () => void;
|
handleCreate?: () => void;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckboxDropdown({
|
export default function CheckboxDropdown({
|
||||||
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
asChild = true,
|
asChild = true,
|
||||||
|
disabled = false,
|
||||||
}: CheckboxDropdownProps) {
|
}: CheckboxDropdownProps) {
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
|
|||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Menu.Item key={item.key}>
|
<Menu.Item key={item.key}>
|
||||||
<div
|
<div
|
||||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={item.key}
|
id={item.key}
|
||||||
name={item.key}
|
name={item.key}
|
||||||
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
|
|||||||
<>
|
<>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
as={asChild ? "div" : undefined}
|
as={asChild ? "div" : undefined}
|
||||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
disabled={disabled}
|
||||||
|
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ export default function CommandPallette({
|
|||||||
value={result}
|
value={result}
|
||||||
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("clicked", url);
|
|
||||||
void router.push(url);
|
void router.push(url);
|
||||||
onClose();
|
onClose();
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
|
|||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -44,6 +45,12 @@ export default function Dashboard({
|
|||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||||
|
|
||||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||||
|
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
enabled: !!session?.user,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||||
@@ -155,8 +162,12 @@ export default function Dashboard({
|
|||||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||||
>
|
>
|
||||||
<SideNavigation
|
<SideNavigation
|
||||||
user={{ email: session?.user.email, image: session?.user.image }}
|
user={{
|
||||||
isLoading={sessionLoading}
|
displayName: user?.name ?? session?.user.name,
|
||||||
|
email: user?.email ?? session?.user.email ?? "",
|
||||||
|
image: user?.image ?? undefined,
|
||||||
|
}}
|
||||||
|
isLoading={sessionLoading || userLoading}
|
||||||
onCloseSideNav={closeSideNav}
|
onCloseSideNav={closeSideNav}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
141
apps/web/src/components/DateSelector.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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;
|
||||||
|
weekStartsOn?: 0 | 1 | 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateSelector = ({
|
||||||
|
selectedDate,
|
||||||
|
onDateSelect,
|
||||||
|
weekStartsOn = 1,
|
||||||
|
}: 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 });
|
||||||
|
return eachDayOfInterval({
|
||||||
|
start: weekStart,
|
||||||
|
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||||
|
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||||
|
}, [weekStartsOn]);
|
||||||
|
|
||||||
|
const days = useMemo(() => {
|
||||||
|
const monthStart = startOfMonth(currentMonth);
|
||||||
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
|
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
|
||||||
|
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||||
|
|
||||||
|
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, weekStartsOn]);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
|||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -30,13 +30,14 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label}>
|
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||||
<button
|
<button
|
||||||
onClick={item.action}
|
onClick={item.action}
|
||||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
disabled={item.disabled ?? !item.action}
|
||||||
|
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Range as TiptapRange } from "@tiptap/core";
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import type {
|
import type {
|
||||||
SuggestionKeyDownProps,
|
SuggestionKeyDownProps,
|
||||||
@@ -7,6 +8,7 @@ import type { Instance as TippyInstance } from "tippy.js";
|
|||||||
import { Button } from "@headlessui/react";
|
import { Button } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Mention from "@tiptap/extension-mention";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import {
|
import {
|
||||||
BubbleMenu,
|
BubbleMenu,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
ReactRenderer,
|
ReactRenderer,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
|
import Typography from "@tiptap/extension-typography";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import {
|
import {
|
||||||
@@ -39,9 +42,11 @@ import {
|
|||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import Mention from "@tiptap/extension-mention";
|
import { Markdown } from "tiptap-markdown";
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -54,7 +59,7 @@ declare module "@tiptap/core" {
|
|||||||
export interface SlashCommandItem {
|
export interface SlashCommandItem {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +82,7 @@ export interface RenderSuggestionsProps {
|
|||||||
command: (item: SlashCommandItem) => void;
|
command: (item: SlashCommandItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceMember = {
|
export interface WorkspaceMember {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -85,7 +90,7 @@ export type WorkspaceMember = {
|
|||||||
image: string | null;
|
image: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
const CommandsList = forwardRef<
|
const CommandsList = forwardRef<
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
@@ -200,7 +205,11 @@ const RenderSuggestions = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type MentionItem = { id: string; label: string; image: string | null };
|
interface MentionItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
image: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const MentionList = forwardRef<
|
const MentionList = forwardRef<
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
@@ -238,30 +247,32 @@ const MentionList = forwardRef<
|
|||||||
return (
|
return (
|
||||||
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||||
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
||||||
{items.length > 0 ? items.map((item, index) => (
|
{items.length > 0 ? (
|
||||||
<button
|
items.map((item, index) => (
|
||||||
key={item.id}
|
<button
|
||||||
onClick={() => command(item)}
|
key={item.id}
|
||||||
className={twMerge(
|
onClick={() => command(item)}
|
||||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
className={twMerge(
|
||||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||||
)}
|
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||||
>
|
)}
|
||||||
<Avatar
|
>
|
||||||
size="xs"
|
<Avatar
|
||||||
name={item.label}
|
size="xs"
|
||||||
imageUrl={
|
name={item.label}
|
||||||
item.image ? getAvatarUrl(item.image) : undefined
|
imageUrl={item.image ? getAvatarUrl(item.image) : undefined}
|
||||||
}
|
email={item.label}
|
||||||
email={item.label}
|
/>
|
||||||
/>
|
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
{item.label}
|
||||||
{item.label}
|
</span>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
))
|
||||||
)) : (
|
) : (
|
||||||
<div className="flex items-center justify-start p-2">
|
<div className="flex items-center justify-start p-2">
|
||||||
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
|
<span className="text-[12px] text-dark-900 dark:text-dark-1000">
|
||||||
|
No results
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -376,46 +387,54 @@ export interface SlashNodeAttrs {
|
|||||||
label?: string | null;
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommandItems: SlashCommandItem[] = [
|
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
||||||
{
|
const headingCommands: SlashCommandItem[] = disableHeadings
|
||||||
title: "Heading 1",
|
? []
|
||||||
icon: <HiH1 />,
|
: [
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
title: "Heading 1",
|
||||||
},
|
icon: <HiH1 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 2",
|
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||||
icon: <HiH2 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
title: "Heading 2",
|
||||||
},
|
icon: <HiH2 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 3",
|
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||||
icon: <HiH3 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
title: "Heading 3",
|
||||||
},
|
icon: <HiH3 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Bullet List",
|
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||||
icon: <HiOutlineListBullet />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
];
|
||||||
},
|
|
||||||
{
|
return [
|
||||||
title: "Ordered List",
|
...headingCommands,
|
||||||
icon: <HiOutlineNumberedList />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
title: "Bullet List",
|
||||||
},
|
icon: <HiOutlineListBullet />,
|
||||||
{
|
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||||
title: "Blockquote",
|
},
|
||||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
title: "Ordered List",
|
||||||
},
|
icon: <HiOutlineNumberedList />,
|
||||||
{
|
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||||
title: "Code Block",
|
},
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
title: "Blockquote",
|
||||||
},
|
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||||
];
|
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code Block",
|
||||||
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
|
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
content,
|
content,
|
||||||
@@ -423,23 +442,26 @@ export default function Editor({
|
|||||||
onBlur,
|
onBlur,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
|
enableYouTubeEmbed = true,
|
||||||
|
placeholder,
|
||||||
|
disableHeadings = false,
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
workspaceMembers: WorkspaceMember[];
|
||||||
|
enableYouTubeEmbed?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disableHeadings?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit.configure({
|
||||||
Placeholder.configure({
|
heading: disableHeadings ? false : undefined,
|
||||||
placeholder: readOnly
|
|
||||||
? ""
|
|
||||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
@@ -450,48 +472,85 @@ export default function Editor({
|
|||||||
},
|
},
|
||||||
validate: (href) => /^https?:\/\//.test(href),
|
validate: (href) => /^https?:\/\//.test(href),
|
||||||
autolink: true,
|
autolink: true,
|
||||||
|
linkOnPaste: true,
|
||||||
|
}),
|
||||||
|
Markdown,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: readOnly
|
||||||
|
? ""
|
||||||
|
: placeholder ??
|
||||||
|
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||||
}),
|
}),
|
||||||
SlashCommands.configure({
|
SlashCommands.configure({
|
||||||
commandItems: CommandItems,
|
commandItems: getCommandItems(disableHeadings),
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: ({ query }: { query: string }) =>
|
items: ({ query }: { query: string }) =>
|
||||||
filterSlashCommandItems(CommandItems, query),
|
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
||||||
startOfLine: true,
|
startOfLine: true,
|
||||||
char: "/",
|
char: "/",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'mention',
|
class: "mention",
|
||||||
},
|
},
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: "@",
|
char: "@",
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
|
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||||
|
|
||||||
|
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||||
id: member.publicId,
|
id: member.publicId,
|
||||||
label: member?.user?.name ?? member.email,
|
label: member?.user?.name?.trim() || member.email || "",
|
||||||
image: member?.user?.image ?? null,
|
image: member?.user?.image ?? null,
|
||||||
}));
|
}));
|
||||||
const q = query.toLowerCase();
|
|
||||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
const all: MentionItem[] = mapped.filter(
|
||||||
|
(item) => item.label && item.label.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (q === "") {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = all.filter((u) =>
|
||||||
|
u.label.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
return filtered;
|
||||||
},
|
},
|
||||||
command: ({ editor, range, props }: any) => {
|
command: ({ editor, range, props }) => {
|
||||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
const id = props.id ?? "";
|
||||||
|
const label = props.label ?? "";
|
||||||
editor
|
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span> `;
|
||||||
.chain()
|
|
||||||
.focus()
|
editor
|
||||||
.deleteRange(range)
|
.chain()
|
||||||
.insertContent(mentionHTML)
|
.focus()
|
||||||
.focus()
|
.deleteRange(range)
|
||||||
.run();
|
.insertContent(mentionHTML)
|
||||||
},
|
.focus()
|
||||||
render: renderMentionSuggestions,
|
.run();
|
||||||
},
|
|
||||||
renderText({ options, node }) {
|
|
||||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
|
||||||
},
|
},
|
||||||
|
render: renderMentionSuggestions,
|
||||||
|
},
|
||||||
|
renderText({ options, node }) {
|
||||||
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
Typography.configure({
|
||||||
|
openDoubleQuote: false,
|
||||||
|
closeDoubleQuote: false,
|
||||||
|
openSingleQuote: false,
|
||||||
|
closeSingleQuote: false,
|
||||||
|
oneHalf: false,
|
||||||
|
oneQuarter: false,
|
||||||
|
threeQuarters: false,
|
||||||
|
superscriptTwo: false,
|
||||||
|
superscriptThree: false,
|
||||||
|
}),
|
||||||
|
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||||
@@ -548,6 +607,9 @@ export default function Editor({
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.tiptap [data-youtube] {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
|
|||||||
32
apps/web/src/components/FontSizeSelector.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { useFontSize, type FontSize } from "~/providers/font-size";
|
||||||
|
|
||||||
|
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
|
||||||
|
{ value: "small", label: () => t`Small` },
|
||||||
|
{ value: "medium", label: () => t`Medium` },
|
||||||
|
{ value: "large", label: () => t`Large` },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FontSizeSelector() {
|
||||||
|
const { fontSize, setFontSize } = useFontSize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<select
|
||||||
|
id="font-size-select"
|
||||||
|
value={fontSize}
|
||||||
|
onChange={(e) => setFontSize(e.target.value as FontSize)}
|
||||||
|
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
|
||||||
|
>
|
||||||
|
{fontSizeOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ export function LabelForm({
|
|||||||
reset(newFormState);
|
reset(newFormState);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -231,9 +231,7 @@ export function LabelForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||||
disabled={
|
disabled={!watch("name")}
|
||||||
!watch("name")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isEdit ? t`Update label` : t`Create label`}
|
{isEdit ? t`Update label` : t`Create label`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,13 +24,16 @@ import { api } from "~/utils/api";
|
|||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, { message: t`Workspace name is required` }),
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: t`Workspace name is required` })
|
||||||
|
.max(64, { message: t`Workspace name cannot exceed 64 characters` }),
|
||||||
slug: z
|
slug: z
|
||||||
.string()
|
.string()
|
||||||
.min(3, {
|
.min(3, {
|
||||||
message: t`URL must be at least 3 characters long`,
|
message: t`URL must be at least 3 characters long`,
|
||||||
})
|
})
|
||||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||||
})
|
})
|
||||||
@@ -108,6 +111,7 @@ export function NewWorkspaceForm() {
|
|||||||
slug: values.slug,
|
slug: values.slug,
|
||||||
plan: values.plan,
|
plan: values.plan,
|
||||||
role: "admin",
|
role: "admin",
|
||||||
|
weekStartDay: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const PageHead = ({ title }: { title: string }) => {
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
</Head>
|
</Head>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
130
apps/web/src/components/PlainTextEditor.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
interface PlainTextEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onBlur?: (value: string) => void;
|
||||||
|
onEnter?: (value: string) => void;
|
||||||
|
onEscape?: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlainTextEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
onEnter,
|
||||||
|
onEscape,
|
||||||
|
readOnly = false,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: PlainTextEditorProps) {
|
||||||
|
const onEnterRef = useRef(onEnter);
|
||||||
|
const onEscapeRef = useRef(onEscape);
|
||||||
|
const onBlurRef = useRef(onBlur);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const contentRef = useRef(content);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEnterRef.current = onEnter;
|
||||||
|
}, [onEnter]);
|
||||||
|
useEffect(() => {
|
||||||
|
onEscapeRef.current = onEscape;
|
||||||
|
}, [onEscape]);
|
||||||
|
useEffect(() => {
|
||||||
|
onBlurRef.current = onBlur;
|
||||||
|
}, [onBlur]);
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
contentRef.current = content;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const editor = useEditor(
|
||||||
|
{
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
strike: false,
|
||||||
|
code: false,
|
||||||
|
codeBlock: false,
|
||||||
|
blockquote: false,
|
||||||
|
heading: false,
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false,
|
||||||
|
listItem: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
hardBreak: false,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({ placeholder }),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editable: !readOnly,
|
||||||
|
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
|
||||||
|
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
|
||||||
|
editorProps: {
|
||||||
|
handleKeyDown: (view, event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
onEnterRef.current?.(view.state.doc.textContent.trim());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
// Reset to original content before calling the callback
|
||||||
|
editor?.commands.setContent(contentRef.current, false);
|
||||||
|
editor?.commands.blur();
|
||||||
|
onEscapeRef.current?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
attributes: {
|
||||||
|
class: "outline-none focus:outline-none focus-visible:ring-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
if (content !== editor.getText()) {
|
||||||
|
editor.commands.setContent(content, false);
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.setEditable(!readOnly);
|
||||||
|
}, [readOnly, editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style jsx global>{`
|
||||||
|
.plain-text-editor p.is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--placeholder-color, #9ca3af);
|
||||||
|
}
|
||||||
|
.plain-text-editor .tiptap p {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<EditorContent
|
||||||
|
editor={editor}
|
||||||
|
className={twMerge("plain-text-editor", className)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||||
<div className="p-4">
|
<div className="p-4 relative">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 mt-1">
|
||||||
{popupIcon === "success" && (
|
{popupIcon === "success" && (
|
||||||
<HiOutlineCheckCircle
|
<HiOutlineCheckCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-6 w-6 text-green-400"
|
className="h-5 w-5 text-green-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{popupIcon === "error" && (
|
{popupIcon === "error" && (
|
||||||
<HiOutlineExclamationCircle
|
<HiOutlineExclamationCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-6 w-6 text-red-400"
|
className="h-5 w-5 text-red-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
||||||
{popupHeader}
|
{popupHeader}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
||||||
{popupMessage}
|
{popupMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex flex-shrink-0">
|
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
<HiXMark
|
<HiXMark
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-dark-900"
|
className="h-4 w-4 text-dark-900"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import LottieIcon from "~/components/LottieIcon";
|
import LottieIcon from "~/components/LottieIcon";
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||||
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
|
|
||||||
const Button: React.FC<{
|
const Button: React.FC<{
|
||||||
href: string;
|
href: string;
|
||||||
@@ -12,10 +14,20 @@ const Button: React.FC<{
|
|||||||
json: object;
|
json: object;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCloseSideNav?: () => void;
|
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 [isHovered, setIsHovered] = useState(false);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
@@ -35,17 +47,27 @@ const Button: React.FC<{
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={twMerge(
|
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",
|
"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
|
current
|
||||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
? "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",
|
: "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}
|
title={isCollapsed ? name : undefined}
|
||||||
>
|
>
|
||||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
<div
|
||||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
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>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
HiChevronDown,
|
HiChevronDown,
|
||||||
HiOutlineBanknotes,
|
HiOutlineBanknotes,
|
||||||
|
HiOutlineBolt,
|
||||||
HiOutlineCodeBracketSquare,
|
HiOutlineCodeBracketSquare,
|
||||||
HiOutlineRectangleGroup,
|
HiOutlineRectangleGroup,
|
||||||
|
HiOutlineShieldCheck,
|
||||||
HiOutlineUser,
|
HiOutlineUser,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -24,8 +28,12 @@ interface SettingsLayoutProps {
|
|||||||
|
|
||||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
||||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||||
|
|
||||||
|
const isAdmin = workspace.role === "admin";
|
||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{
|
{
|
||||||
key: "account",
|
key: "account",
|
||||||
@@ -37,13 +45,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "workspace",
|
key: "workspace",
|
||||||
icon: <HiOutlineRectangleGroup />,
|
icon: <HiOutlineRectangleGroup />,
|
||||||
label: t`Workspace`,
|
label: t`Workspace`,
|
||||||
condition: true,
|
condition: canViewWorkspace,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "permissions",
|
||||||
|
icon: <HiOutlineShieldCheck />,
|
||||||
|
label: t`Permissions`,
|
||||||
|
condition: isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
label: t`Billing`,
|
label: t`Billing`,
|
||||||
icon: <HiOutlineBanknotes />,
|
icon: <HiOutlineBanknotes />,
|
||||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "api",
|
key: "api",
|
||||||
@@ -51,11 +65,17 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
label: t`API`,
|
label: t`API`,
|
||||||
condition: true,
|
condition: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "webhooks",
|
||||||
|
icon: <HiOutlineBolt />,
|
||||||
|
label: t`Webhooks`,
|
||||||
|
condition: isAdmin,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "integrations",
|
key: "integrations",
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
label: t`Integrations`,
|
label: t`Integrations`,
|
||||||
condition: true,
|
condition: canEditWorkspace,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -97,7 +117,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
>
|
>
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||||
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
|
||||||
<HiChevronDown
|
<HiChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||||
|
|||||||
@@ -2,14 +2,20 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Button } from "@headlessui/react";
|
import { Button } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { HiBolt } from "react-icons/hi2";
|
||||||
import {
|
import {
|
||||||
TbLayoutSidebarLeftCollapse,
|
TbLayoutSidebarLeftCollapse,
|
||||||
TbLayoutSidebarLeftExpand,
|
TbLayoutSidebarLeftExpand,
|
||||||
} from "react-icons/tb";
|
} from "react-icons/tb";
|
||||||
import { twMerge } from "tailwind-merge";
|
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 boardsIconDark from "~/assets/boards-dark.json";
|
||||||
import boardsIconLight from "~/assets/boards-light.json";
|
import boardsIconLight from "~/assets/boards-light.json";
|
||||||
import membersIconDark from "~/assets/members-dark.json";
|
import membersIconDark from "~/assets/members-dark.json";
|
||||||
@@ -18,9 +24,13 @@ import settingsIconDark from "~/assets/settings-dark.json";
|
|||||||
import settingsIconLight from "~/assets/settings-light.json";
|
import settingsIconLight from "~/assets/settings-light.json";
|
||||||
import templatesIconDark from "~/assets/templates-dark.json";
|
import templatesIconDark from "~/assets/templates-dark.json";
|
||||||
import templatesIconLight from "~/assets/templates-light.json";
|
import templatesIconLight from "~/assets/templates-light.json";
|
||||||
|
import ButtonComponent from "~/components/Button";
|
||||||
import ReactiveButton from "~/components/ReactiveButton";
|
import ReactiveButton from "~/components/ReactiveButton";
|
||||||
import UserMenu from "~/components/UserMenu";
|
import UserMenu from "~/components/UserMenu";
|
||||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
interface SideNavigationProps {
|
interface SideNavigationProps {
|
||||||
user: UserType;
|
user: UserType;
|
||||||
@@ -29,6 +39,7 @@ interface SideNavigationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
|
displayName?: string | null | undefined;
|
||||||
email?: string | null | undefined;
|
email?: string | null | undefined;
|
||||||
image?: string | null | undefined;
|
image?: string | null | undefined;
|
||||||
}
|
}
|
||||||
@@ -39,13 +50,24 @@ export default function SideNavigation({
|
|||||||
onCloseSideNav,
|
onCloseSideNav,
|
||||||
}: SideNavigationProps) {
|
}: SideNavigationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [isInitialised, setIsInitialised] = useState(false);
|
const [isInitialised, setIsInitialised] = useState(false);
|
||||||
|
const { openModal } = useModal();
|
||||||
|
|
||||||
|
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||||
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
|
| Subscription[]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedState = localStorage.getItem("kan_sidebar-collapsed");
|
const savedState = localStorage.getItem("kan_sidebar-collapsed");
|
||||||
if (savedState !== null) {
|
if (savedState !== null) {
|
||||||
setIsCollapsed(JSON.parse(savedState));
|
setIsCollapsed(Boolean(JSON.parse(savedState)));
|
||||||
}
|
}
|
||||||
setIsInitialised(true);
|
setIsInitialised(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -61,30 +83,65 @@ export default function SideNavigation({
|
|||||||
|
|
||||||
const { pathname } = router;
|
const { pathname } = router;
|
||||||
|
|
||||||
const { theme, resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
const navigation = [
|
const navigation: {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: object;
|
||||||
|
keyboardShortcut: KeyboardShortcut;
|
||||||
|
}[] = [
|
||||||
{
|
{
|
||||||
name: t`Boards`,
|
name: t`Boards`,
|
||||||
href: "/boards",
|
href: "/boards",
|
||||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
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`,
|
name: t`Templates`,
|
||||||
href: "/templates",
|
href: "/templates",
|
||||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
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`,
|
name: t`Members`,
|
||||||
href: "/members",
|
href: "/members",
|
||||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
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`,
|
name: t`Settings`,
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||||
|
keyboardShortcut: {
|
||||||
|
type: "SEQUENCE",
|
||||||
|
strokes: [{ key: "G" }, { key: "S" }],
|
||||||
|
action: () => router.push("/settings"),
|
||||||
|
group: "NAVIGATION",
|
||||||
|
description: t`Go to settings`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -101,10 +158,10 @@ export default function SideNavigation({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
|
<div className="hidden h-[45px] items-center justify-between pb-3 md:flex">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<Link href="/" className="block">
|
<Link href="/" className="block">
|
||||||
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
<h1 className="pl-2 text-[16px] font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||||
kan.bn
|
kan.bn
|
||||||
</h1>
|
</h1>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -142,20 +199,49 @@ export default function SideNavigation({
|
|||||||
json={item.icon}
|
json={item.icon}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onCloseSideNav={onCloseSideNav}
|
onCloseSideNav={onCloseSideNav}
|
||||||
|
keyboardShortcut={item.keyboardShortcut}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<UserMenu
|
<UserMenu
|
||||||
email={user.email ?? ""}
|
displayName={user.displayName ?? undefined}
|
||||||
|
email={user.email ?? "Email not provided?"}
|
||||||
imageUrl={user.image ?? undefined}
|
imageUrl={user.image ?? undefined}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onCloseSideNav={onCloseSideNav}
|
onCloseSideNav={onCloseSideNav}
|
||||||
/>
|
/>
|
||||||
|
{isCloudEnv &&
|
||||||
|
!hasActiveSubscription(subscriptions, "pro") &&
|
||||||
|
!hasActiveSubscription(subscriptions, "team") && (
|
||||||
|
<div className={twMerge(isCollapsed && "flex justify-center")}>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ButtonComponent
|
||||||
|
iconLeft={<HiBolt />}
|
||||||
|
variant="secondary"
|
||||||
|
href="/settings/workspace?upgrade=pro"
|
||||||
|
aria-label="Upgrade to Pro"
|
||||||
|
title="Upgrade to Pro"
|
||||||
|
iconOnly
|
||||||
|
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ButtonComponent
|
||||||
|
iconLeft={<HiBolt />}
|
||||||
|
fullWidth
|
||||||
|
variant="secondary"
|
||||||
|
href="/settings/workspace?upgrade=pro"
|
||||||
|
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||||
|
>
|
||||||
|
{t`Upgrade to Pro`}
|
||||||
|
</ButtonComponent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ const Toggle = ({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
|
showLabel = true,
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mr-4 flex items-center justify-end">
|
<div className="mr-4 flex items-center justify-end">
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
{showLabel && (
|
||||||
{label}
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
</span>
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|||||||
54
apps/web/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
if (!content) 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",
|
||||||
|
touch: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||||
|
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
|
displayName: string | undefined;
|
||||||
email: string;
|
email: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
@@ -24,6 +27,7 @@ interface UserMenuProps {
|
|||||||
export default function UserMenu({
|
export default function UserMenu({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
email,
|
email,
|
||||||
|
displayName,
|
||||||
isLoading,
|
isLoading,
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
onCloseSideNav,
|
onCloseSideNav,
|
||||||
@@ -31,6 +35,7 @@ export default function UserMenu({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { openLegend } = useKeyboardShortcuts();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -72,7 +77,7 @@ export default function UserMenu({
|
|||||||
) : (
|
) : (
|
||||||
<Menu.Button
|
<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"
|
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 ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -99,7 +104,7 @@ export default function UserMenu({
|
|||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{email}
|
{displayName || email}
|
||||||
</span>
|
</span>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
@@ -169,6 +174,19 @@ export default function UserMenu({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
<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>
|
<Menu.Item>
|
||||||
<Link
|
<Link
|
||||||
href="mailto:support@kan.bn"
|
href="mailto:support@kan.bn"
|
||||||
@@ -210,6 +228,25 @@ export default function UserMenu({
|
|||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Button, Menu, Transition } from "@headlessui/react";
|
import { Button, Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
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 { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import CommandPallette from "./CommandPallette";
|
import CommandPallette from "./CommandPallette";
|
||||||
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
export default function WorkspaceMenu({
|
export default function WorkspaceMenu({
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
@@ -18,17 +20,17 @@ export default function WorkspaceMenu({
|
|||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
useKeyboardShortcut({
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
type: "PRESS",
|
||||||
event.preventDefault();
|
stroke: {
|
||||||
setIsOpen(true);
|
key: "k",
|
||||||
}
|
modifiers: ["META"],
|
||||||
};
|
},
|
||||||
|
action: () => setIsOpen(true),
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
description: t`Open command menu`,
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
group: "GENERAL",
|
||||||
}, []);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -48,13 +50,13 @@ export default function WorkspaceMenu({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex items-center gap-1",
|
"flex items-center justify-start gap-1",
|
||||||
isCollapsed && "md:flex-col-reverse md:items-stretch",
|
isCollapsed && "md:flex-col-reverse md:items-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"mb-1 flex h-[34px] flex-1 items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
"mb-1 flex h-[34px] min-w-0 flex-1 items-center justify-start rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||||
isCollapsed &&
|
isCollapsed &&
|
||||||
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
|
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
|
||||||
)}
|
)}
|
||||||
@@ -67,7 +69,7 @@ export default function WorkspaceMenu({
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
"ml-2 min-w-0 flex-1 truncate text-left text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -84,15 +86,17 @@ export default function WorkspaceMenu({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Button
|
<Tooltip content={commandPaletteShortcutTooltipContent}>
|
||||||
className={twMerge(
|
<Button
|
||||||
"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",
|
className={twMerge(
|
||||||
isCollapsed && "md:mb-2 md:h-9 md:w-9 md:self-center",
|
"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)}
|
)}
|
||||||
>
|
onClick={() => setIsOpen(true)}
|
||||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
>
|
||||||
</Button>
|
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
positionFromTop?: "sm" | "md" | "lg";
|
positionFromTop?: "sm" | "md" | "lg";
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
|
centered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
isVisible,
|
||||||
closeOnClickOutside,
|
closeOnClickOutside,
|
||||||
|
centered = false,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@@ -71,7 +73,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const env = createEnv({
|
|||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
|
KAN_ADMIN_API_KEY: z.string().optional(),
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
@@ -51,6 +52,8 @@ export const env = createEnv({
|
|||||||
VK_CLIENT_SECRET: z.string().optional(),
|
VK_CLIENT_SECRET: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||||
|
NOVU_API_KEY: z.string().optional(),
|
||||||
|
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
||||||
// Generic OIDC Provider
|
// Generic OIDC Provider
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -75,6 +78,7 @@ export const env = createEnv({
|
|||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||||
EMAIL_FROM: z.string().optional(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
|
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,10 +91,19 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: 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_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().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_STORAGE_DOMAIN: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
|
||||||
|
.string()
|
||||||
|
.transform((s) => (s === "" ? undefined : s))
|
||||||
|
.refine(
|
||||||
|
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
.transform((s) => (s === "" ? undefined : s))
|
||||||
@@ -125,7 +138,12 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||||
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||||
|
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
|
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
|
||||||
|
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
||||||
|
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
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 { useLingui } from "@lingui/react";
|
||||||
|
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
||||||
|
|
||||||
import type { Locale } from "~/locales";
|
import type { Locale } from "~/locales";
|
||||||
import { useLinguiContext } from "~/providers/lingui";
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
@@ -8,6 +10,20 @@ export function useLocalisation() {
|
|||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
const { locale, setLocale, availableLocales } = useLinguiContext();
|
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) => {
|
const handleSetLocale = async (newLocale: Locale) => {
|
||||||
await activateLocale(newLocale);
|
await activateLocale(newLocale);
|
||||||
setLocale(newLocale);
|
setLocale(newLocale);
|
||||||
@@ -15,9 +31,9 @@ export function useLocalisation() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
|
dateLocale: currentDateLocale,
|
||||||
setLocale: handleSetLocale,
|
setLocale: handleSetLocale,
|
||||||
availableLocales,
|
availableLocales,
|
||||||
formatDate: i18n.date,
|
|
||||||
formatNumber: i18n.number,
|
formatNumber: i18n.number,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
112
apps/web/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Permission } from "@kan/shared";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
import { WorkspaceContext } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
interface UsePermissionsResult {
|
||||||
|
permissions: Permission[];
|
||||||
|
role: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasPermission: (permission: Permission) => boolean;
|
||||||
|
canViewCard: boolean;
|
||||||
|
canCreateCard: boolean;
|
||||||
|
canEditCard: boolean;
|
||||||
|
canDeleteCard: boolean;
|
||||||
|
canCreateList: boolean;
|
||||||
|
canEditList: boolean;
|
||||||
|
canDeleteList: boolean;
|
||||||
|
canCreateBoard: boolean;
|
||||||
|
canEditBoard: boolean;
|
||||||
|
canDeleteBoard: boolean;
|
||||||
|
canArchiveBoard: boolean;
|
||||||
|
canViewComment: boolean;
|
||||||
|
canCreateComment: boolean;
|
||||||
|
canEditComment: boolean;
|
||||||
|
canDeleteComment: boolean;
|
||||||
|
canInviteMember: boolean;
|
||||||
|
canEditMember: boolean;
|
||||||
|
canRemoveMember: boolean;
|
||||||
|
canViewWorkspace: boolean;
|
||||||
|
canEditWorkspace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions(): UsePermissionsResult {
|
||||||
|
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||||
|
const workspaceContext = useContext(WorkspaceContext);
|
||||||
|
|
||||||
|
// If WorkspaceProvider is not available, return safe defaults
|
||||||
|
if (!workspaceContext) {
|
||||||
|
const emptyPermissions: UsePermissionsResult = {
|
||||||
|
permissions: [],
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
hasPermission: () => false,
|
||||||
|
canViewCard: false,
|
||||||
|
canCreateCard: false,
|
||||||
|
canEditCard: false,
|
||||||
|
canDeleteCard: false,
|
||||||
|
canCreateList: false,
|
||||||
|
canEditList: false,
|
||||||
|
canDeleteList: false,
|
||||||
|
canCreateBoard: false,
|
||||||
|
canEditBoard: false,
|
||||||
|
canDeleteBoard: false,
|
||||||
|
canArchiveBoard: false,
|
||||||
|
canViewComment: false,
|
||||||
|
canCreateComment: false,
|
||||||
|
canEditComment: false,
|
||||||
|
canDeleteComment: false,
|
||||||
|
canInviteMember: false,
|
||||||
|
canEditMember: false,
|
||||||
|
canRemoveMember: false,
|
||||||
|
canViewWorkspace: false,
|
||||||
|
canEditWorkspace: false,
|
||||||
|
};
|
||||||
|
return emptyPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace } = workspaceContext;
|
||||||
|
|
||||||
|
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||||
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
{
|
||||||
|
enabled: !!workspace.publicId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = (data?.permissions ?? []) as Permission[];
|
||||||
|
const role = data?.role ?? null;
|
||||||
|
|
||||||
|
const hasPermission = (permission: Permission): boolean => {
|
||||||
|
return permissions.includes(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
role,
|
||||||
|
isLoading,
|
||||||
|
hasPermission,
|
||||||
|
canViewCard: hasPermission("card:view"),
|
||||||
|
canCreateCard: hasPermission("card:create"),
|
||||||
|
canEditCard: hasPermission("card:edit"),
|
||||||
|
canDeleteCard: hasPermission("card:delete"),
|
||||||
|
canCreateList: hasPermission("list:create"),
|
||||||
|
canEditList: hasPermission("list:edit"),
|
||||||
|
canDeleteList: hasPermission("list:delete"),
|
||||||
|
canCreateBoard: hasPermission("board:create"),
|
||||||
|
canEditBoard: hasPermission("board:edit"),
|
||||||
|
canDeleteBoard: hasPermission("board:delete"),
|
||||||
|
canArchiveBoard: hasPermission("board:edit"),
|
||||||
|
canViewComment: hasPermission("comment:view"),
|
||||||
|
canCreateComment: hasPermission("comment:create"),
|
||||||
|
canEditComment: hasPermission("comment:edit"),
|
||||||
|
canDeleteComment: hasPermission("comment:delete"),
|
||||||
|
canInviteMember: hasPermission("member:invite"),
|
||||||
|
canEditMember: hasPermission("member:edit"),
|
||||||
|
canRemoveMember: hasPermission("member:remove"),
|
||||||
|
canViewWorkspace: hasPermission("workspace:view"),
|
||||||
|
canEditWorkspace: hasPermission("workspace:edit"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { NextRouter } from "next/router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const scrollPositions = new Map<string, number>();
|
||||||
|
|
||||||
|
export function useScrollRestore(
|
||||||
|
boardId: string | null | undefined,
|
||||||
|
scrollRef: RefObject<HTMLElement | null>,
|
||||||
|
router: NextRouter,
|
||||||
|
isReady: boolean,
|
||||||
|
) {
|
||||||
|
const restored = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!boardId) return;
|
||||||
|
|
||||||
|
restored.current = false;
|
||||||
|
|
||||||
|
const saveScrollPosition = () => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on("routeChangeStart", saveScrollPosition);
|
||||||
|
return () => router.events.off("routeChangeStart", saveScrollPosition);
|
||||||
|
}, [boardId, router.events, scrollRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (restored.current || !isReady || !boardId) return;
|
||||||
|
restored.current = true;
|
||||||
|
|
||||||
|
const saved = scrollPositions.get(boardId);
|
||||||
|
if (saved === undefined) return;
|
||||||
|
|
||||||
|
// StrictModeDroppable delays rendering by one requestAnimationFrame
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [isReady, scrollRef, boardId]);
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export async function register() {
|
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
||||||
await require("pino");
|
|
||||||
await require("next-logger");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6665
apps/web/src/locales/de/messages.json
Normal file
11311
apps/web/src/locales/en/messages.json
Normal file
6665
apps/web/src/locales/es/messages.json
Normal file
6665
apps/web/src/locales/fr/messages.json
Normal file
@@ -7,6 +7,7 @@ export const locales = [
|
|||||||
"nl",
|
"nl",
|
||||||
"ru",
|
"ru",
|
||||||
"pl",
|
"pl",
|
||||||
|
"ptbr"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
@@ -22,4 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
|
ptbr: "Português",
|
||||||
};
|
};
|
||||||
|
|||||||
6665
apps/web/src/locales/it/messages.json
Normal file
6665
apps/web/src/locales/nl/messages.json
Normal file
6665
apps/web/src/locales/pl/messages.json
Normal file
6665
apps/web/src/locales/pt-BR/messages.json
Normal file
1
apps/web/src/locales/pt-BR/messages.ts
Normal file
1
apps/web/src/locales/ptbr/messages.ts
Normal file
6665
apps/web/src/locales/ru/messages.json
Normal file
55
apps/web/src/pages/404.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t`404 - Page Not Found | kan.bn`} />
|
||||||
|
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||||
|
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||||
|
<div className="z-10 flex w-full flex-col items-center">
|
||||||
|
<Link href="/">
|
||||||
|
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
kan.bn
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
404
|
||||||
|
</p>
|
||||||
|
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
<Trans>Page not found</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
|
||||||
|
<Trans>
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
|
||||||
|
>
|
||||||
|
<Trans>Go to homepage</Trans>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/boards"
|
||||||
|
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||||
|
>
|
||||||
|
<Trans>Go to boards</Trans>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PatternedBackground />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import posthog from "posthog-js";
|
|||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { FontSizeProvider } from "~/providers/font-size";
|
||||||
|
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
||||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||||
import { ModalProvider } from "~/providers/modal";
|
import { ModalProvider } from "~/providers/modal";
|
||||||
import { PopupProvider } from "~/providers/popup";
|
import { PopupProvider } from "~/providers/popup";
|
||||||
@@ -80,21 +82,25 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
)}
|
)}
|
||||||
<script src="/__ENV.js" />
|
<script src="/__ENV.js" />
|
||||||
<main className="font-sans">
|
<main className="font-sans">
|
||||||
<LinguiProviderWrapper>
|
<KeyboardShortcutProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<LinguiProviderWrapper>
|
||||||
<ModalProvider>
|
<FontSizeProvider>
|
||||||
<PopupProvider>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
{posthogKey ? (
|
<ModalProvider>
|
||||||
<PostHogProvider client={posthog}>
|
<PopupProvider>
|
||||||
{getLayout(<Component {...pageProps} />)}
|
{posthogKey ? (
|
||||||
</PostHogProvider>
|
<PostHogProvider client={posthog}>
|
||||||
) : (
|
{getLayout(<Component {...pageProps} />)}
|
||||||
getLayout(<Component {...pageProps} />)
|
</PostHogProvider>
|
||||||
)}
|
) : (
|
||||||
</PopupProvider>
|
getLayout(<Component {...pageProps} />)
|
||||||
</ModalProvider>
|
)}
|
||||||
</ThemeProvider>
|
</PopupProvider>
|
||||||
</LinguiProviderWrapper>
|
</ModalProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</FontSizeProvider>
|
||||||
|
</LinguiProviderWrapper>
|
||||||
|
</KeyboardShortcutProvider>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,34 @@ import { toNodeHandler } from "better-auth/node";
|
|||||||
|
|
||||||
import { initAuth } from "@kan/auth/server";
|
import { initAuth } from "@kan/auth/server";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
export const auth = initAuth(createDrizzleClient());
|
export const auth = initAuth(createDrizzleClient());
|
||||||
|
|
||||||
export default toNodeHandler(auth.handler);
|
const authHandler = toNodeHandler(auth.handler);
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req, res) => {
|
||||||
|
/**
|
||||||
|
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||||
|
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||||
|
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||||
|
*/
|
||||||
|
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||||
|
if (forwardedProto) {
|
||||||
|
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||||
|
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedHost = req.headers["x-forwarded-host"];
|
||||||
|
if (forwardedHost) {
|
||||||
|
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||||
|
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await authHandler(req, res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
75
apps/web/src/pages/api/download/attatchment.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, filename } = req.query;
|
||||||
|
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
return res.status(400).json({ message: "url parameter is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Endpoint = env.S3_ENDPOINT;
|
||||||
|
|
||||||
|
if (s3Endpoint) {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ message: "Invalid URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
let allowedHost: string;
|
||||||
|
try {
|
||||||
|
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||||
|
return res.status(403).json({ message: "URL not allowed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadFilename =
|
||||||
|
typeof filename === "string"
|
||||||
|
? encodeURIComponent(filename)
|
||||||
|
: "attachment";
|
||||||
|
|
||||||
|
const upstream = await fetch(url);
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return res
|
||||||
|
.status(upstream.status)
|
||||||
|
.json({ message: "Failed to fetch attachment" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = await upstream.arrayBuffer();
|
||||||
|
return res.send(Buffer.from(buffer));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error downloading attachment:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Failed to download attachment" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
25
apps/web/src/pages/api/oss-friends.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch from Formbricks");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return res.status(200).json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching OSS friends:", error);
|
||||||
|
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
|
|||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
export default async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
) {
|
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -31,4 +31,5 @@ export default async function handler(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating portal session" });
|
return res.status(500).json({ error: "Error creating portal session" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { createNextApiContext } from "@kan/api/trpc";
|
|||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
const workspaceSlugSchema = z
|
const workspaceSlugSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(24)
|
.max(64)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||||
|
|
||||||
interface CheckoutSessionRequest {
|
interface CheckoutSessionRequest {
|
||||||
@@ -21,10 +22,9 @@ interface CheckoutSessionRequest {
|
|||||||
stripeCustomerId: string;
|
stripeCustomerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
) {
|
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -88,12 +88,16 @@ export default async function handler(
|
|||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
|
payment_method_collection: "always",
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
subscription_data: {
|
||||||
|
trial_period_days: 14,
|
||||||
|
},
|
||||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||||
client_reference_id: workspacePublicId,
|
client_reference_id: workspacePublicId,
|
||||||
@@ -111,4 +115,5 @@ export default async function handler(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating checkout session" });
|
return res.status(500).json({ error: "Error creating checkout session" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import type { Readable } from "node:stream";
|
|||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { createLogger } from "@kan/logger";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
|
const log = createLogger("stripe-webhook");
|
||||||
|
|
||||||
async function buffer(readable: Readable) {
|
async function buffer(readable: Readable) {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for await (const chunk of readable) {
|
for await (const chunk of readable) {
|
||||||
@@ -41,6 +44,8 @@ export default async function handler(
|
|||||||
|
|
||||||
const { db } = await createNextApiContext(req);
|
const { db } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "checkout.session.completed": {
|
case "checkout.session.completed": {
|
||||||
const checkoutSession = event.data.object;
|
const checkoutSession = event.data.object;
|
||||||
@@ -56,12 +61,12 @@ export default async function handler(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.log(`Unhandled event type: ${event.type}`);
|
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({ received: true });
|
return res.status(200).json({ received: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Webhook error:", err);
|
log.error({ err }, "Stripe webhook handler failed");
|
||||||
return res.status(400).json({ message: "Webhook handler failed" });
|
return res.status(400).json({ message: "Webhook handler failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { integrations } from "@kan/db/schema";
|
import { integrations } from "@kan/db/schema";
|
||||||
import { addYears } from "date-fns";
|
import { addYears } from "date-fns";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
export default async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
) {
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -48,4 +48,5 @@ export default async function handler(
|
|||||||
console.error("Trello authentication error:", err);
|
console.error("Trello authentication error:", err);
|
||||||
return res.status(400).json({ message: "Trello authentication failed" });
|
return res.status(400).json({ message: "Trello authentication failed" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
);
|
||||||