Compare commits
100 Commits
feat/virtu
...
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 |
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -28,12 +28,14 @@ S3_ENDPOINT=
|
|||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
S3_FORCE_PATH_STYLE=
|
S3_FORCE_PATH_STYLE=
|
||||||
|
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
|
||||||
NEXT_PUBLIC_STORAGE_URL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||||
|
|
||||||
|
|
||||||
# Auth config (optional)
|
# Auth config (optional)
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
@@ -49,6 +51,9 @@ TRELLO_APP_SECRET=
|
|||||||
# If not provided, rate limiting will use in-memory storage
|
# If not provided, rate limiting will use in-memory storage
|
||||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
||||||
|
|
||||||
|
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||||
|
LOG_LEVEL=
|
||||||
|
|
||||||
# OAuth providers (optional)
|
# OAuth providers (optional)
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||||
|
|||||||
56
.github/workflows/docker-publish.yml
vendored
56
.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
|
||||||
@@ -74,7 +82,22 @@ 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
|
# Extract version from git tag or ref
|
||||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||||
@@ -129,6 +152,22 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Build and push migrate Docker image
|
||||||
|
id: build-and-push-migrate
|
||||||
|
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/web/Dockerfile
|
||||||
|
target: migrate
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-migrate.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
# repository is public to avoid leaking data. If you would like to publish
|
# repository is public to avoid leaking data. If you would like to publish
|
||||||
@@ -143,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
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
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,3 +54,5 @@ i18n.cache
|
|||||||
|
|
||||||
# pgdata
|
# pgdata
|
||||||
/apps/web/pgdata
|
/apps/web/pgdata
|
||||||
|
|
||||||
|
.claude
|
||||||
20
AGENTS.md
20
AGENTS.md
@@ -185,9 +185,17 @@ Use `assertUserInWorkspace` helper for workspace checks.
|
|||||||
|
|
||||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
||||||
- Provide user-friendly error messages
|
- Provide user-friendly error messages
|
||||||
- Log errors appropriately
|
- Log errors appropriately using the `@kan/logger` package
|
||||||
- Show popup notifications for user-facing errors
|
- Show popup notifications for user-facing errors
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
|
||||||
|
- Create a module-scoped logger: `const logger = createLogger("module-name")`
|
||||||
|
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
|
||||||
|
- Defaults to `debug` in development, `info` in production
|
||||||
|
- Never use `console.log` — always use the logger
|
||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
|
|
||||||
### Creating a Card
|
### Creating a Card
|
||||||
@@ -219,6 +227,16 @@ Use `assertUserInWorkspace` helper for workspace checks.
|
|||||||
5. **Frontend**: Add UI components in `apps/web/src/`
|
5. **Frontend**: Add UI components in `apps/web/src/`
|
||||||
6. **i18n**: Add translations for new strings
|
6. **i18n**: Add translations for new strings
|
||||||
|
|
||||||
|
## Adding a New Environment Variable
|
||||||
|
|
||||||
|
Update all of the following:
|
||||||
|
|
||||||
|
1. `.env.example` — add the variable with an empty value and a comment explaining it
|
||||||
|
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
|
||||||
|
3. `docker-compose.yml` — add to the `web` service `environment` section
|
||||||
|
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
|
||||||
|
5. `README.md` — add a row to the Environment Variables table
|
||||||
|
|
||||||
## Database Changes
|
## Database Changes
|
||||||
|
|
||||||
- Always create migrations (never modify existing migrations)
|
- Always create migrations (never modify existing migrations)
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -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 🧑💻
|
||||||
|
|
||||||
@@ -171,6 +203,7 @@ pnpm dev
|
|||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||||
|
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||||
@@ -180,6 +213,7 @@ pnpm dev
|
|||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
||||||
|
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -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,25 +1,23 @@
|
|||||||
ARG NODE_VERSION=20
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG APP_DIRNAME=web
|
|
||||||
ARG PROJECT=@kan/web
|
|
||||||
|
|
||||||
# 1. Alpine image
|
ARG NODE_VERSION=20
|
||||||
|
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 1: Alpine base with pnpm and turbo
|
||||||
|
# ============================================
|
||||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
apk add --no-cache libc6-compat && \
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true
|
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||||
|
corepack enable && \
|
||||||
|
npm install turbo@2.3.1 --global && \
|
||||||
|
pnpm config set store-dir ~/.pnpm-store
|
||||||
|
|
||||||
# Setup pnpm and turbo on the alpine base
|
# ============================================
|
||||||
FROM alpine AS base
|
# Stage 2: Prune the monorepo
|
||||||
RUN corepack enable
|
# ============================================
|
||||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
FROM alpine AS pruner
|
||||||
# RUN npm install turbo@2.1.3 --global
|
|
||||||
RUN npm install turbo@2.3.1 --global
|
|
||||||
|
|
||||||
RUN pnpm config set store-dir ~/.pnpm-store
|
|
||||||
|
|
||||||
# 2. Prune projects
|
|
||||||
FROM base AS pruner
|
|
||||||
ARG PROJECT
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
@@ -35,53 +33,89 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
|
|||||||
echo "unknown") && \
|
echo "unknown") && \
|
||||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||||
|
|
||||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
||||||
|
|
||||||
# 3. Build the project
|
|
||||||
FROM base AS builder
|
|
||||||
ARG PROJECT
|
|
||||||
ARG APP_VERSION
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 3: Install dependencies
|
||||||
|
# ============================================
|
||||||
|
FROM alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
|
||||||
|
|
||||||
ENV CI=true
|
ENV CI=true
|
||||||
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY --from=pruner /app/out/full/ .
|
|
||||||
|
|
||||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
# ============================================
|
||||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
# Stage 4: Build the web application
|
||||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
# ============================================
|
||||||
|
FROM alpine AS builder
|
||||||
# # Copy static files to standalone directory
|
ARG APP_VERSION
|
||||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
|
||||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
|
||||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
|
||||||
|
|
||||||
# 4. Final image - runner stage to run the application
|
|
||||||
FROM base AS runner
|
|
||||||
ARG APP_DIRNAME
|
|
||||||
|
|
||||||
# Don't run production as root
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/ ./
|
||||||
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||||
|
|
||||||
|
# Force standalone output for production Docker image
|
||||||
|
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||||
|
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 5: Migration image (run-once container)
|
||||||
|
# ============================================
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS migrate
|
||||||
|
WORKDIR /db
|
||||||
|
|
||||||
|
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
||||||
|
COPY packages/db/migrations/ ./migrations/
|
||||||
|
|
||||||
|
RUN npm init -y && \
|
||||||
|
npm install drizzle-kit drizzle-orm pg --save-exact && \
|
||||||
|
# Strip unnecessary files from node_modules
|
||||||
|
find node_modules -type f \( \
|
||||||
|
-name '*.d.ts' -o \
|
||||||
|
-name '*.d.mts' -o \
|
||||||
|
-name '*.d.cts' -o \
|
||||||
|
-name '*.map' -o \
|
||||||
|
-name '*.md' -o \
|
||||||
|
-name '*.txt' -o \
|
||||||
|
-name 'LICENSE*' -o \
|
||||||
|
-name 'CHANGELOG*' -o \
|
||||||
|
-name 'README*' -o \
|
||||||
|
-name '.eslint*' -o \
|
||||||
|
-name '.prettier*' -o \
|
||||||
|
-name 'tsconfig*.json' \
|
||||||
|
\) -delete && \
|
||||||
|
find node_modules -type d -empty -delete && \
|
||||||
|
rm -rf /root/.npm /tmp/*
|
||||||
|
|
||||||
|
CMD ["npx", "drizzle-kit", "migrate"]
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Stage 6: Production web image (distroless)
|
||||||
|
# ============================================
|
||||||
|
FROM ${DISTROLESS_NODE_IMAGE} AS web
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
# Copy the standalone Next.js server
|
||||||
WORKDIR /app/apps/${APP_DIRNAME}
|
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||||
|
# Copy static assets and public files
|
||||||
|
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||||
|
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||||
|
|
||||||
ARG PORT=3000
|
# Copy bootstrap script for runtime env var injection
|
||||||
ENV PORT=${PORT}
|
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||||
EXPOSE ${PORT}
|
|
||||||
|
|
||||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
EXPOSE 3000
|
||||||
|
CMD ["bootstrap.cjs"]
|
||||||
|
|||||||
44
apps/web/bootstrap.cjs
Normal file
44
apps/web/bootstrap.cjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Bootstrap script for the distroless production image.
|
||||||
|
*
|
||||||
|
* Distroless images have no shell, so this Node.js script handles two tasks
|
||||||
|
* that would normally be done in an entrypoint.sh:
|
||||||
|
*
|
||||||
|
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
||||||
|
* environment variables. The file was originally created at build time by
|
||||||
|
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
||||||
|
* env vars are provided at *run* time via docker-compose / docker run.
|
||||||
|
*
|
||||||
|
* 2. Start the Next.js standalone server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const publicDir = path.join(__dirname, "apps", "web", "public");
|
||||||
|
|
||||||
|
if (!existsSync(publicDir)) {
|
||||||
|
mkdirSync(publicDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const envVars = {};
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (key.startsWith("NEXT_PUBLIC_")) {
|
||||||
|
envVars[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(publicDir, "__ENV.js"),
|
||||||
|
`self.__ENV = ${JSON.stringify(envVars)};`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Start the Next.js standalone server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
require("./apps/web/server.js");
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": 0,
|
"version": "1.10",
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"json": {
|
||||||
"include": ["src/locales/[locale]/messages.po"]
|
"include": ["src/locales/[locale]/messages.json"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||||
|
|||||||
3456
apps/web/i18n.lock
3456
apps/web/i18n.lock
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf";
|
import { defineConfig } from "@lingui/cli";
|
||||||
|
import { formatter } from "@lingui/format-json";
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
export default defineConfig({
|
||||||
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
|
|||||||
exclude: ["**/node_modules/**"],
|
exclude: ["**/node_modules/**"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
format: formatter({ style: "lingui" }),
|
||||||
|
});
|
||||||
export default config;
|
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ const config = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
/** Exclude build tools and dev-only packages from the standalone output */
|
||||||
|
outputFileTracingExcludes: {
|
||||||
|
"**/*": [
|
||||||
|
"@esbuild/**",
|
||||||
|
"esbuild/**",
|
||||||
|
"typescript/**",
|
||||||
|
"webpack/**",
|
||||||
|
"uglify-js/**",
|
||||||
|
"terser/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@kan/api",
|
"@kan/api",
|
||||||
@@ -35,50 +47,13 @@ const config = {
|
|||||||
remotePatterns: (() => {
|
remotePatterns: (() => {
|
||||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||||
const patterns = [
|
const patterns = [
|
||||||
{
|
{ protocol: "https", hostname: "**" },
|
||||||
protocol: "https",
|
|
||||||
hostname:
|
|
||||||
env("S3_FORCE_PATH_STYLE") === "true"
|
|
||||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
|
||||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "*.googleusercontent.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'cdn.discordapp.com',
|
|
||||||
pathname: '/avatars/**',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
|
||||||
const s3Endpoint = env("S3_ENDPOINT");
|
|
||||||
if (s3Endpoint) {
|
|
||||||
try {
|
|
||||||
const url = new URL(s3Endpoint);
|
|
||||||
const hostname = url.hostname;
|
|
||||||
const protocol = url.protocol.replace(":", "");
|
|
||||||
|
|
||||||
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
|
||||||
const parts = hostname.split(".");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
const rootDomain = parts.slice(-2).join(".");
|
|
||||||
patterns.push({
|
|
||||||
protocol: protocol === "http" ? "http" : "https",
|
|
||||||
hostname: `*.${rootDomain}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If S3_ENDPOINT is not a valid URL, ignore it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return patterns;
|
return patterns;
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
@@ -95,12 +70,6 @@ const config = {
|
|||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
},
|
},
|
||||||
|
|
||||||
api: {
|
|
||||||
bodyParser: {
|
|
||||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,12 +20,14 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"@tiptap/extension-link": "^2.22.2",
|
"@tiptap/extension-link": "^2.22.2",
|
||||||
"@tiptap/extension-mention": "^3.0.9",
|
"@tiptap/extension-mention": "^3.0.9",
|
||||||
"@tiptap/extension-placeholder": "^2.14.0",
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
|
"@tiptap/extension-typography": "^3.18.0",
|
||||||
"@tiptap/pm": "^2.14.0",
|
"@tiptap/pm": "^2.14.0",
|
||||||
"@tiptap/react": "^2.14.0",
|
"@tiptap/react": "^2.14.0",
|
||||||
"@tiptap/starter-kit": "^2.14.0",
|
"@tiptap/starter-kit": "^2.14.0",
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.26.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
@@ -76,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",
|
||||||
|
|||||||
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
39
apps/web/public/manifest.json
Normal file
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
BIN
apps/web/public/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/web/public/screenshot-wide.png
Normal file
BIN
apps/web/public/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
||||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
socialProviders?.length === 0 && (
|
||||||
<div className="flex w-full items-center gap-4">
|
<div className="flex w-full items-center gap-4">
|
||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||||
@@ -371,65 +371,62 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
{socialProviders?.length !== 0 && (
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
{t`or`}
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
</span>
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
{t`or`}
|
||||||
</div>
|
</span>
|
||||||
)}
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
<div className="space-y-2">
|
|
||||||
{isSignUp && isCredentialsEnabled && (
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
{...register("name", { required: true })}
|
|
||||||
placeholder={t`Enter your name`}
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="mt-2 text-xs text-red-400">
|
|
||||||
{t`Please enter a valid name`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
<div className="space-y-2">
|
||||||
<>
|
{isSignUp && isCredentialsEnabled && (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
{...register("email", { required: true })}
|
{...register("name", { required: true })}
|
||||||
placeholder={t`Enter your email address`}
|
placeholder={t`Enter your name`}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.name && (
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{t`Please enter a valid email address`}
|
{t`Please enter a valid name`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{isCredentialsEnabled && (
|
<div>
|
||||||
<div>
|
<Input
|
||||||
<Input
|
{...register("email", { required: true })}
|
||||||
type="password"
|
placeholder={t`Enter your email address`}
|
||||||
{...register("password", { required: true })}
|
/>
|
||||||
placeholder={t`Enter your password`}
|
{errors.email && (
|
||||||
/>
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{errors.password && (
|
{t`Please enter a valid email address`}
|
||||||
<p className="mt-2 text-xs text-red-400">
|
</p>
|
||||||
{errors.password.message ??
|
|
||||||
t`Please enter a valid password`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
{loginError && (
|
{isCredentialsEnabled && (
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
<div>
|
||||||
)}
|
<Input
|
||||||
</div>
|
type="password"
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
{...register("password", { required: true })}
|
||||||
|
placeholder={t`Enter your password`}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">
|
||||||
|
{errors.password.message ??
|
||||||
|
t`Please enter a valid password`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loginError && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoginWithEmailPending}
|
isLoading={isLoginWithEmailPending}
|
||||||
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</form>
|
||||||
</form>
|
)}
|
||||||
|
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const initials = name
|
const initials = name?.trim()
|
||||||
? getInitialsFromName(name)
|
? getInitialsFromName(name)
|
||||||
: inferInitialsFromEmail(email);
|
: inferInitialsFromEmail(email);
|
||||||
|
|
||||||
|
|||||||
@@ -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={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
user={{
|
||||||
isLoading={sessionLoading}
|
displayName: user?.name ?? session?.user.name,
|
||||||
|
email: user?.email ?? session?.user.email ?? "",
|
||||||
|
image: user?.image ?? undefined,
|
||||||
|
}}
|
||||||
|
isLoading={sessionLoading || userLoading}
|
||||||
onCloseSideNav={closeSideNav}
|
onCloseSideNav={closeSideNav}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import { twMerge } from "tailwind-merge";
|
|||||||
interface DateSelectorProps {
|
interface DateSelectorProps {
|
||||||
selectedDate?: Date | null;
|
selectedDate?: Date | null;
|
||||||
onDateSelect?: (date: Date | undefined) => void;
|
onDateSelect?: (date: Date | undefined) => void;
|
||||||
|
weekStartsOn?: 0 | 1 | 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
const DateSelector = ({
|
||||||
|
selectedDate,
|
||||||
|
onDateSelect,
|
||||||
|
weekStartsOn = 1,
|
||||||
|
}: DateSelectorProps) => {
|
||||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||||
});
|
});
|
||||||
@@ -28,18 +33,18 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
|||||||
const year = format(currentMonth, "yyyy");
|
const year = format(currentMonth, "yyyy");
|
||||||
|
|
||||||
const dayHeaders = useMemo(() => {
|
const dayHeaders = useMemo(() => {
|
||||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
const weekStart = startOfWeek(new Date(), { weekStartsOn });
|
||||||
return eachDayOfInterval({
|
return eachDayOfInterval({
|
||||||
start: weekStart,
|
start: weekStart,
|
||||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||||
}, []);
|
}, [weekStartsOn]);
|
||||||
|
|
||||||
const days = useMemo(() => {
|
const days = useMemo(() => {
|
||||||
const monthStart = startOfMonth(currentMonth);
|
const monthStart = startOfMonth(currentMonth);
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
|
||||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||||
|
|
||||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||||
(date) => {
|
(date) => {
|
||||||
@@ -53,7 +58,7 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [currentMonth, selectedDate]);
|
}, [currentMonth, selectedDate, weekStartsOn]);
|
||||||
|
|
||||||
const handlePreviousMonth = () => {
|
const handlePreviousMonth = () => {
|
||||||
setCurrentMonth(subMonths(currentMonth, 1));
|
setCurrentMonth(subMonths(currentMonth, 1));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ReactRenderer,
|
ReactRenderer,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
|
import Typography from "@tiptap/extension-typography";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import {
|
import {
|
||||||
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
|
|||||||
label?: string | null;
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommandItems: SlashCommandItem[] = [
|
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
||||||
{
|
const headingCommands: SlashCommandItem[] = disableHeadings
|
||||||
title: "Heading 1",
|
? []
|
||||||
icon: <HiH1 />,
|
: [
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
title: "Heading 1",
|
||||||
},
|
icon: <HiH1 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 2",
|
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||||
icon: <HiH2 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
title: "Heading 2",
|
||||||
},
|
icon: <HiH2 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 3",
|
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||||
icon: <HiH3 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
title: "Heading 3",
|
||||||
},
|
icon: <HiH3 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Bullet List",
|
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||||
icon: <HiOutlineListBullet />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
];
|
||||||
},
|
|
||||||
{
|
return [
|
||||||
title: "Ordered List",
|
...headingCommands,
|
||||||
icon: <HiOutlineNumberedList />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
title: "Bullet List",
|
||||||
},
|
icon: <HiOutlineListBullet />,
|
||||||
{
|
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||||
title: "Blockquote",
|
},
|
||||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
title: "Ordered List",
|
||||||
},
|
icon: <HiOutlineNumberedList />,
|
||||||
{
|
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||||
title: "Code Block",
|
},
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
{
|
||||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
title: "Blockquote",
|
||||||
},
|
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||||
];
|
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code Block",
|
||||||
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
|
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
content,
|
content,
|
||||||
@@ -434,6 +443,8 @@ export default function Editor({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
enableYouTubeEmbed = true,
|
enableYouTubeEmbed = true,
|
||||||
|
placeholder,
|
||||||
|
disableHeadings = false,
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
@@ -441,18 +452,16 @@ export default function Editor({
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
workspaceMembers: WorkspaceMember[];
|
||||||
enableYouTubeEmbed?: boolean;
|
enableYouTubeEmbed?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disableHeadings?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit.configure({
|
||||||
Markdown,
|
heading: disableHeadings ? false : undefined,
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: readOnly
|
|
||||||
? ""
|
|
||||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
@@ -463,12 +472,20 @@ 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: "/",
|
||||||
},
|
},
|
||||||
@@ -480,20 +497,28 @@ export default function Editor({
|
|||||||
suggestion: {
|
suggestion: {
|
||||||
char: "@",
|
char: "@",
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
const all: MentionItem[] = workspaceMembers.map(
|
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||||
(member: WorkspaceMember) => ({
|
|
||||||
id: member.publicId,
|
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||||
label: member?.user?.name ?? member.email,
|
id: member.publicId,
|
||||||
image: member?.user?.image ?? null,
|
label: member?.user?.name?.trim() || member.email || "",
|
||||||
}),
|
image: member?.user?.image ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const all: MentionItem[] = mapped.filter(
|
||||||
|
(item) => item.label && item.label.length > 0,
|
||||||
);
|
);
|
||||||
const q = query.toLowerCase();
|
|
||||||
return all.filter(
|
const q = query.toLowerCase().trim();
|
||||||
(u) =>
|
|
||||||
u.label &&
|
if (q === "") {
|
||||||
typeof u.label === "string" &&
|
return all;
|
||||||
u.label.toLowerCase().includes(q),
|
}
|
||||||
|
|
||||||
|
const filtered = all.filter((u) =>
|
||||||
|
u.label.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
|
return filtered;
|
||||||
},
|
},
|
||||||
command: ({ editor, range, props }) => {
|
command: ({ editor, range, props }) => {
|
||||||
const id = props.id ?? "";
|
const id = props.id ?? "";
|
||||||
@@ -514,6 +539,17 @@ export default function Editor({
|
|||||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Typography.configure({
|
||||||
|
openDoubleQuote: false,
|
||||||
|
closeDoubleQuote: false,
|
||||||
|
openSingleQuote: false,
|
||||||
|
closeSingleQuote: false,
|
||||||
|
oneHalf: false,
|
||||||
|
oneQuarter: false,
|
||||||
|
threeQuarters: false,
|
||||||
|
superscriptTwo: false,
|
||||||
|
superscriptThree: false,
|
||||||
|
}),
|
||||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
|
|||||||
32
apps/web/src/components/FontSizeSelector.tsx
Normal file
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>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const schema = z.object({
|
|||||||
.min(3, {
|
.min(3, {
|
||||||
message: t`URL must be at least 3 characters long`,
|
message: t`URL must be at least 3 characters long`,
|
||||||
})
|
})
|
||||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||||
})
|
})
|
||||||
@@ -111,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
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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ export default function SideNavigation({
|
|||||||
const [isInitialised, setIsInitialised] = useState(false);
|
const [isInitialised, setIsInitialised] = useState(false);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||||
workspacePublicId: workspace.publicId,
|
{ workspacePublicId: workspace.publicId },
|
||||||
});
|
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||||
|
);
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
|
|||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
content: ReactNode;
|
content?: ReactNode;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
delay?: number | [number, number];
|
delay?: number | [number, number];
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ export function Tooltip({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!triggerRef.current) return;
|
if (!triggerRef.current) return;
|
||||||
|
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
rootRef.current = root;
|
rootRef.current = root;
|
||||||
|
|||||||
@@ -77,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 ? displayName ?? email : undefined}
|
title={isCollapsed ? (displayName || email) : undefined}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -104,7 +104,7 @@ export default function UserMenu({
|
|||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayName ?? email}
|
{displayName || email}
|
||||||
</span>
|
</span>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -78,7 +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(),
|
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
112
apps/web/src/hooks/usePermissions.ts
Normal file
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
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]);
|
||||||
|
}
|
||||||
6665
apps/web/src/locales/de/messages.json
Normal file
6665
apps/web/src/locales/de/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
11311
apps/web/src/locales/en/messages.json
Normal file
11311
apps/web/src/locales/en/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
6665
apps/web/src/locales/es/messages.json
Normal file
6665
apps/web/src/locales/es/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
6665
apps/web/src/locales/fr/messages.json
Normal file
6665
apps/web/src/locales/fr/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export const locales = [
|
|||||||
"nl",
|
"nl",
|
||||||
"ru",
|
"ru",
|
||||||
"pl",
|
"pl",
|
||||||
"pt-BR"
|
"ptbr"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
"pt-BR": "Português",
|
ptbr: "Português",
|
||||||
};
|
};
|
||||||
|
|||||||
6665
apps/web/src/locales/it/messages.json
Normal file
6665
apps/web/src/locales/it/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
6665
apps/web/src/locales/nl/messages.json
Normal file
6665
apps/web/src/locales/nl/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
6665
apps/web/src/locales/pl/messages.json
Normal file
6665
apps/web/src/locales/pl/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
6665
apps/web/src/locales/pt-BR/messages.json
Normal file
6665
apps/web/src/locales/pt-BR/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/ptbr/messages.ts
Normal file
1
apps/web/src/locales/ptbr/messages.ts
Normal file
File diff suppressed because one or more lines are too long
6665
apps/web/src/locales/ru/messages.json
Normal file
6665
apps/web/src/locales/ru/messages.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
55
apps/web/src/pages/404.tsx
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,7 @@ import posthog from "posthog-js";
|
|||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { FontSizeProvider } from "~/providers/font-size";
|
||||||
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
||||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||||
import { ModalProvider } from "~/providers/modal";
|
import { ModalProvider } from "~/providers/modal";
|
||||||
@@ -83,19 +84,21 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
<main className="font-sans">
|
<main className="font-sans">
|
||||||
<KeyboardShortcutProvider>
|
<KeyboardShortcutProvider>
|
||||||
<LinguiProviderWrapper>
|
<LinguiProviderWrapper>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<FontSizeProvider>
|
||||||
<ModalProvider>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<PopupProvider>
|
<ModalProvider>
|
||||||
{posthogKey ? (
|
<PopupProvider>
|
||||||
<PostHogProvider client={posthog}>
|
{posthogKey ? (
|
||||||
{getLayout(<Component {...pageProps} />)}
|
<PostHogProvider client={posthog}>
|
||||||
</PostHogProvider>
|
{getLayout(<Component {...pageProps} />)}
|
||||||
) : (
|
</PostHogProvider>
|
||||||
getLayout(<Component {...pageProps} />)
|
) : (
|
||||||
)}
|
getLayout(<Component {...pageProps} />)
|
||||||
</PopupProvider>
|
)}
|
||||||
</ModalProvider>
|
</PopupProvider>
|
||||||
</ThemeProvider>
|
</ModalProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</FontSizeProvider>
|
||||||
</LinguiProviderWrapper>
|
</LinguiProviderWrapper>
|
||||||
</KeyboardShortcutProvider>
|
</KeyboardShortcutProvider>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ const authHandler = toNodeHandler(auth.handler);
|
|||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
|
/**
|
||||||
|
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||||
|
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||||
|
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||||
|
*/
|
||||||
|
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||||
|
if (forwardedProto) {
|
||||||
|
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||||
|
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedHost = req.headers["x-forwarded-host"];
|
||||||
|
if (forwardedHost) {
|
||||||
|
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||||
|
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
return await authHandler(req, res);
|
return await authHandler(req, res);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
{ points: 100, duration: 60 },
|
{ points: 100, duration: 60 },
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
|
||||||
|
|
||||||
const { url, filename } = req.query;
|
|
||||||
|
|
||||||
if (!url || typeof url !== "string") {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: "url parameter is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const downloadFilename = typeof filename === "string"
|
|
||||||
? encodeURIComponent(filename)
|
|
||||||
: "attachment";
|
|
||||||
|
|
||||||
const upstream = await fetch(url);
|
|
||||||
|
|
||||||
if (!upstream.ok) {
|
|
||||||
return res.status(upstream.status).json({
|
|
||||||
message: "Failed to fetch attachment",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType =
|
const { url, filename } = req.query;
|
||||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", contentType);
|
if (!url || typeof url !== "string") {
|
||||||
res.setHeader(
|
return res.status(400).json({ message: "url parameter is required" });
|
||||||
"Content-Disposition",
|
}
|
||||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const buffer = await upstream.arrayBuffer();
|
const s3Endpoint = env.S3_ENDPOINT;
|
||||||
return res.send(Buffer.from(buffer));
|
|
||||||
} catch (error) {
|
if (s3Endpoint) {
|
||||||
console.error("Error downloading attachment:", error);
|
let parsed: URL;
|
||||||
return res.status(500).json({ message: "Failed to download attachment" });
|
try {
|
||||||
}
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ message: "Invalid URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
let allowedHost: string;
|
||||||
|
try {
|
||||||
|
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||||
|
return res.status(403).json({ message: "URL not allowed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadFilename =
|
||||||
|
typeof filename === "string"
|
||||||
|
? encodeURIComponent(filename)
|
||||||
|
: "attachment";
|
||||||
|
|
||||||
|
const upstream = await fetch(url);
|
||||||
|
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return res
|
||||||
|
.status(upstream.status)
|
||||||
|
.json({ message: "Failed to fetch attachment" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = await upstream.arrayBuffer();
|
||||||
|
return res.send(Buffer.from(buffer));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error downloading attachment:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Failed to download attachment" });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|||||||
const workspaceSlugSchema = z
|
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 {
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
apps/web/src/pages/api/upload/attachment.ts
Normal file
136
apps/web/src/pages/api/upload/attachment.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { assertPermission } from "@kan/api/utils/permissions";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||||
|
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||||
|
import { createS3Client, generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
||||||
|
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, db } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||||
|
if (!bucket) {
|
||||||
|
return res.status(500).json({ error: "Attachments bucket not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardPublicId = req.query.cardPublicId;
|
||||||
|
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
|
||||||
|
return res.status(400).json({ error: "Invalid cardPublicId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers["content-type"];
|
||||||
|
const contentLengthHeader = req.headers["content-length"];
|
||||||
|
const contentLength = contentLengthHeader
|
||||||
|
? Number.parseInt(contentLengthHeader, 10)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
if (typeof contentType !== "string") {
|
||||||
|
return res.status(400).json({ error: "Missing content type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
|
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
|
return res.status(400).json({ error: "File too large" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFilenameHeader =
|
||||||
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
|
||||||
|
const sanitizedFilename = originalFilenameHeader
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
|
.substring(0, 200);
|
||||||
|
|
||||||
|
// Get card and check permissions
|
||||||
|
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||||
|
db,
|
||||||
|
cardPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
return res.status(404).json({ error: "Card not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission to edit the card
|
||||||
|
try {
|
||||||
|
await assertPermission(db, user.id, card.workspaceId, "card:edit");
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: "Permission denied" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
|
||||||
|
|
||||||
|
const client = createS3Client();
|
||||||
|
|
||||||
|
const upload = new Upload({
|
||||||
|
client,
|
||||||
|
params: {
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: s3Key,
|
||||||
|
Body: req,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
},
|
||||||
|
leavePartsOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await upload.done();
|
||||||
|
|
||||||
|
// Create attachment record and log activity
|
||||||
|
const attachment = await cardAttachmentRepo.create(db, {
|
||||||
|
cardId: card.id,
|
||||||
|
filename: sanitizedFilename,
|
||||||
|
originalFilename: originalFilenameHeader,
|
||||||
|
contentType,
|
||||||
|
size: contentLength,
|
||||||
|
s3Key,
|
||||||
|
createdBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return res.status(500).json({ error: "Failed to create attachment" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await cardActivityRepo.create(db, {
|
||||||
|
type: "card.updated.attachment.added",
|
||||||
|
cardId: card.id,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
toTitle: originalFilenameHeader,
|
||||||
|
createdBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ attachment });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Attachment upload failed", error);
|
||||||
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
import { createS3Client } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
|
||||||
|
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user, db } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
|
||||||
|
if (!bucket) {
|
||||||
|
return res.status(500).json({ error: "Avatar bucket not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers["content-type"];
|
||||||
|
const contentLengthHeader = req.headers["content-length"];
|
||||||
|
const contentLength = contentLengthHeader
|
||||||
|
? Number.parseInt(contentLengthHeader, 10)
|
||||||
|
: NaN;
|
||||||
|
|
||||||
|
if (typeof contentType !== "string") {
|
||||||
|
return res.status(400).json({ error: "Missing content type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedContentTypes.includes(contentType)) {
|
||||||
|
return res.status(400).json({ error: "Invalid content type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
|
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength > MAX_SIZE_BYTES) {
|
||||||
|
return res.status(400).json({ error: "File too large" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFilenameHeader =
|
||||||
|
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||||
|
|
||||||
|
const sanitizedFilename = originalFilenameHeader
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
|
.substring(0, 200);
|
||||||
|
|
||||||
|
const s3Key = `${user.id}/${sanitizedFilename}`;
|
||||||
|
|
||||||
|
const client = createS3Client();
|
||||||
|
|
||||||
|
// Upload the file to S3
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: s3Key,
|
||||||
|
Body: req,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update user image in database
|
||||||
|
const updatedUser = await userRepo.update(db, user.id, {
|
||||||
|
image: s3Key,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
key: s3Key,
|
||||||
|
filename: sanitizedFilename,
|
||||||
|
contentType,
|
||||||
|
size: contentLength,
|
||||||
|
user: updatedUser,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Avatar upload failed", error);
|
||||||
|
return res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
||||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { user } = await createNextApiContext(req);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { filename, contentType } = req.body as {
|
|
||||||
filename: string;
|
|
||||||
contentType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Specific to avatar uploads for now
|
|
||||||
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
|
|
||||||
|
|
||||||
if (!filenameRegex.test(filename)) {
|
|
||||||
return res.status(400).json({ error: "Invalid filename" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof contentType !== "string" ||
|
|
||||||
!allowedContentTypes.includes(contentType)
|
|
||||||
) {
|
|
||||||
return res.status(400).json({ error: "Invalid content type" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials =
|
|
||||||
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
|
||||||
? {
|
|
||||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const client = new S3Client({
|
|
||||||
region: env.S3_REGION ?? "",
|
|
||||||
endpoint: env.S3_ENDPOINT ?? "",
|
|
||||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
|
||||||
credentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
const signedUrl = await getSignedUrl(
|
|
||||||
client,
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
|
||||||
Key: filename,
|
|
||||||
ACL: "public-read",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(200).json({ url: signedUrl, key: filename });
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(500).json({ error: (error as Error).message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import ApiSettings from "~/views/settings/ApiSettings";
|
import ApiSettings from "~/views/settings/ApiSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="api">
|
<SettingsLayout currentTab="api">
|
||||||
<ApiSettings />
|
<ApiSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import BillingSettings from "~/views/settings/BillingSettings";
|
import BillingSettings from "~/views/settings/BillingSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="billing">
|
<SettingsLayout currentTab="billing">
|
||||||
<BillingSettings />
|
<BillingSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="integrations">
|
<SettingsLayout currentTab="integrations">
|
||||||
<IntegrationsSettings />
|
<IntegrationsSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
23
apps/web/src/pages/settings/permissions.tsx
Normal file
23
apps/web/src/pages/settings/permissions.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
import PermissionsSettings from "~/views/settings/PermissionsSettings";
|
||||||
|
|
||||||
|
const PermissionsSettingsPage: NextPageWithLayout = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsLayout currentTab="permissions">
|
||||||
|
<PermissionsSettings />
|
||||||
|
<Popup />
|
||||||
|
</SettingsLayout>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||||
|
|
||||||
|
export default PermissionsSettingsPage;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function TrelloAuthorize() {
|
export default function TrelloAuthorize() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
const token = hash.split("=")[1];
|
const token = hash.split("=")[1];
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log("Posting token to /api/trello/authenticate", token);
|
fetch("/api/trello/authenticate", {
|
||||||
fetch("/api/trello/authenticate", {
|
method: "POST",
|
||||||
method: "POST",
|
headers: {
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
},
|
||||||
},
|
body: JSON.stringify({ token }),
|
||||||
body: JSON.stringify({ token }),
|
}).then(() => {
|
||||||
}).then(() => {
|
window.close();
|
||||||
window.close();
|
});
|
||||||
});
|
}
|
||||||
}
|
}, []);
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <div className="flex h-[200px] items-center justify-center">
|
return (
|
||||||
<p className="text-center">Connecting to Trello...</p>
|
<div className="flex h-[200px] items-center justify-center">
|
||||||
</div>;
|
<p className="text-center">Connecting to Trello...</p>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
|
import WebhookSettings from "~/views/settings/WebhookSettings";
|
||||||
|
|
||||||
|
const WebhookSettingsPage: NextPageWithLayout = () => {
|
||||||
|
return (
|
||||||
|
<SettingsLayout currentTab="webhooks">
|
||||||
|
<WebhookSettings />
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||||
|
|
||||||
|
export default WebhookSettingsPage;
|
||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="workspace">
|
<SettingsLayout currentTab="workspace">
|
||||||
<WorkspaceSettings />
|
<WorkspaceSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
57
apps/web/src/providers/font-size.tsx
Normal file
57
apps/web/src/providers/font-size.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type FontSize = "small" | "medium" | "large";
|
||||||
|
|
||||||
|
const fontSizeMap: Record<FontSize, string> = {
|
||||||
|
small: "14px",
|
||||||
|
medium: "16px",
|
||||||
|
large: "18px",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FontSizeContextProps {
|
||||||
|
fontSize: FontSize;
|
||||||
|
setFontSize: (size: FontSize) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FontSizeContext = createContext<FontSizeContextProps | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FontSizeProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [fontSize, setFontSizeState] = useState<FontSize>("medium");
|
||||||
|
|
||||||
|
const setFontSize = (size: FontSize) => {
|
||||||
|
document.documentElement.style.fontSize = fontSizeMap[size];
|
||||||
|
localStorage.setItem("fontSize", size);
|
||||||
|
setFontSizeState(size);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("fontSize") as FontSize | null;
|
||||||
|
const size = stored && fontSizeMap[stored] ? stored : "medium";
|
||||||
|
document.documentElement.style.fontSize = fontSizeMap[size];
|
||||||
|
setFontSizeState(size);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FontSizeContext.Provider value={{ fontSize, setFontSize }}>
|
||||||
|
{children}
|
||||||
|
</FontSizeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFontSize = (): FontSizeContextProps => {
|
||||||
|
const context = useContext(FontSizeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useFontSize must be used within a FontSizeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogBackdrop,
|
DialogBackdrop,
|
||||||
@@ -464,19 +465,37 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|||||||
? stroke.modifiers.map(stringifyModifier)
|
? stroke.modifiers.map(stringifyModifier)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
modifierStrings.forEach((mod) => {
|
modifierStrings.forEach((mod, index) => {
|
||||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
parts.push(
|
||||||
|
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
|
||||||
|
{mod}
|
||||||
|
</kbd>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
parts.push(
|
||||||
|
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
|
||||||
|
{stroke.key.toUpperCase()}
|
||||||
|
</kbd>,
|
||||||
|
);
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shortcut.type === "SEQUENCE") {
|
if (shortcut.type === "SEQUENCE") {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
shortcut.strokes.forEach((stroke) => {
|
shortcut.strokes.forEach((stroke, strokeIndex) => {
|
||||||
parts.push(...formatStroke(stroke));
|
const strokeParts = formatStroke(stroke);
|
||||||
|
// Add stroke index to keys to ensure uniqueness across multiple strokes
|
||||||
|
const keyedParts = strokeParts.map((part, partIndex) => {
|
||||||
|
if (React.isValidElement(part)) {
|
||||||
|
return React.cloneElement(part, {
|
||||||
|
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
parts.push(...keyedParts);
|
||||||
});
|
});
|
||||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface Workspace {
|
|||||||
slug: string | undefined;
|
slug: string | undefined;
|
||||||
plan: "free" | "pro" | "enterprise" | undefined;
|
plan: "free" | "pro" | "enterprise" | undefined;
|
||||||
role: "admin" | "member" | "guest";
|
role: "admin" | "member" | "guest";
|
||||||
|
weekStartDay: 0 | 1 | 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialWorkspace: Workspace = {
|
const initialWorkspace: Workspace = {
|
||||||
@@ -28,13 +29,14 @@ const initialWorkspace: Workspace = {
|
|||||||
slug: "",
|
slug: "",
|
||||||
plan: "free",
|
plan: "free",
|
||||||
role: "member",
|
role: "member",
|
||||||
|
weekStartDay: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
|
|
||||||
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
export const WorkspaceContext = createContext<
|
||||||
undefined,
|
WorkspaceContextProps | undefined
|
||||||
);
|
>(undefined);
|
||||||
|
|
||||||
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
@@ -79,6 +81,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
slug: workspace.slug,
|
slug: workspace.slug,
|
||||||
description: workspace.description,
|
description: workspace.description,
|
||||||
plan: workspace.plan,
|
plan: workspace.plan,
|
||||||
|
weekStartDay: workspace.weekStartDay,
|
||||||
hasLoaded: true,
|
hasLoaded: true,
|
||||||
})) as Workspace[];
|
})) as Workspace[];
|
||||||
|
|
||||||
@@ -100,6 +103,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
plan: selectedWorkspace.workspace.plan,
|
plan: selectedWorkspace.workspace.plan,
|
||||||
description: selectedWorkspace.workspace.description,
|
description: selectedWorkspace.workspace.description,
|
||||||
role: selectedWorkspace.role,
|
role: selectedWorkspace.role,
|
||||||
|
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workspacePublicId) {
|
if (workspacePublicId) {
|
||||||
@@ -119,6 +123,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
plan: primaryWorkspace.plan,
|
plan: primaryWorkspace.plan,
|
||||||
description: primaryWorkspace.description,
|
description: primaryWorkspace.description,
|
||||||
role: primaryWorkspaceRole,
|
role: primaryWorkspaceRole,
|
||||||
|
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, isLoading, workspacePublicId, router]);
|
}, [data, isLoading, workspacePublicId, router]);
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
[contenteditable="true"]:empty:before {
|
[contenteditable="true"]:empty:before {
|
||||||
content: attr(placeholder);
|
content: attr(placeholder);
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export async function invalidateCard(
|
|||||||
utils: ReturnType<typeof api.useUtils>,
|
utils: ReturnType<typeof api.useUtils>,
|
||||||
cardPublicId: string,
|
cardPublicId: string,
|
||||||
) {
|
) {
|
||||||
|
if (!cardPublicId || cardPublicId.length < 12) return;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
utils.card.byId.invalidate({ cardPublicId }),
|
utils.card.byId.invalidate({ cardPublicId }),
|
||||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
utils.card.getActivities.invalidate({ cardPublicId }),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { env } from "next-runtime-env";
|
|
||||||
|
|
||||||
export const formatToArray = (
|
export const formatToArray = (
|
||||||
value: string | string[] | undefined,
|
value: string | string[] | undefined,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
|||||||
return imageOrKey;
|
return imageOrKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
return "";
|
||||||
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
|
|
||||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
|
||||||
|
|
||||||
if (useVirtualHostedUrls === "true" && storageDomain) {
|
|
||||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
|
||||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isSignUpDisabled && (
|
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
|
||||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||||
<Trans>
|
<Trans>
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export default function SignUpPage() {
|
|||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isSignUpDisabled) {
|
const isInviteFlow = redirect?.startsWith("/invite/");
|
||||||
|
|
||||||
|
if (isSignUpDisabled && !isInviteFlow) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={t`Sign up | kan.bn`} />
|
<PageHead title={t`Sign up | kan.bn`} />
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import {
|
|||||||
HiLink,
|
HiLink,
|
||||||
HiOutlineDocumentDuplicate,
|
HiOutlineDocumentDuplicate,
|
||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
|
HiOutlineStar,
|
||||||
|
HiStar,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
import { IoArchiveOutline } from "react-icons/io5";
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -14,42 +17,137 @@ import { api } from "~/utils/api";
|
|||||||
export default function BoardDropdown({
|
export default function BoardDropdown({
|
||||||
isTemplate,
|
isTemplate,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isArchived,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
workspacePublicId,
|
isFavorite,
|
||||||
|
boardName,
|
||||||
}: {
|
}: {
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
workspacePublicId: string;
|
isArchived?: boolean;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
boardName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
return (
|
const { showPopup } = usePopup();
|
||||||
<Dropdown
|
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||||
disabled={isLoading}
|
usePermissions();
|
||||||
items={[
|
const utils = api.useUtils();
|
||||||
...(isTemplate
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: t`Make template`,
|
|
||||||
action: () => openModal("CREATE_TEMPLATE"),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Edit board URL`,
|
|
||||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
|
||||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
|
|
||||||
|
const updateBoard = api.board.update.useMutation({
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
void utils.board.all.invalidate();
|
||||||
|
void utils.board.byId.invalidate();
|
||||||
|
if (variables.isArchived !== undefined) {
|
||||||
|
showPopup({
|
||||||
|
header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
|
||||||
|
message: variables.isArchived
|
||||||
|
? t`The board has been archived.`
|
||||||
|
: t`The board has been unarchived.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
} else if (variables.favorite !== undefined) {
|
||||||
|
showPopup({
|
||||||
|
header: variables.favorite
|
||||||
|
? t`Added to favorites`
|
||||||
|
: t`Removed from favorites`,
|
||||||
|
message: variables.favorite
|
||||||
|
? t`${boardName ?? "Board"} has been added to your favorites.`
|
||||||
|
: t`${boardName ?? "Board"} has been removed from your favorites.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to update board`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleFavorite = () => {
|
||||||
|
updateBoard.mutate({
|
||||||
|
boardPublicId,
|
||||||
|
favorite: !isFavorite,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchiveOrUnarchive = () => {
|
||||||
|
updateBoard.mutate({
|
||||||
|
boardPublicId,
|
||||||
|
isArchived: !isArchived,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isArchiveActionPending = updateBoard.isPending;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(isTemplate && canCreateBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Make template`,
|
||||||
|
action: () => openModal("CREATE_TEMPLATE"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!isTemplate && canEditBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Edit board URL`,
|
||||||
|
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||||
|
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!isTemplate && canArchiveBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: isArchived ? t`Unarchive board` : t`Archive board`,
|
||||||
|
action: handleArchiveOrUnarchive,
|
||||||
|
icon: (
|
||||||
|
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: isFavorite
|
||||||
|
? t`Remove from favorites`
|
||||||
|
: t`Add to favorites`,
|
||||||
|
action: handleToggleFavorite,
|
||||||
|
icon: isFavorite ? (
|
||||||
|
<HiStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(canDeleteBoard
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||||
action: () => openModal("DELETE_BOARD"),
|
action: () => openModal("DELETE_BOARD"),
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
disabled={isLoading || isArchiveActionPending}
|
||||||
|
items={items}
|
||||||
>
|
>
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ const Card = ({
|
|||||||
const hasDueDate = !!dueDate;
|
const hasDueDate = !!dueDate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||||
<span>{title}</span>
|
<span className="break-words">{title}</span>
|
||||||
{labels.length ||
|
{labels.length ||
|
||||||
members.length ||
|
members.length ||
|
||||||
checklists.length > 0 ||
|
checklists.length > 0 ||
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ interface ListProps {
|
|||||||
interface List {
|
interface List {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
createdBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
@@ -39,8 +44,14 @@ export default function List({
|
|||||||
setSelectedPublicListId,
|
setSelectedPublicListId,
|
||||||
}: ListProps) {
|
}: ListProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
||||||
|
const canEdit = canEditList || isCreator;
|
||||||
|
const canDrag = canEditList || isCreator;
|
||||||
|
|
||||||
const openNewCardForm = (publicListId: PublicListId) => {
|
const openNewCardForm = (publicListId: PublicListId) => {
|
||||||
|
if (!canCreateCard) return;
|
||||||
openModal("NEW_CARD");
|
openModal("NEW_CARD");
|
||||||
setSelectedPublicListId(publicListId);
|
setSelectedPublicListId(publicListId);
|
||||||
};
|
};
|
||||||
@@ -59,6 +70,7 @@ export default function List({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
|
if (!canEdit) return;
|
||||||
updateList.mutate({
|
updateList.mutate({
|
||||||
listPublicId: values.listPublicId,
|
listPublicId: values.listPublicId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
@@ -71,7 +83,12 @@ export default function List({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
|
<Draggable
|
||||||
|
key={list.publicId}
|
||||||
|
draggableId={list.publicId}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={!canDrag}
|
||||||
|
>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
key={list.publicId}
|
key={list.publicId}
|
||||||
@@ -90,41 +107,65 @@ export default function List({
|
|||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
|
readOnly={!canEdit}
|
||||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<Tooltip
|
||||||
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-400 dark:hover:bg-dark-200"
|
content={
|
||||||
onClick={() => openNewCardForm(list.publicId)}
|
!canCreateCard ? t`You don't have permission` : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HiOutlinePlusSmall
|
<button
|
||||||
className="h-5 w-5 text-dark-900"
|
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-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
|
||||||
aria-hidden="true"
|
onClick={() => openNewCardForm(list.publicId)}
|
||||||
/>
|
disabled={!canCreateCard}
|
||||||
</button>
|
|
||||||
<div className="relative mr-1 inline-block">
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Add a card`,
|
|
||||||
action: () => openNewCardForm(list.publicId),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete list`,
|
|
||||||
action: handleOpenDeleteListConfirmation,
|
|
||||||
icon: (
|
|
||||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiOutlinePlusSmall
|
||||||
</Dropdown>
|
className="h-5 w-5 text-dark-900"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{(() => {
|
||||||
|
const dropdownItems = [
|
||||||
|
...(canCreateCard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Add a card`,
|
||||||
|
action: () => openNewCardForm(list.publicId),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canDeleteList || isCreator
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Delete list`,
|
||||||
|
action: handleOpenDeleteListConfirmation,
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dropdownItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mr-1 inline-block">
|
||||||
|
<Dropdown items={dropdownItems}>
|
||||||
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import Toggle from "~/components/Toggle";
|
|||||||
import { useModalFormState } from "~/hooks/useModalFormState";
|
import { useModalFormState } from "~/hooks/useModalFormState";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function NewCardForm({
|
|||||||
queryParams,
|
queryParams,
|
||||||
}: NewCardFormProps) {
|
}: NewCardFormProps) {
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
const { closeModal, openModal, modalStates, clearModalState } = useModal();
|
const { closeModal, openModal, modalStates, clearModalState } = useModal();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -491,6 +493,7 @@ export function NewCardForm({
|
|||||||
setValue("dueDate", date ?? null);
|
setValue("dueDate", date ?? null);
|
||||||
setIsDateSelectorOpen(false);
|
setIsDateSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
|
weekStartsOn={workspace.weekStartDay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
import Link from "next/link";
|
import { Fragment } from "react";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { HiLink } from "react-icons/hi";
|
import { HiLink } from "react-icons/hi";
|
||||||
|
|
||||||
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
|
|
||||||
|
const displayBaseUrl =
|
||||||
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||||
|
? "kan.bn"
|
||||||
|
: env("NEXT_PUBLIC_BASE_URL");
|
||||||
|
|
||||||
|
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||||
|
|
||||||
|
const pathSeparator = (
|
||||||
|
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
|
||||||
|
);
|
||||||
|
|
||||||
const UpdateBoardSlugButton = ({
|
const UpdateBoardSlugButton = ({
|
||||||
handleOnClick,
|
handleOnClick,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
boardSlug,
|
boardSlug,
|
||||||
|
boardPublicId,
|
||||||
|
visibility,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
canEdit,
|
||||||
}: {
|
}: {
|
||||||
handleOnClick: () => void;
|
handleOnClick: () => void;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
boardSlug: string;
|
boardSlug: string;
|
||||||
|
boardPublicId: string;
|
||||||
|
visibility: "public" | "private";
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -21,32 +42,54 @@ const UpdateBoardSlugButton = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!workspaceSlug || !boardSlug || !boardPublicId) return <></>;
|
||||||
|
|
||||||
|
const isPublic = visibility === "public";
|
||||||
|
const boardUrl = isPublic
|
||||||
|
? `${linkBaseUrl}/${workspaceSlug}/${boardSlug}`
|
||||||
|
: `${linkBaseUrl}/boards/${boardPublicId}`;
|
||||||
|
|
||||||
|
const pathSegments = isPublic
|
||||||
|
? [displayBaseUrl, workspaceSlug, boardSlug]
|
||||||
|
: [displayBaseUrl, "boards", boardPublicId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip
|
||||||
onClick={handleOnClick}
|
content={!canEdit ? t`You don't have permission` : undefined}
|
||||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<button
|
||||||
<span>
|
onClick={canEdit ? handleOnClick : undefined}
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
disabled={!canEdit || isLoading}
|
||||||
? "kan.bn"
|
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||||
: env("NEXT_PUBLIC_BASE_URL")}
|
|
||||||
</span>
|
|
||||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
|
||||||
<span>{workspaceSlug}</span>
|
|
||||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
|
||||||
<span>{boardSlug}</span>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
|
||||||
>
|
>
|
||||||
<HiLink className="h-[13px] w-[13px]" />
|
<div className="flex items-center">
|
||||||
</Link>
|
{pathSegments.map((segment, i) => (
|
||||||
</button>
|
<Fragment key={i}>
|
||||||
|
{i > 0 && pathSeparator}
|
||||||
|
<span>{segment}</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(boardUrl).then(
|
||||||
|
() =>
|
||||||
|
showPopup({
|
||||||
|
header: t`Link copied`,
|
||||||
|
icon: "success",
|
||||||
|
message: t`Board URL copied to clipboard`,
|
||||||
|
}),
|
||||||
|
).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||||
|
aria-label={t`Copy board link`}
|
||||||
|
>
|
||||||
|
<HiLink className="h-[13px] w-[13px]" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user