Compare commits

..

82 Commits

Author SHA1 Message Date
Henry
e48f16ade6 fix: migration order 2026-03-03 17:38:49 +00:00
Henry
eeae23a24c docs: add migrate service to self hosting guide (#425)
* docs: add migrate service to self hosting guide

* chore: remove build from readme compose
2026-02-28 23:08:59 +00:00
Henry
d81950b8bd fix: publish migrate image (#423) 2026-02-27 22:38:01 +00:00
hjball
e530f39360 chore: update translations 2026-02-27 13:16:47 +00:00
Nick Meinhold
bd25fb33f7 feat(api): add webhook delivery utility and card event integration (#392)
* feat(api): add webhook delivery utility and card event integration

Add the core webhook delivery logic and wire it into card mutations:

- Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout
- Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget)
- Add createCardWebhookPayload() for building webhook payloads
- Fire webhooks on card create, update, move, and delete events
- Add unit tests for webhook utility functions

Depends on #391 (DB schema & repository).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): use correct boardId in webhook payloads and add rejection safety

- Fix bug where workspaceId was incorrectly passed as boardId in all
  webhook payloads — now uses board's publicId via boardPublicId
- Replace void sendWebhooksForWorkspace() with .catch() to prevent
  unhandled promise rejections if the DB query inside fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(api): add SSRF protection to webhook delivery

Block webhook URLs targeting internal networks:
- Require HTTPS (reject HTTP)
- Block localhost, 127.0.0.1, ::1, 0.0.0.0
- Block cloud metadata endpoints (169.254.169.254, metadata.google.internal)
- Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
- Add tests for all blocked URL patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): use WebhookEvent type from schema instead of duplicating

Replace the hardcoded WebhookEventType union with the canonical
WebhookEvent type from @kan/db/schema, addressing reviewer feedback
on PR #392.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(api): improve webhook delivery safety and validation

Cherry-pick delivery-related changes from b2cc9ac:
- Extract URL validation into reusable webhookUrlSchema zod validator
  for SSRF checks
- Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled
  promise rejections
- Document SSRF risk mitigation on sendWebhookToUrl
- Add corresponding tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:15:35 +00:00
hjball
93f2816b37 chore: compile translations 2026-02-26 21:41:09 +00:00
hjball
2292b972c4 chore: update translations 2026-02-26 21:41:05 +00:00
Eliott Herbert-Byrnes
af36fb133a feat: archived boards (#386)
* init: schema migration, isArchived added to
board table. refactor: board repo for isArchived filtering

* init: archived, unarchived API procedures. refactor: all query

* fix: migration error

* feat: add tabbed navigation for boards view

* Implemented a Listbox for mobile and a tabbed navigation for desktop to switch between "Boards" and "Archived" views.
* Introduced state management for active tab selection.
* Updated UI components to reflect the new navigation structure.

* init: frontend/boards lists & tabs

* chore: fixed font styling and spacing

* init:boardDropdown / boardView.

* chore:added translations

* Remove .cursor plan file from repo

* fix:build erros

* revert: remove locales changes

* fix:reverted changes under locales, replaced the archive and unarchive endpoints. Reorder migrations

* fix:migration issue

* fix: update journal.json

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-26 21:39:15 +00:00
hjball
9cb1fb5218 chore: update translations 2026-02-26 21:26:17 +00:00
Nick Meinhold
5f190b92de fix: allow invited users to sign up when registration is disabled (#418)
* fix: allow invited users to sign up when registration is disabled

Move sign-up restriction logic from better-auth's disableSignUp config
to the existing user.create.before database hook, which already checks
for pending invitations. The frontend signup and login pages now detect
invite flows (?next=/invite/...) and bypass the disabled UI accordingly.

Closes #411

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add regression tests for sign-up hook invite bypass

Verify that the user.create.before database hook correctly:
- allows sign-up when registration is not disabled
- blocks sign-up when disabled and no invitation exists
- allows sign-up when disabled but a pending invitation exists
- respects BETTER_AUTH_ALLOWED_DOMAINS in combination with invites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add OIDC/social sign-up path coverage for invite bypass

Address review suggestion: add explicit tests verifying the
user.create.before hook handles OIDC/social sign-ups the same way as
email/password — invited users are allowed, uninvited users are blocked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:24:58 +00:00
Nick Meinhold
48a1f39588 fix(api): add missing openapi meta to checklist.update (#417)
The checklist.update procedure was inheriting the default
protectedProcedure meta (GET /protected) instead of declaring
its own OpenAPI route, making it unreachable via the REST API.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:12:02 +00:00
hjball
c7fce10fa7 chore: update translations 2026-02-26 21:05:45 +00:00
Matej
094e1d139d fix: make links clickable in comments (#376) (#415)
Move Link extension before Markdown in the TipTap extensions array
to ensure URL detection happens before markdown processing. Also
explicitly enable linkOnPaste for paste detection.

Fixes #376

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 21:04:31 +00:00
Henry
fae169c691 fix: ensure deps are installed during translate workflow 2026-02-23 23:11:52 +00:00
Henry
91bc319cf1 fix: prevent cross-workspace access on member and permission endpoints (#413) 2026-02-23 22:56:36 +00:00
Henry
d42540b9ef chore: update translations to json (#408)
* chore: update translations format

* chore: compile to ts
2026-02-20 21:38:01 +00:00
Henry
71ecf2e6d3 fix: catch and log failed email attempts 2026-02-20 15:42:07 +00:00
Henry
8a9cb8fabf chore: trigger docker publish after translate workflow 2026-02-19 22:30:19 +00:00
Henry
37f6be52ee chore: improve translation workflow 2026-02-19 22:26:20 +00:00
hjball
b72449bef1 chore: compile translations 2026-02-19 21:39:32 +00:00
Henry
5cb47a588f fix: update file pattern for translation workflow 2026-02-19 21:38:25 +00:00
Lingo.dev
d5c2404715 chore: update translations 2026-02-19 21:23:58 +00:00
Henry
0276c51ac7 fix: correct broken workflow 2026-02-19 21:22:58 +00:00
Henry
1eee819c9d fix: typo 2026-02-19 21:20:40 +00:00
Henry
2113cc7873 fix: update translate workflow 2026-02-19 21:19:46 +00:00
Henry
32714ccedf fix: update commit message format in translation workflow 2026-02-19 21:12:41 +00:00
Henry
e72b34b541 feat: setup translation workflow 2026-02-19 21:11:02 +00:00
Henry
b44a1296a0 feat: update remote patterns to be more permissive 2026-02-19 14:38:09 +00:00
Henry
3bf132286b fix: use correct context 2026-02-19 13:17:49 +00:00
Henry
c9c3365c0a fix: use correct path to dockerfile 2026-02-19 13:15:06 +00:00
Henry
7c2937a7b7 fix: add migrator container name to docker compose 2026-02-19 13:12:38 +00:00
Henry
f27a031ea8 feat(cloud): add migrator to docker-compose 2026-02-19 12:52:58 +00:00
Henry
05a0245988 feat: improve pricing tiers and add comparison table (#405) 2026-02-18 22:46:32 +00:00
Matt
b6b9423823 fix: increase max workspace url length from 24 to 64 characters (closes #379( (#398) 2026-02-17 13:40:58 +00:00
Owais Rizvi
1696aab43b fix: handle empty commenter name in mention notification emails (#400)
Use `||` instead of `??` so empty strings also fall back to email.
The nullish coalescing operator (`??`) only catches null/undefined,
so users with an empty name string would appear nameless in emails.
2026-02-17 13:39:24 +00:00
Nick Meinhold
33d2f23955 feat(db): add webhook schema, migration, and repository (#391)
* feat(db): add webhook schema, migration, and repository

Add the database foundation for workspace webhooks:

- Add workspace_webhooks table with migration (webhook_event enum,
  URL, secret, event subscriptions, active flag)
- Add webhook repository with CRUD operations
- Add webhooks schema definition with relations
- Extend card and list repos to return board/list names for
  webhook payload context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): remove unused webhook_event enum and fix migration timestamp

- Remove dead webhook_event pgEnum from schema (events column uses text)
- Remove CREATE TYPE statement from migration SQL
- Fix migration journal timestamp to be chronologically after the
  notifications migration (idx 25)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(db): return boardPublicId from card and list repo queries

Add board publicId to getWorkspaceAndCardIdByCardPublicId and
getWorkspaceAndListIdByListPublicId return values, needed for
correct boardId in webhook payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): add workspaceId index and document secret exposure

- Add index on workspaceId for efficient webhook lookups per workspace
- Add JSDoc comment on getActiveByWorkspaceId explaining that it
  returns secrets for server-side HMAC signing only and must never
  be exposed via client-facing endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(db): extract parseEvents helper in webhook repo

DRY up 6 repeated JSON.parse-and-cast calls into a single helper
function, addressing reviewer feedback on PR #391.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:23:00 +00:00
Morfixx
961219835e refactor: docker image for less final size (#385) 2026-02-17 13:12:53 +00:00
Nick Meinhold
031a42b9eb fix: generate presigned avatar URLs for card members on board view (#388)
The board.byId query was generating presigned S3 URLs for workspace
member avatars but not for card member avatars. This caused card avatars
to silently fall back to initials since the frontend helper returns ""
for raw S3 keys.

Closes #387

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:10:23 +00:00
Matt
8a1189d011 feat: Activity log for uploading attachments (closes #354) (#366)
* feat: Activity log for uploading attachments (closes #354)

* chore: fix coding style

* revert: upload spinning icon patch

* refactor: add fallbacks, extended card_activity
2026-02-15 20:53:01 +00:00
Charity
53f6ada4d3 feat: add close button to card view header (#383) 2026-02-11 13:30:05 +00:00
Henry
6ea02b1db1 feat: send email notifications on user mentions (#372)
* feat: add notifications schema

* chore: gen migration

* feat: add mention email template

* feat: add sendMentionEmail func

* feat: add repo funcs

* feat: update card router to send emails on mention

* fix: update the editor suggestion to show all members

* feat: skip pending members in sendMentionEmails

* feat: update comments to use tiptap editor
2026-02-11 13:10:46 +00:00
Matt
737b1383d0 feat: add two types of roadmap templates (#369) 2026-02-11 13:03:18 +00:00
Matt
38d0e9495c Join Workspace Email improvements (#371)
* fix failing on percent-encoding invitation urls

* feat: add the workspace and inviter names

* fix: respect the NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY environment variable
2026-02-08 21:34:17 +00:00
Henry
19798ddbc9 fix: update pnpm-lock 2026-02-08 21:11:26 +00:00
Matt
08ba965921 fix: handle x-forwarded-host and proxy issues behind Cloudflare (#370) 2026-02-08 20:59:01 +00:00
kOaDT
008b5fd1a0 fix: prevent long card titles from overflowing column width (#367) 2026-02-08 20:54:44 +00:00
Henry
97cfd60b59 fix: use multipart upload for attachments to resolve chunk size error (#368) 2026-02-07 22:42:38 +00:00
Ivan Shelepugin
24e283e22c feat: smart typography (#360)
* build(web, deps): add `@tiptap/extension-typography`

* feat(editor): add typography extension

* chore(editor): disable some typographic characters

* chore(editor): disable superscript
2026-02-07 20:04:37 +00:00
Matt
07db7409cb fix: remove phantom spacing below social providers in auth form (closes #334) (#364) 2026-02-07 19:34:06 +00:00
Charity
346ddc16be feat: add functionality to copy card link (#358)
* Implemented link copying feature in CardDropdown and CardModal components.
* Updated Dropdown component to accept new props for card identification.
2026-02-06 23:34:23 +00:00
Charity
f9eee53ed0 fix: board and workspace URL links and copy behavior (#357)
- Use /boards/{id} for private board links so copied link works; keep pretty URL for public boards
- Show matching path in board header (pretty path for public, boards/id for private)
- Copy link button copies URL to clipboard instead of opening in new tab (opening a new tab seems redundant since we're already on it)
- Edit workspace URL button links to /settings/workspace instead of account settings
2026-02-06 23:31:08 +00:00
Henry
1ab4e24c96 docs: add railway deployment template to self hosting docs (#363) 2026-02-05 21:52:40 +00:00
Henry
34165b390f fix: handle missing board in board router 2026-02-04 22:56:17 +00:00
Henry
ef0d53db8f refactor: standardise S3 URL generation (#362)
* refactor: replace presigned URL uploads with backend upload endpoints

* feat: update avatar upload to use new endpoint

* refactor: use createS3Client in auth hooks

* feat: generate presigned URLs for avatars

* fix: show avatar image in user menu

* fix: hide tooltip if content is empty

* fix: support external avatar URLs in generateAvatarUrl

* fix: remove content type restriction on attachments
2026-02-04 22:40:14 +00:00
Henry
78b9de869f fix: prevent validation errors in workspace/card queries (#356)
* fix: prevent workspace.byId validation errors when publicId is empty

* fix: add keys to shortcut elements

* fix: prevent card.byId validation errors when cardPublicId is empty

* chore: remove invalid config option from next.config.js
2026-02-02 22:25:54 +00:00
Henry
81c03b51e2 fix: fallback to email when user name is missing in activity list (#355)
* fix: fallback to email when user name is missing in activity list

* chore: gen translations
2026-02-02 22:00:03 +00:00
Henry
0d3d9a358f fix: prevent ellipses from showing through member dropdown menu (#352) 2026-02-02 20:05:53 +00:00
Henry
2269d23c94 fix: return empty permissions if WorkspaceProvider is not available 2026-02-01 23:23:06 +00:00
Henry
e437d075e3 fix: remove duplicate statement 2026-02-01 22:58:57 +00:00
Henry
b422907b53 fix: skip new col if already exists 2026-02-01 22:52:28 +00:00
Henry
672dfe6540 fix: ensure publicId is returned from create card (#350) 2026-02-01 22:42:13 +00:00
Eliott Herbert-Byrnes
ef5bf87fdf feat: add board favourites with animations (#318)
* feat: add board favorites with animations

* feat: add board favorites functionality with admin-only access

* refactor: make board favorites user-specific via junction table

* fix: assertUserInWorkspace

* Update pnpm lockfile to match package.json

* chore: clean up migrations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-02-01 22:29:51 +00:00
Nick Meinhold
ec37f5480a fix(api): include name and slug in workspace byId response (#347)
The GET /workspaces/{id} endpoint was missing the workspace's name and
slug fields because they weren't included in the column selection.

Fixes #346

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:29:37 +00:00
Henry
7f5a1ab513 feat: customisable workspace role permissions (#345)
* feat: setup schema for workspace roles

* chore: regen migration

* feat: add publicId to workspace roles

* feat: setup default permissions

* feat: add repo funcs

* feat: setup basic router interactions

* feat: add card permissions

* feat: assert permissions for lists

* feat: assert board permissions

* feat: assert permission for remaining routes

* feat: add permissions page to settings

* feat: enable updating member roles

* feat: order members by role and createdAt

* feat: allow editing individual permissions

* feat: reset role defaults

* feat: clear all permission overrides

* feat: allow users to delete entities they have created

* feat: set roleId when inviting new members

* feat: disable UI elements if user does not have permissions

* feat: allow admins to assign the admin role to other users

* feat: allow delete:list as default

* refactor: centre permissions modal

* chore: translations
2026-02-01 21:17:18 +00:00
Henry
5f2d409773 fix: allow empty REDIS_URL in environment configuration 2026-01-26 11:44:17 +00:00
Henry
210e44db5c feat: add env var to use virtual hosted urls (#338) 2026-01-26 10:16:42 +00:00
Henry
befe7ab7f4 feat: setup AGENTS.md file (#337) 2026-01-25 22:40:33 +00:00
Henry
53a33c68fc feat: rate limit api routes (#336)
* feat: install rate-limiter-flexible and ioredis

* feat: setup redis client

* feat: add withRateLimit wrapper

* feat: wrap trpc endpoint in withRateLimit

* feat: wrap withRateLimit on remaining routes

* feat: exclude webhook route from rate limiting

* chore: update compose files and readme

* refactor: move into api/db packages
2026-01-25 22:28:41 +00:00
Henry
3bae03613d feat(cloud): reset workspace slug on subscription cancelled (#335) 2026-01-25 21:20:04 +00:00
Sachin Divekar
c42ee7cf2c fix: disable tooltips on touch devices (#333) 2026-01-25 13:41:59 +00:00
Henry
07f997c3ad fix: update localization for brazilian portuguese (#332)
* fix: update localization for brazilian portuguese

* compile
2026-01-24 22:51:32 +00:00
Sachin Divekar
d084d323bc feat: show user avatar on comments and activity (#331)
Display user profile pictures instead of initials on card comments
and activity items. Fetches the user image field from the database
and passes imageUrl to the Avatar component.

Co-authored-by: Sachin Divekar <sachin.divekar@remiges.tech>
2026-01-24 22:04:46 +00:00
Sachin Divekar
6729c2e228 fix: support virtual-hosted S3 URLs for avatar storage (#330)
Avatar images failed to load with 403 on Tigris and newer AWS S3 buckets
because getAvatarUrl only constructed path-style URLs. Modern S3-compatible
providers require virtual-hosted URLs (bucket.domain/key).

Add NEXT_PUBLIC_STORAGE_DOMAIN env var to signal URL style:
- If set: virtual-hosted URL (Tigris, AWS S3)
- If not set: path-style URL (MinIO, LocalStack) - backward compatible

Add vitest and tests for getAvatarUrl behavior.

Co-authored-by: Sachin Divekar <sachin.divekar@remiges.tech>
2026-01-24 22:02:41 +00:00
Charity
b18ef10313 refactor: simplify drag-to-scroll logic to enable dragging on any non-interactive element within the container (fixes my oopsie) (#328) 2026-01-24 21:43:12 +00:00
Charity
03bd2d771b Hide member emails from non-admins (#327)
* feat(workspace): sanitize member emails for non-admin users in workspace router

* feat(workspace): add email visibility setting and update member display logic

* fix(workspace): update default value for showEmailsToMembers column to true

* - Removed unnecessary type assertions
- Added anonymous name handling (anonymous_[publicId]) in the API
- Removed placeholder logic
- Reverted null check
- Avoided as unknown cast
2026-01-24 21:41:59 +00:00
Redox
ff7944a4a2 Show display name instead of email (#323)
* Show display name instead of email

* Properly pass displayName

* Show email on settings page too

* Show email on missing username
2026-01-24 21:34:38 +00:00
Redox
0ca9c1d0e6 Add NEXT_API_BODY_SIZE_LIMIT config (#325)
* Add `NEXT_API_BODY_SIZE_LIMIT` config

* Add NEXT_API_BODY_SIZE_LIMIT env var to more files
2026-01-24 21:22:32 +00:00
Charity
927bf2fe69 feat: implement horizontal drag-to-scroll functionality in board views (#326) 2026-01-22 19:49:25 +00:00
Redox
ccd84385a2 Add Discord CDN avatars to allowed image sources (#322) 2026-01-22 14:23:25 +00:00
Henry
58c5e92155 feat(cloud): handle cancelled subscriptions (#313)
* feat(cloud): handle cancelled subscriptions

* chore: refactor auth
2026-01-16 20:20:22 +00:00
Henry
885f119404 chore: unshallow git clone 2026-01-15 20:15:19 +00:00
Henry
b17a24455a chore: generate app version during build 2026-01-14 21:21:04 +00:00
196 changed files with 93850 additions and 25354 deletions

View File

@@ -1,18 +1,62 @@
# Environment
.env
.env.*
!.env.example
docker-compose.override.yml
Dockerfile
./**/*/Dockerfile
# Docker
docker-compose*.yml
.dockerignore
# Dependencies (rebuilt in Docker)
node_modules
./**/*/node_modules
**/node_modules
# Build outputs (rebuilt in Docker)
**/.next
**/dist
**/out
**/.turbo
**/.cache
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# CI/CD
.github
.husky
.changeset
# Documentation (not needed in build)
*.md
!packages/db/migrations/**
LICENSE
CONTRIBUTING.md
AGENTS.md
CHANGELOG.md
# Test files
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
**/__tests__
**/coverage
# Logs
pnpm-debug.log
./**/*/pnpm-debug.log
**/pnpm-debug.log
README.md
.next
.git
# Cloud compose (separate deployment)
cloud/

View File

@@ -32,15 +32,23 @@ NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# API configuration (optional)
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# Redis (optional - for rate limiting)
# If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)

View File

@@ -9,20 +9,30 @@ on:
schedule:
- cron: "21 21 * * *"
push:
branches: ["main"]
# Publish semver tags as releases.
# Only trigger on tags (releases), not main branch pushes
# Main branch builds are handled by workflow_run after Translate completes
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
workflow_run:
workflows: ["Translate"]
types: [completed]
branches: [main]
# Docker builds after Translate completes (success or failure)
# This ensures Docker always has the latest translations
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs:
build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
permissions:
contents: read
@@ -76,6 +86,19 @@ jobs:
type=semver,pattern={{major}}
type=raw,value=latest,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={{is_default_branch}}
# Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
- name: Extract version
@@ -129,6 +152,22 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push migrate Docker image
id: build-and-push-migrate
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: apps/web/Dockerfile
target: migrate
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta-migrate.outputs.tags }}
labels: ${{ steps.meta-migrate.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
@@ -143,3 +182,10 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
- name: Sign the published migrate Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
TAGS: ${{ steps.meta-migrate.outputs.tags }}
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

56
.github/workflows/translate.yml vendored Normal file
View 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

View File

@@ -47,39 +47,71 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
### One-click Deployments
1. Create a new file called `docker-compose.yml` and paste the following configuration:
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
</a>
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
```yaml
services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: kan-migrate
networks:
- kan-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
- "${WEB_PORT:-3000}:3000"
networks:
- kan-network
env_file:
- .env
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
depends_on:
- postgres
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- kan-network
@@ -91,23 +123,23 @@ volumes:
kan_postgres_data:
```
2. Start the containers in detached mode:
3. Start the containers in detached mode:
```bash
docker compose up -d
```
3. Access Kan at http://localhost:3000
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
The application will be running in the background. You can manage the containers using these commands:
**Managing containers:**
- To stop the containers: `docker compose down`
- To view logs: `docker compose logs -f`
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
- To restart the containers: `docker compose restart`
- To rebuild after code changes: `docker compose up -d --build`
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
## Local Development 🧑‍💻
@@ -141,6 +173,7 @@ pnpm dev
| Variable | Description | Required | Example |
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
@@ -150,6 +183,7 @@ pnpm dev
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
@@ -171,6 +205,7 @@ pnpm dev
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |

View File

@@ -1,87 +1,121 @@
ARG NODE_VERSION=20
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# syntax=docker/dockerfile:1.7
# 1. Alpine image
ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
# ============================================
# Stage 1: Alpine base with pnpm and turbo
# ============================================
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true
apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
# Setup pnpm and turbo on the alpine base
FROM alpine AS base
RUN corepack enable
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN npm install turbo@2.1.3 --global
RUN npm install turbo@2.3.1 --global
# ============================================
# Stage 2: Prune the monorepo
# ============================================
FROM alpine AS pruner
RUN pnpm config set store-dir ~/.pnpm-store
RUN apk add --no-cache git
# 2. Prune projects
FROM base AS pruner
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
# RUN export VERSION=$(npm run version)
ARG PROJECT
# Set working directory
WORKDIR /app
# It might be the path to <ROOT> turborepo
COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
ARG APP_VERSION
# Environment to skip .env validation on build
ENV CI=true
ENV NEXT_PUBLIC_APP_VERSION=${APP_VERSION}
# Generate version from git (fallback if APP_VERSION not provided)
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
sed 's/^v//' || \
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
echo "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
# ============================================
# Stage 3: Install dependencies
# ============================================
FROM alpine AS deps
WORKDIR /app
# Copy lockfile and package.json's of isolated subworkspace
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
# First install the dependencies (as they change less often)
ENV CI=true
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
RUN pnpm build --filter=${PROJECT}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# ============================================
# Stage 4: Build the web application
# ============================================
FROM alpine AS builder
ARG APP_VERSION
WORKDIR /app
COPY --from=deps /app/ ./
COPY --from=pruner /app/out/full/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
# Force standalone output for production Docker image
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
ENV CI=true
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
# ============================================
# Stage 5: Migration image (run-once container)
# ============================================
FROM node:${NODE_VERSION}-alpine AS migrate
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
COPY packages/db/migrations/ ./migrations/
RUN npm init -y && \
npm install drizzle-kit drizzle-orm pg --save-exact && \
# Strip unnecessary files from node_modules
find node_modules -type f \( \
-name '*.d.ts' -o \
-name '*.d.mts' -o \
-name '*.d.cts' -o \
-name '*.map' -o \
-name '*.md' -o \
-name '*.txt' -o \
-name 'LICENSE*' -o \
-name 'CHANGELOG*' -o \
-name 'README*' -o \
-name '.eslint*' -o \
-name '.prettier*' -o \
-name 'tsconfig*.json' \
\) -delete && \
find node_modules -type d -empty -delete && \
rm -rf /root/.npm /tmp/*
CMD ["npx", "drizzle-kit", "migrate"]
# ============================================
# Stage 6: Production web image (distroless)
# ============================================
FROM ${DISTROLESS_NODE_IMAGE} AS web
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --chown=nextjs:nodejs --from=builder /app/ ./
WORKDIR /app/apps/${APP_DIRNAME}
# Copy the standalone Next.js server
COPY --from=builder /app/apps/web/.next/standalone/ ./
# Copy static assets and public files
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
# Copy bootstrap script for runtime env var injection
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
EXPOSE 3000
CMD ["bootstrap.cjs"]

44
apps/web/bootstrap.cjs Normal file
View 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");

View File

@@ -1,12 +1,12 @@
{
"version": 0,
"version": "1.10",
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
},
"buckets": {
"po": {
"include": ["src/locales/[locale]/messages.po"]
"json": {
"include": ["src/locales/[locale]/messages.json"]
}
},
"$schema": "https://lingo.dev/schema/i18n.json"

File diff suppressed because it is too large Load Diff

View File

@@ -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","ptbr"],
export default defineConfig({
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
sourceLocale: "en",
catalogs: [
{
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
exclude: ["**/node_modules/**"],
},
],
};
export default config;
format: formatter({ style: "lingui" }),
});

View File

@@ -16,6 +16,18 @@ const config = {
: undefined,
reactStrictMode: true,
/** Exclude build tools and dev-only packages from the standalone output */
outputFileTracingExcludes: {
"**/*": [
"@esbuild/**",
"esbuild/**",
"typescript/**",
"webpack/**",
"uglify-js/**",
"terser/**",
],
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: [
"@kan/api",
@@ -35,45 +47,13 @@ const config = {
remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [
{
protocol: "https",
hostname:
env("S3_FORCE_PATH_STYLE") === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{ protocol: "https", hostname: "**" },
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
];
// 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;
})(),
},

View File

@@ -10,6 +10,8 @@
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --",
"lingui:extract": "lingui extract",
@@ -18,6 +20,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4",
@@ -36,6 +39,7 @@
"@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-typography": "^3.18.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
@@ -45,6 +49,7 @@
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"date-fns": "^4.1.0",
"framer-motion": "^12.26.2",
"geist": "^1.3.1",
"jose": "^6.1.2",
"next": "15.5.9",
@@ -74,6 +79,7 @@
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0",
@@ -86,7 +92,8 @@
"jiti": "^1.21.6",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^3.0.0"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
})}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{!isCredentialsEnabled && socialProviders?.length === 0 && (
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
socialProviders?.length === 0 && (
<div className="flex w-full items-center gap-4">
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
<span className="text-center text-sm text-light-900 dark:text-dark-900">
@@ -371,65 +371,62 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
</div>
)}
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
{t`or`}
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("name", { required: true })}
placeholder={t`Enter your name`}
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid name`}
</p>
)}
{(isCredentialsEnabled || isMagicLinkAvailable) && (
<form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
{t`or`}
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
{(isCredentialsEnabled || isMagicLinkAvailable) && (
<>
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("email", { required: true })}
placeholder={t`Enter your email address`}
{...register("name", { required: true })}
placeholder={t`Enter your name`}
/>
{errors.email && (
{errors.name && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`}
{t`Please enter a valid name`}
</p>
)}
</div>
{isCredentialsEnabled && (
<div>
<Input
type="password"
{...register("password", { required: true })}
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)}
<div>
<Input
{...register("email", { required: true })}
placeholder={t`Enter your email address`}
/>
{errors.email && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`}
</p>
)}
</>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
{(isCredentialsEnabled || isMagicLinkAvailable) && (
</div>
{isCredentialsEnabled && (
<div>
<Input
type="password"
{...register("password", { required: true })}
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
<div className="mt-[1.5rem] flex items-center gap-4">
<Button
isLoading={isLoginWithEmailPending}
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{isMagicLinkMode ? t`magic link` : t`email`}
</Button>
</div>
)}
</form>
</form>
)}
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
);
}

View File

@@ -25,7 +25,7 @@ const Avatar = ({
icon?: React.ReactNode;
isLoading?: boolean;
}) => {
const initials = name
const initials = name?.trim()
? getInitialsFromName(name)
: inferInitialsFromEmail(email);

View File

@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
handleEdit?: (key: string) => void;
handleCreate?: () => void;
asChild?: boolean;
disabled?: boolean;
}
export default function CheckboxDropdown({
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
handleEdit,
handleCreate,
asChild = true,
disabled = false,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
{items.length > 0 ? (
items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<input
id={item.key}
name={item.key}
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
<>
<Menu.Button
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}
</Menu.Button>

View File

@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import SideNavigation from "./SideNavigation";
interface DashboardProps {
@@ -44,6 +45,12 @@ export default function Dashboard({
const { availableWorkspaces, hasLoaded } = useWorkspace();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -155,8 +162,12 @@ export default function Dashboard({
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
>
<SideNavigation
user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading}
user={{
displayName: user?.name ?? session?.user.name,
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
onCloseSideNav={closeSideNav}
/>
</div>

View File

@@ -6,7 +6,7 @@ export default function Dropdown({
children,
disabled,
}: {
items: { label: string; action: () => void; icon?: React.ReactNode }[];
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
children: React.ReactNode;
disabled?: boolean;
}) {
@@ -30,13 +30,14 @@ export default function Dropdown({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label}>
<Menu.Item key={item.label} disabled={item.disabled}>
<button
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.label}

View File

@@ -17,6 +17,7 @@ import {
ReactRenderer,
useEditor,
} from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion";
import {
@@ -386,46 +387,54 @@ export interface SlashNodeAttrs {
label?: string | null;
}
const CommandItems: SlashCommandItem[] = [
{
title: "Heading 1",
icon: <HiH1 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 1 }).run(),
},
{
title: "Heading 2",
icon: <HiH2 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 2 }).run(),
},
{
title: "Heading 3",
icon: <HiH3 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 3 }).run(),
},
{
title: "Bullet List",
icon: <HiOutlineListBullet />,
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
title: "Ordered List",
icon: <HiOutlineNumberedList />,
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
{
title: "Blockquote",
icon: <HiOutlineChatBubbleLeftEllipsis />,
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
const headingCommands: SlashCommandItem[] = disableHeadings
? []
: [
{
title: "Heading 1",
icon: <HiH1 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 1 }).run(),
},
{
title: "Heading 2",
icon: <HiH2 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 2 }).run(),
},
{
title: "Heading 3",
icon: <HiH3 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 3 }).run(),
},
];
return [
...headingCommands,
{
title: "Bullet List",
icon: <HiOutlineListBullet />,
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
title: "Ordered List",
icon: <HiOutlineNumberedList />,
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
{
title: "Blockquote",
icon: <HiOutlineChatBubbleLeftEllipsis />,
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
};
export default function Editor({
content,
@@ -434,6 +443,8 @@ export default function Editor({
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
}: {
content: string | null;
onChange?: (value: string) => void;
@@ -441,18 +452,16 @@ export default function Editor({
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor(
{
extensions: [
StarterKit,
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: t`Add description... (type '/' to open commands or '@' to mention)`,
StarterKit.configure({
heading: disableHeadings ? false : undefined,
}),
Link.configure({
openOnClick: true,
@@ -463,12 +472,20 @@ export default function Editor({
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
linkOnPaste: true,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}),
SlashCommands.configure({
commandItems: CommandItems,
commandItems: getCommandItems(disableHeadings),
suggestion: {
items: ({ query }: { query: string }) =>
filterSlashCommandItems(CommandItems, query),
filterSlashCommandItems(getCommandItems(disableHeadings), query),
startOfLine: true,
char: "/",
},
@@ -480,20 +497,28 @@ export default function Editor({
suggestion: {
char: "@",
items: ({ query }: { query: string }) => {
const all: MentionItem[] = workspaceMembers.map(
(member: WorkspaceMember) => ({
id: member.publicId,
label: member?.user?.name ?? member.email,
image: member?.user?.image ?? null,
}),
const withEmail = workspaceMembers.filter((member) => member.email);
const mapped = withEmail.map((member: WorkspaceMember) => ({
id: member.publicId,
label: member?.user?.name?.trim() || member.email || "",
image: member?.user?.image ?? null,
}));
const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0,
);
const q = query.toLowerCase();
return all.filter(
(u) =>
u.label &&
typeof u.label === "string" &&
u.label.toLowerCase().includes(q),
const q = query.toLowerCase().trim();
if (q === "") {
return all;
}
const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q),
);
return filtered;
},
command: ({ editor, range, props }) => {
const id = props.id ?? "";
@@ -514,6 +539,17 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
}),
Typography.configure({
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, {
message: t`URL must be at least 3 characters long`,
})
.max(24, { message: t`URL cannot exceed 24 characters` })
.max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
})

View File

@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
return (
<div
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">
<Transition
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
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="p-4">
<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 relative">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="flex-shrink-0 mt-1">
{popupIcon === "success" && (
<HiOutlineCheckCircle
aria-hidden="true"
className="h-6 w-6 text-green-400"
className="h-5 w-5 text-green-400"
/>
)}
{popupIcon === "error" && (
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-6 w-6 text-red-400"
className="h-5 w-5 text-red-400"
/>
)}
</div>
<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}
</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}
</p>
</div>
<div className="ml-4 flex flex-shrink-0">
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
<button
type="button"
onClick={() => {
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>
<HiXMark
aria-hidden="true"
className="h-5 w-5 text-dark-900"
className="h-4 w-4 text-dark-900"
/>
</button>
</div>

View File

@@ -14,8 +14,11 @@ import {
HiOutlineBanknotes,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
HiOutlineUser,
} from "react-icons/hi2";
import { usePermissions } from "~/hooks/usePermissions";
import { useWorkspace } from "~/providers/workspace";
interface SettingsLayoutProps {
children: React.ReactNode;
@@ -24,8 +27,12 @@ interface SettingsLayoutProps {
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
const router = useRouter();
const { workspace } = useWorkspace();
const { canViewWorkspace, canEditWorkspace } = usePermissions();
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const isAdmin = workspace.role === "admin";
const settingsTabs = [
{
key: "account",
@@ -37,13 +44,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "workspace",
icon: <HiOutlineRectangleGroup />,
label: t`Workspace`,
condition: true,
condition: canViewWorkspace,
},
{
key: "permissions",
icon: <HiOutlineShieldCheck />,
label: t`Permissions`,
condition: isAdmin,
},
{
key: "billing",
label: t`Billing`,
icon: <HiOutlineBanknotes />,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
},
{
key: "api",
@@ -55,7 +68,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
label: t`Integrations`,
condition: true,
condition: canEditWorkspace,
},
];
@@ -97,7 +110,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
>
<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">
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"

View File

@@ -39,6 +39,7 @@ interface SideNavigationProps {
}
interface UserType {
displayName?: string | null | undefined;
email?: string | null | undefined;
image?: string | null | undefined;
}
@@ -54,9 +55,10 @@ export default function SideNavigation({
const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery({
workspacePublicId: workspace.publicId,
});
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
@@ -206,7 +208,8 @@ export default function SideNavigation({
<div className="space-y-2">
<UserMenu
email={user.email ?? ""}
displayName={user.displayName ?? undefined}
email={user.email ?? "Email not provided?"}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}

View File

@@ -6,16 +6,20 @@ const Toggle = ({
onChange,
label,
disabled,
showLabel = true,
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
}) => (
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
{showLabel && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
<Switch
checked={isChecked}
onChange={onChange}

View File

@@ -7,7 +7,7 @@ import tippy from "tippy.js";
interface TooltipProps {
children: ReactNode;
content: ReactNode;
content?: ReactNode;
placement?: Placement;
delay?: number | [number, number];
}
@@ -24,6 +24,8 @@ export function Tooltip({
useEffect(() => {
if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div");
const root = createRoot(container);
rootRef.current = root;
@@ -35,6 +37,7 @@ export function Tooltip({
delay,
interactive: false,
theme: "tooltip",
touch: false,
});
return () => {

View File

@@ -17,6 +17,7 @@ import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
displayName: string | undefined;
email: string;
isLoading: boolean;
isCollapsed?: boolean;
@@ -26,6 +27,7 @@ interface UserMenuProps {
export default function UserMenu({
imageUrl,
email,
displayName,
isLoading,
isCollapsed = false,
onCloseSideNav,
@@ -75,7 +77,7 @@ export default function UserMenu({
) : (
<Menu.Button
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
title={isCollapsed ? email : undefined}
title={isCollapsed ? (displayName || email) : undefined}
>
{avatarUrl ? (
<Image
@@ -102,7 +104,7 @@ export default function UserMenu({
isCollapsed && "md:hidden",
)}
>
{email}
{displayName || email}
</span>
</Menu.Button>
)}

View File

@@ -9,6 +9,7 @@ interface Props {
positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
closeOnClickOutside?: boolean;
centered?: boolean;
}
const Modal: React.FC<Props> = ({
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
positionFromTop = "md",
isVisible,
closeOnClickOutside,
centered = false,
}) => {
const {
isOpen,
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
</Transition.Child>
<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
as={Fragment}
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"
>
<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}
</Dialog.Panel>

View File

@@ -78,6 +78,7 @@ export const env = createEnv({
S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
REDIS_URL: z.string().url().optional().or(z.literal("")),
},
/**
@@ -95,6 +96,13 @@ export const env = createEnv({
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
@@ -133,6 +141,8 @@ export const env = createEnv({
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,

View File

@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from "react";
interface UseDragToScrollOptions {
/**
* Whether drag-to-scroll is enabled
*/
enabled?: boolean;
/**
* The direction to scroll
*/
direction?: "horizontal" | "vertical" | "both";
}
/**
* Hook to enable drag-to-scroll functionality on a scrollable element
* @param options Configuration options
* @returns Ref to attach to the scrollable element and mouse event handlers
*/
export function useDragToScroll({
enabled = true,
direction = "horizontal",
}: UseDragToScrollOptions = {}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const startPosRef = useRef({ x: 0, y: 0 });
const scrollStartRef = useRef({ x: 0, y: 0 });
useEffect(() => {
if (!enabled || !isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!scrollRef.current) return;
const deltaX = e.clientX - startPosRef.current.x;
const deltaY = e.clientY - startPosRef.current.y;
if (direction === "horizontal" || direction === "both") {
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
}
if (direction === "vertical" || direction === "both") {
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [enabled, isDragging, direction]);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
const target = e.target as HTMLElement;
const container = scrollRef.current;
// Check if the click is on an interactive or draggable element
// We need to be careful not to interfere with react-beautiful-dnd dragging
const isInteractiveElement =
target.closest("a") ||
target.closest("button") ||
target.closest("input") ||
target.closest("textarea") ||
target.closest("[role='button']") ||
target.closest("[draggable='true']") ||
target.closest(".react-beautiful-dnd-drag-handle") ||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
target.closest("[data-rbd-draggable-id]");
// Don't start dragging if clicking on interactive elements
if (isInteractiveElement) return;
// Enable drag-to-scroll for any non-interactive element within the container
if (container.contains(target)) {
e.preventDefault();
setIsDragging(true);
startPosRef.current = { x: e.clientX, y: e.clientY };
scrollStartRef.current = {
x: container.scrollLeft,
y: container.scrollTop,
};
}
};
return {
ref: scrollRef,
onMouseDown: handleMouseDown,
isDragging,
};
}

View File

@@ -19,7 +19,7 @@ export function useLocalisation() {
nl,
ru,
pl,
ptbr: ptBR,
"pt-BR": ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;

View 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"),
};
}

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: src/views/card/components/Comment.tsx:124
#: src/views/card/components/Comment.tsx:123
msgid " (edited)"
msgstr " (editado)"
@@ -31,17 +31,17 @@ msgstr "{0}"
#. placeholder {1}: board?.name ?? t`Board`
#. placeholder {1}: workspace.name ?? t`Workspace`
#: src/views/boards/index.tsx:33
#: src/views/card/index.tsx:273
#: src/views/card/index.tsx:272
msgid "{0} | {1}"
msgstr "{0} | {1}"
#. placeholder {0}: labelPublicIds.length
#: src/views/board/components/NewCardForm.tsx:453
#: src/views/board/components/NewCardForm.tsx:452
msgid "{0} labels"
msgstr "{0} etiquetas"
#. placeholder {0}: isTemplate ? "Template" : "Board"
#: src/views/board/index.tsx:416
#: src/views/board/index.tsx:412
msgid "{0} not found"
msgstr "{0} não encontrado"
@@ -95,7 +95,7 @@ msgstr "A conta foi excluída"
msgid "Actions"
msgstr "Ações"
#: src/views/card/index.tsx:387
#: src/views/card/index.tsx:386
#: src/views/public/board/CardModal.tsx:158
msgid "Activity"
msgstr "Atividade"
@@ -112,12 +112,12 @@ msgstr "Registros de atividade"
msgid "Add a card"
msgstr "Adicionar um cartão"
#: src/views/card/components/Comment.tsx:147
#: src/views/card/components/NewCommentForm.tsx:55
#: src/views/card/components/Comment.tsx:146
#: src/views/card/components/NewCommentForm.tsx:57
msgid "Add a comment..."
msgstr "Adicionar um comentário..."
#: src/views/card/components/NewChecklistItemForm.tsx:141
#: src/views/card/components/NewChecklistItemForm.tsx:140
msgid "Add an item..."
msgstr "Adicionar um item..."
@@ -125,7 +125,7 @@ msgstr "Adicionar um item..."
msgid "Add checklist"
msgstr "Adicionar checklist"
#: src/components/Editor.tsx:455
#: src/components/Editor.tsx:451
msgid "Add description... (type '/' to open commands or '@' to mention)"
msgstr "Adicionar descrição... (digite '/' para abrir comandos ou '@' para mencionar)"
@@ -133,53 +133,48 @@ msgstr "Adicionar descrição... (digite '/' para abrir comandos ou '@' para men
#~ msgid "Add description... (type '/' to open commands)"
#~ msgstr "Adicionar descrição... (digite '/' para abrir comandos)"
#: src/views/card/components/ChecklistItemRow.tsx:200
#: src/views/card/components/ChecklistItemRow.tsx:172
msgid "Add details..."
msgstr "Adicionar detalhes..."
#: src/views/card/components/LabelSelector.tsx:110
#: src/views/card/components/LabelSelector.tsx:115
#: src/views/card/components/LabelSelector.tsx:109
#: src/views/card/components/LabelSelector.tsx:114
msgid "Add label"
msgstr "Adicionar etiqueta"
#: src/views/card/components/MemberSelector.tsx:131
#: src/views/card/components/MemberSelector.tsx:130
#: src/views/members/components/InviteMemberForm.tsx:257
msgid "Add member"
msgstr "Adicionar membro"
#. placeholder {0}: mergedLabels.length
#: src/views/card/components/ActivityList.tsx:77
msgid "added {0} labels: <0>{labelList}</0>"
msgstr ""
#: src/views/card/components/ActivityList.tsx:107
#: src/views/card/components/ActivityList.tsx:66
msgid "added a checklist"
msgstr "adicionou uma checklist"
#: src/views/card/components/ActivityList.tsx:110
#: src/views/card/components/ActivityList.tsx:69
msgid "added a checklist item"
msgstr "adicionou um item à checklist"
#: src/views/card/components/ActivityList.tsx:103
#: src/views/card/components/ActivityList.tsx:62
msgid "added a label to the card"
msgstr "adicionou uma etiqueta ao cartão"
#: src/views/card/components/ActivityList.tsx:105
#: src/views/card/components/ActivityList.tsx:64
msgid "added a member to the card"
msgstr "adicionou um membro ao cartão"
#. placeholder {0}: truncate(toTitle)
#: src/views/card/components/ActivityList.tsx:181
#: src/views/card/components/ActivityList.tsx:146
msgid "added checklist <0>{0}</0>"
msgstr "adicionou a checklist <0>{0}</0>"
#. placeholder {0}: truncate(toTitle)
#: src/views/card/components/ActivityList.tsx:205
#: src/views/card/components/ActivityList.tsx:170
msgid "added checklist item <0>{0}</0>"
msgstr "adicionou o item <0>{0}</0> à checklist"
#. placeholder {0}: truncate(label)
#: src/views/card/components/ActivityList.tsx:165
#: src/views/card/components/ActivityList.tsx:130
msgid "added label <0>{0}</0>"
msgstr "adicionou a etiqueta <0>{0}</0>"
@@ -282,11 +277,11 @@ msgstr "Tem certeza de que deseja excluir este {0}?"
msgid "Are you sure you want to delete this card?"
msgstr "Tem certeza de que deseja excluir este cartão?"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:56
#: src/views/card/components/DeleteChecklistConfirmation.tsx:55
msgid "Are you sure you want to delete this checklist?"
msgstr "Tem certeza de que deseja excluir esta checklist?"
#: src/views/card/components/DeleteCommentConfirmation.tsx:66
#: src/views/card/components/DeleteCommentConfirmation.tsx:65
msgid "Are you sure you want to delete this comment?"
msgstr "Tem certeza de que deseja excluir este comentário?"
@@ -303,7 +298,7 @@ msgstr "Tem certeza de que deseja excluir sua conta?"
#~ msgstr "around the world"
#. placeholder {0}: truncate(memberName)
#: src/views/card/components/ActivityList.tsx:145
#: src/views/card/components/ActivityList.tsx:110
msgid "assigned <0>{0}</0> to the card"
msgstr "atribuído <0>{0}</0> ao cartão"
@@ -315,7 +310,7 @@ msgstr "atribuído <0>{0}</0> ao cartão"
msgid "Assignees"
msgstr "Responsáveis"
#: src/views/card/components/AttachmentUpload.tsx:26
#: src/views/card/components/AttachmentUpload.tsx:25
msgid "Attachment uploaded"
msgstr "Anexo enviado"
@@ -357,9 +352,9 @@ msgstr "Portal de faturamento"
msgid "Blog Post"
msgstr "Post de blog"
#: src/views/board/index.tsx:390
#: src/views/card/index.tsx:273
#: src/views/public/board/index.tsx:114
#: src/views/board/index.tsx:386
#: src/views/card/index.tsx:272
#: src/views/public/board/index.tsx:113
msgid "Board"
msgstr "Quadro"
@@ -385,7 +380,7 @@ msgstr "O nome do quadro não pode exceder 100 caracteres"
msgid "Board name is required"
msgstr "O nome do quadro é obrigatório"
#: src/views/public/board/index.tsx:166
#: src/views/public/board/index.tsx:165
msgid "Board not found"
msgstr "Quadro não encontrado"
@@ -401,7 +396,7 @@ msgstr "A URL do quadro só pode conter letras, números e hífens"
msgid "Board URL cannot exceed 60 characters"
msgstr "A URL do quadro não pode exceder 60 caracteres"
#: src/views/public/board/index.tsx:80
#: src/views/public/board/index.tsx:79
msgid "Board URL copied to clipboard"
msgstr "URL do quadro copiada para a área de transferência"
@@ -443,12 +438,11 @@ msgid "Bug Report"
msgstr "Relatório de bug"
#: src/components/DeleteLabelConfirmation.tsx:49
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:176
#: src/views/board/components/DeleteBoardConfirmation.tsx:44
#: src/views/card/components/Comment.tsx:159
#: src/views/card/components/Comment.tsx:158
#: src/views/card/components/DeleteCardConfirmation.tsx:86
#: src/views/card/components/DeleteChecklistConfirmation.tsx:64
#: src/views/card/components/DeleteCommentConfirmation.tsx:77
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
#: src/views/settings/components/Avatar.tsx:306
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
@@ -458,12 +452,12 @@ msgstr "Relatório de bug"
msgid "Cancel"
msgstr "Cancelar"
#: src/views/card/index.tsx:273
#: src/views/card/index.tsx:272
msgid "Card"
msgstr "Cartão"
#: src/views/card/index.tsx:307
#: src/views/card/index.tsx:343
#: src/views/card/index.tsx:306
#: src/views/card/index.tsx:342
msgid "Card not found"
msgstr "Cartão não encontrado"
@@ -486,8 +480,8 @@ msgstr "Alterar senha"
msgid "Change your language preferences."
msgstr "Altere suas preferências de idioma."
#: src/views/card/components/ActivityList.tsx:255
#: src/views/card/components/ActivityList.tsx:269
#: src/views/card/components/ActivityList.tsx:220
#: src/views/card/components/ActivityList.tsx:234
msgid "changed the due date to <0>{formattedDate}</0>"
msgstr "alterou a data de vencimento para <0>{formattedDate}</0>"
@@ -500,7 +494,7 @@ msgstr "Confira alguns dos nossos projetos de código aberto favoritos."
msgid "Check your inbox"
msgstr "Verifique sua caixa de entrada"
#: src/views/card/components/NewChecklistForm.tsx:119
#: src/views/card/components/NewChecklistForm.tsx:118
msgid "Checklist name"
msgstr "Nome da checklist"
@@ -561,12 +555,12 @@ msgstr "Concluído"
msgid "Complete control and ownership:"
msgstr "Controle e propriedade completos:"
#: src/views/card/components/ActivityList.tsx:112
#: src/views/card/components/ActivityList.tsx:71
msgid "completed a checklist item"
msgstr "concluiu um item da checklist"
#. placeholder {0}: truncate(toTitle)
#: src/views/card/components/ActivityList.tsx:222
#: src/views/card/components/ActivityList.tsx:187
msgid "completed checklist item <0>{0}</0>"
msgstr "concluiu o item <0>{0}</0> da checklist"
@@ -621,10 +615,6 @@ msgstr "Continuar com {0}"
msgid "Control who can view and edit your boards."
msgstr "Controle quem pode ver e editar seus quadros."
#: src/components/YouTubeEmbed/YouTubeDropdown.tsx:47
msgid "Convert to link"
msgstr ""
#: src/views/pricing/components/FeatureComparisonTable.tsx:38
msgid "Core features"
msgstr "Recursos principais"
@@ -635,7 +625,7 @@ msgid "Create {0}"
msgstr "Criar {0}"
#: src/components/LabelForm.tsx:213
#: src/views/board/components/NewCardForm.tsx:517
#: src/views/board/components/NewCardForm.tsx:516
#: src/views/board/components/NewListForm.tsx:141
msgid "Create another"
msgstr "Criar outro"
@@ -652,11 +642,11 @@ msgstr "Criar chave de API"
#~ msgid "Create board"
#~ msgstr "Create board"
#: src/views/board/components/NewCardForm.tsx:527
#: src/views/board/components/NewCardForm.tsx:526
msgid "Create card"
msgstr "Criar cartão"
#: src/views/card/components/NewChecklistForm.tsx:135
#: src/views/card/components/NewChecklistForm.tsx:134
msgid "Create checklist"
msgstr "Criar checklist"
@@ -686,13 +676,13 @@ msgstr "Criar novo {0}"
msgid "Create new key"
msgstr "Criar nova chave"
#: src/views/board/components/NewCardForm.tsx:415
#: src/views/card/components/LabelSelector.tsx:98
#: src/views/board/components/NewCardForm.tsx:414
#: src/views/card/components/LabelSelector.tsx:97
msgid "Create new label"
msgstr "Criar nova etiqueta"
#: src/views/board/index.tsx:65
#: src/views/board/index.tsx:507
#: src/views/board/index.tsx:503
msgid "Create new list"
msgstr "Criar nova lista"
@@ -709,7 +699,7 @@ msgstr "Criar template"
msgid "Create workspace"
msgstr "Criar workspace"
#: src/views/card/components/ActivityList.tsx:99
#: src/views/card/components/ActivityList.tsx:58
msgid "created the card"
msgstr "criou o cartão"
@@ -773,11 +763,10 @@ msgstr "Desativar link de convite"
#: src/components/DeleteLabelConfirmation.tsx:51
#: src/components/LabelForm.tsx:228
#: src/components/YouTubeEmbed/YouTubeDropdown.tsx:52
#: src/views/board/components/DeleteBoardConfirmation.tsx:47
#: src/views/card/components/DeleteCardConfirmation.tsx:92
#: src/views/card/components/DeleteChecklistConfirmation.tsx:67
#: src/views/card/components/DeleteCommentConfirmation.tsx:83
#: src/views/card/components/DeleteChecklistConfirmation.tsx:66
#: src/views/card/components/DeleteCommentConfirmation.tsx:82
msgid "Delete"
msgstr "Excluir"
@@ -795,7 +784,7 @@ msgstr "Excluir quadro"
msgid "Delete card"
msgstr "Excluir cartão"
#: src/views/card/components/Comment.tsx:92
#: src/views/card/components/Comment.tsx:91
msgid "Delete comment"
msgstr "Excluir comentário"
@@ -813,21 +802,21 @@ msgstr "Excluir template"
msgid "Delete workspace"
msgstr "Excluir workspace"
#: src/views/card/components/ActivityList.tsx:109
#: src/views/card/components/ActivityList.tsx:68
msgid "deleted a checklist"
msgstr "excluiu uma checklist"
#: src/views/card/components/ActivityList.tsx:114
#: src/views/card/components/ActivityList.tsx:73
msgid "deleted a checklist item"
msgstr "excluiu um item da checklist"
#. placeholder {0}: truncate(fromTitle)
#: src/views/card/components/ActivityList.tsx:197
#: src/views/card/components/ActivityList.tsx:162
msgid "deleted checklist <0>{0}</0>"
msgstr "excluiu a checklist <0>{0}</0>"
#. placeholder {0}: truncate(fromTitle)
#: src/views/card/components/ActivityList.tsx:240
#: src/views/card/components/ActivityList.tsx:205
msgid "deleted checklist item <0>{0}</0>"
msgstr "excluiu o item <0>{0}</0> da checklist"
@@ -893,13 +882,13 @@ msgstr "Não tem uma conta? <0><1>Cadastre-se</1></0>"
msgid "Done"
msgstr "Concluído"
#: src/views/card/components/AttachmentThumbnails.tsx:142
#: src/views/card/components/AttachmentThumbnails.tsx:141
msgid "Download failed"
msgstr "Falha no download"
#: src/views/board/components/Filters.tsx:171
#: src/views/board/components/NewCardForm.tsx:470
#: src/views/card/index.tsx:140
#: src/views/board/components/NewCardForm.tsx:469
#: src/views/card/index.tsx:138
msgid "Due date"
msgstr "Data de vencimento"
@@ -919,16 +908,12 @@ msgstr "Vence hoje"
msgid "Due tomorrow"
msgstr "Vence amanhã"
#: src/components/YouTubeEmbed/YouTubeDropdown.tsx:42
msgid "Edit"
msgstr ""
#: src/views/board/components/BoardDropdown.tsx:41
#: src/views/board/components/UpdateBoardSlugForm.tsx:116
msgid "Edit board URL"
msgstr "Editar URL do quadro"
#: src/views/card/components/Comment.tsx:83
#: src/views/card/components/Comment.tsx:82
msgid "Edit comment"
msgstr "Editar comentário"
@@ -940,10 +925,6 @@ msgstr "Editar etiqueta"
msgid "Edit workspace URL"
msgstr "Editar URL do workspace"
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:108
msgid "Edit YouTube Video"
msgstr ""
#: src/views/boards/components/TemplateBoards.tsx:32
msgid "Editing"
msgstr "Edição"
@@ -960,10 +941,6 @@ msgstr "E-mail"
msgid "Enhancement"
msgstr "Melhoria"
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:131
msgid "Enter a custom title"
msgstr ""
#: src/views/settings/components/ChangePasswordConfirmation.tsx:122
msgid "Enter your current password"
msgstr "Digite sua senha atual"
@@ -1088,17 +1065,13 @@ msgstr "Falha ao copiar link de convite"
msgid "Failed to create board"
msgstr "Falha ao criar quadro"
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:82
msgid "Failed to fetch video information"
msgstr ""
#. placeholder {0}: provider.at(0)?.toUpperCase() + provider.slice(1)
#: src/components/AuthForm.tsx:291
msgid "Failed to login with {0}. Please try again."
msgstr "Falha ao fazer login com {0}. Tente novamente."
#: src/views/card/components/AttachmentUpload.tsx:34
#: src/views/card/components/AttachmentUpload.tsx:80
#: src/views/card/components/AttachmentUpload.tsx:33
#: src/views/card/components/AttachmentUpload.tsx:79
msgid "Failed to upload attachment. Please try again."
msgstr "Falha ao enviar o anexo. Tente novamente."
@@ -1217,7 +1190,7 @@ msgstr "Comece criando um novo {0}"
#~ msgid "Get started by creating a new board"
#~ msgstr "Get started by creating a new board"
#: src/views/board/index.tsx:499
#: src/views/board/index.tsx:495
msgid "Get started by creating a new list"
msgstr "Comece criando uma nova lista"
@@ -1300,10 +1273,6 @@ msgstr "Como faço auto-hospedagem?"
msgid "How is the project funded?"
msgstr "Como o projeto é financiado?"
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:151
msgid "https://www.youtube.com/watch?v=..."
msgstr ""
#: src/views/settings/components/DeleteAccountConfirmation.tsx:82
msgid "I acknowledge that all of my account data will be permanently deleted and want to proceed."
msgstr "Reconheço que todos os dados da minha conta serão excluídos permanentemente e desejo prosseguir."
@@ -1416,7 +1385,7 @@ msgstr "Links de convite"
msgid "Invite links require a Team or Pro subscription. Please upgrade your workspace."
msgstr "Links de convite exigem uma assinatura Team ou Pro. Faça upgrade do seu workspace."
#: src/views/card/components/MemberSelector.tsx:112
#: src/views/card/components/MemberSelector.tsx:111
#: src/views/members/components/InviteMemberForm.tsx:367
msgid "Invite member"
msgstr "Convidar membro"
@@ -1464,8 +1433,8 @@ msgid "Keyboard Shortcuts"
msgstr "Atalhos de teclado"
#: src/views/board/components/Filters.tsx:155
#: src/views/board/components/NewCardForm.tsx:419
#: src/views/card/index.tsx:122
#: src/views/board/components/NewCardForm.tsx:418
#: src/views/card/index.tsx:120
msgid "Labels"
msgstr "Etiquetas"
@@ -1515,11 +1484,11 @@ msgstr "Licença"
msgid "Light"
msgstr "Claro"
#: src/views/public/board/index.tsx:78
#: src/views/public/board/index.tsx:77
msgid "Link copied"
msgstr "Link copiado"
#: src/views/card/index.tsx:114
#: src/views/card/index.tsx:112
msgid "List"
msgstr "Lista"
@@ -1531,14 +1500,6 @@ msgstr "Nome da lista"
msgid "Lists"
msgstr "Listas"
#: src/views/card/components/ActivityList.tsx:530
msgid "Load more activities"
msgstr ""
#: src/views/card/components/ActivityList.tsx:530
msgid "Loading..."
msgstr ""
#: src/views/auth/login/index.tsx:33
msgid "Login | kan.bn"
msgstr "Login | kan.bn"
@@ -1567,12 +1528,12 @@ msgstr "link mágico"
msgid "Make template"
msgstr "Transformar em template"
#: src/views/card/components/ActivityList.tsx:113
#: src/views/card/components/ActivityList.tsx:72
msgid "marked a checklist item as incomplete"
msgstr "marcou um item da checklist como incompleto"
#. placeholder {0}: truncate(toTitle)
#: src/views/card/components/ActivityList.tsx:231
#: src/views/card/components/ActivityList.tsx:196
msgid "marked checklist item <0>{0}</0> as incomplete"
msgstr "marcou o item <0>{0}</0> da checklist como incompleto"
@@ -1582,8 +1543,8 @@ msgstr "Prioridade média"
#: src/components/SideNavigation.tsx:121
#: src/views/board/components/Filters.tsx:147
#: src/views/board/components/NewCardForm.tsx:377
#: src/views/card/index.tsx:131
#: src/views/board/components/NewCardForm.tsx:376
#: src/views/card/index.tsx:129
#: src/views/members/index.tsx:178
msgid "Members"
msgstr "Membros"
@@ -1604,7 +1565,7 @@ msgstr "cobrança mensal"
#. placeholder {0}: truncate(fromList)
#. placeholder {1}: truncate(toList)
#: src/views/card/components/ActivityList.tsx:133
#: src/views/card/components/ActivityList.tsx:98
msgid "moved the card from <0>{0}</0> to<1>{1}</1>"
msgstr "moveu o cartão de <0>{0}</0> para <1>{1}</1>"
@@ -1612,7 +1573,7 @@ msgstr "moveu o cartão de <0>{0}</0> para <1>{1}</1>"
#~ msgid "moved the card from <0>{fromList}</0> to<1>{toList}</1>"
#~ msgstr "moved the card from <0>{fromList}</0> to<1>{toList}</1>"
#: src/views/card/components/ActivityList.tsx:102
#: src/views/card/components/ActivityList.tsx:61
msgid "moved the card to another list"
msgstr "moveu o cartão para outra lista"
@@ -1656,7 +1617,7 @@ msgstr "Nova chave de API"
msgid "New card"
msgstr "Novo cartão"
#: src/views/card/components/NewChecklistForm.tsx:103
#: src/views/card/components/NewChecklistForm.tsx:102
msgid "New checklist"
msgstr "Nova checklist"
@@ -1669,7 +1630,7 @@ msgid "New label"
msgstr "Nova etiqueta"
#: src/views/board/components/NewListForm.tsx:113
#: src/views/board/index.tsx:470
#: src/views/board/index.tsx:466
msgid "New list"
msgstr "Nova lista"
@@ -1730,7 +1691,7 @@ msgstr "Nenhum cartão de crédito é necessário"
msgid "No dates"
msgstr "Sem datas"
#: src/views/card/components/AttachmentThumbnails.tsx:143
#: src/views/card/components/AttachmentThumbnails.tsx:142
msgid "No download URL available for this attachment."
msgstr "Nenhuma URL de download disponível para este anexo."
@@ -1738,7 +1699,7 @@ msgstr "Nenhuma URL de download disponível para este anexo."
msgid "No keyboard shortcuts registered."
msgstr "Nenhum atalho de teclado registrado."
#: src/views/board/index.tsx:496
#: src/views/board/index.tsx:492
msgid "No lists"
msgstr "Nenhuma lista"
@@ -1876,11 +1837,6 @@ msgstr "Digite um nome válido"
msgid "Please enter a valid password"
msgstr "Digite uma senha válida"
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:58
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:98
msgid "Please enter a valid YouTube URL"
msgstr ""
#: src/views/settings/components/Avatar.tsx:92
msgid "Please select a file to upload."
msgstr "Selecione um arquivo para enviar."
@@ -1894,27 +1850,26 @@ msgstr "Selecione um arquivo para enviar."
#: src/views/board/components/NewTemplateForm.tsx:77
#: src/views/board/components/UpdateBoardSlugForm.tsx:73
#: src/views/board/components/VisibilityButton.tsx:54
#: src/views/board/index.tsx:171
#: src/views/board/index.tsx:227
#: src/views/board/index.tsx:174
#: src/views/board/index.tsx:230
#: src/views/boards/components/ImportBoardsForm.tsx:205
#: src/views/card/components/AttachmentThumbnails.tsx:74
#: src/views/card/components/ChecklistItemRow.tsx:69
#: src/views/card/components/ChecklistItemRow.tsx:97
#: src/views/card/components/ChecklistNameInput.tsx:47
#: src/views/card/components/Checklists.tsx:81
#: src/views/card/components/Comment.tsx:65
#: src/views/card/components/AttachmentThumbnails.tsx:73
#: src/views/card/components/ChecklistItemRow.tsx:60
#: src/views/card/components/ChecklistItemRow.tsx:88
#: src/views/card/components/ChecklistNameInput.tsx:46
#: src/views/card/components/Comment.tsx:64
#: src/views/card/components/DeleteCardConfirmation.tsx:52
#: src/views/card/components/DeleteChecklistConfirmation.tsx:38
#: src/views/card/components/DeleteCommentConfirmation.tsx:46
#: src/views/card/components/DueDateSelector.tsx:60
#: src/views/card/components/LabelSelector.tsx:73
#: src/views/card/components/ListSelector.tsx:53
#: src/views/card/components/MemberSelector.tsx:80
#: src/views/card/components/NewChecklistForm.tsx:71
#: src/views/card/components/NewChecklistItemForm.tsx:90
#: src/views/card/components/NewCommentForm.tsx:32
#: src/views/card/index.tsx:188
#: src/views/card/index.tsx:201
#: src/views/card/components/DeleteChecklistConfirmation.tsx:37
#: src/views/card/components/DeleteCommentConfirmation.tsx:45
#: src/views/card/components/DueDateSelector.tsx:59
#: src/views/card/components/LabelSelector.tsx:72
#: src/views/card/components/ListSelector.tsx:52
#: src/views/card/components/MemberSelector.tsx:79
#: src/views/card/components/NewChecklistForm.tsx:70
#: src/views/card/components/NewChecklistItemForm.tsx:89
#: src/views/card/components/NewCommentForm.tsx:31
#: src/views/card/index.tsx:187
#: src/views/card/index.tsx:200
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
#: src/views/members/components/InviteMemberForm.tsx:104
#: src/views/members/components/InviteMemberForm.tsx:241
@@ -2014,21 +1969,16 @@ msgstr "Remover"
msgid "Remove member"
msgstr "Remover membro"
#. placeholder {0}: mergedLabels.length
#: src/views/card/components/ActivityList.tsx:91
msgid "removed {0} labels: <0>{labelList}</0>"
msgstr ""
#: src/views/card/components/ActivityList.tsx:104
#: src/views/card/components/ActivityList.tsx:63
msgid "removed a label from the card"
msgstr "removeu uma etiqueta do cartão"
#: src/views/card/components/ActivityList.tsx:106
#: src/views/card/components/ActivityList.tsx:65
msgid "removed a member from the card"
msgstr "removeu um membro do cartão"
#. placeholder {0}: truncate(label)
#: src/views/card/components/ActivityList.tsx:173
#: src/views/card/components/ActivityList.tsx:138
msgid "removed label <0>{0}</0>"
msgstr "removeu a etiqueta <0>{0}</0>"
@@ -2036,22 +1986,22 @@ msgstr "removeu a etiqueta <0>{0}</0>"
#~ msgid "removed label <0>{label}</0>"
#~ msgstr "removed label <0>{label}</0>"
#: src/views/card/components/ActivityList.tsx:117
#: src/views/card/components/ActivityList.tsx:276
#: src/views/card/components/ActivityList.tsx:76
#: src/views/card/components/ActivityList.tsx:241
msgid "removed the due date"
msgstr "removeu a data de vencimento"
#: src/views/card/components/ActivityList.tsx:108
#: src/views/card/components/ActivityList.tsx:67
msgid "renamed a checklist"
msgstr "renomeou uma checklist"
#. placeholder {0}: truncate(toTitle)
#: src/views/card/components/ActivityList.tsx:189
#: src/views/card/components/ActivityList.tsx:154
msgid "renamed checklist <0>{0}</0>"
msgstr "renomeou a checklist <0>{0}</0>"
#. placeholder {0}: truncate(toTitle)
#: src/views/card/components/ActivityList.tsx:213
#: src/views/card/components/ActivityList.tsx:178
msgid "renamed checklist item to <0>{0}</0>"
msgstr "renomeou o item da checklist para <0>{0}</0>"
@@ -2094,8 +2044,7 @@ msgstr "Função"
msgid "Run on your own infrastructure"
msgstr "Execute na sua própria infraestrutura"
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:183
#: src/views/card/components/Comment.tsx:166
#: src/views/card/components/Comment.tsx:165
#: src/views/settings/components/Avatar.tsx:309
msgid "Save"
msgstr "Salvar"
@@ -2133,7 +2082,7 @@ msgstr "Auto-hospedar"
msgid "Self host with Github"
msgstr "Auto-hospedar com Github"
#: src/views/card/components/ActivityList.tsx:142
#: src/views/card/components/ActivityList.tsx:107
msgid "self-assigned the card"
msgstr "auto-atribuiu o cartão"
@@ -2153,11 +2102,11 @@ msgstr "Enviar feedback"
msgid "Senior"
msgstr "Sênior"
#: src/views/card/components/DueDateSelector.tsx:117
#: src/views/card/components/DueDateSelector.tsx:116
msgid "Set due date"
msgstr "Definir data de vencimento"
#: src/views/card/components/ActivityList.tsx:115
#: src/views/card/components/ActivityList.tsx:74
msgid "set the due date"
msgstr "definiu a data de vencimento"
@@ -2290,8 +2239,8 @@ msgstr "Plano Team"
msgid "Teams"
msgstr "Equipes"
#: src/views/board/index.tsx:390
#: src/views/board/index.tsx:425
#: src/views/board/index.tsx:386
#: src/views/board/index.tsx:421
msgid "Template"
msgstr "Template"
@@ -2356,8 +2305,8 @@ msgstr "Eles não poderão acessar este workspace."
#: src/components/DeleteLabelConfirmation.tsx:44
#: src/views/board/components/DeleteBoardConfirmation.tsx:39
#: src/views/card/components/DeleteCardConfirmation.tsx:78
#: src/views/card/components/DeleteChecklistConfirmation.tsx:59
#: src/views/card/components/DeleteCommentConfirmation.tsx:69
#: src/views/card/components/DeleteChecklistConfirmation.tsx:58
#: src/views/card/components/DeleteCommentConfirmation.tsx:68
msgid "This action can't be undone."
msgstr "Essa ação não pode ser desfeita."
@@ -2365,7 +2314,7 @@ msgstr "Essa ação não pode ser desfeita."
msgid "This API key will only be shown once. Please save it in a secure location."
msgstr "Esta chave de API será exibida apenas uma vez. Salve-a em um local seguro."
#: src/views/public/board/index.tsx:169
#: src/views/public/board/index.tsx:168
msgid "This board is private or does not exist"
msgstr "Este quadro é privado ou não existe"
@@ -2397,10 +2346,6 @@ msgstr "Esta URL de workspace está reservada"
msgid "This workspace username has already been taken"
msgstr "Este nome de usuário do workspace já está em uso"
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:127
msgid "Title"
msgstr ""
#: src/views/boards/components/TemplateBoards.tsx:17
#: src/views/boards/components/TemplateBoards.tsx:23
msgid "To Do"
@@ -2439,19 +2384,19 @@ msgstr "Triagem"
#~ msgid "Trusted by fast-moving teams"
#~ msgstr "Trusted by fast-moving teams"
#: src/views/home/components/Logos.tsx:67
#: src/views/home/components/Logos.tsx:73
msgid "Trusted by fast-moving teams<0/>around the world"
msgstr "Confiado por equipes ágeis<0/>ao redor do mundo"
#: src/views/card/components/NewChecklistItemForm.tsx:89
#: src/views/card/components/NewChecklistItemForm.tsx:88
msgid "Unable to add checklist item"
msgstr "Não foi possível adicionar o item da checklist"
#: src/views/card/components/NewCommentForm.tsx:31
#: src/views/card/components/NewCommentForm.tsx:30
msgid "Unable to add comment"
msgstr "Não foi possível adicionar comentário"
#: src/views/card/index.tsx:200
#: src/views/card/index.tsx:199
msgid "Unable to add label"
msgstr ""
@@ -2459,7 +2404,7 @@ msgstr ""
msgid "Unable to create card"
msgstr "Não foi possível criar o cartão"
#: src/views/card/components/NewChecklistForm.tsx:70
#: src/views/card/components/NewChecklistForm.tsx:69
msgid "Unable to create checklist"
msgstr "Não foi possível criar a checklist"
@@ -2476,7 +2421,7 @@ msgstr "Não foi possível criar o template"
msgid "Unable to create workspace"
msgstr "Não foi possível criar o workspace"
#: src/views/card/components/AttachmentThumbnails.tsx:73
#: src/views/card/components/AttachmentThumbnails.tsx:72
msgid "Unable to delete attachment"
msgstr "Não foi possível excluir o anexo"
@@ -2484,15 +2429,15 @@ msgstr "Não foi possível excluir o anexo"
msgid "Unable to delete card"
msgstr "Não foi possível excluir o cartão"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:37
#: src/views/card/components/DeleteChecklistConfirmation.tsx:36
msgid "Unable to delete checklist"
msgstr "Não foi possível excluir a checklist"
#: src/views/card/components/ChecklistItemRow.tsx:96
#: src/views/card/components/ChecklistItemRow.tsx:87
msgid "Unable to delete checklist item"
msgstr "Não foi possível excluir o item da checklist"
#: src/views/card/components/DeleteCommentConfirmation.tsx:45
#: src/views/card/components/DeleteCommentConfirmation.tsx:44
msgid "Unable to delete comment"
msgstr "Não foi possível excluir o comentário"
@@ -2500,10 +2445,6 @@ msgstr "Não foi possível excluir o comentário"
msgid "Unable to remove member"
msgstr "Não foi possível remover o membro"
#: src/views/card/components/Checklists.tsx:80
msgid "Unable to reorder checklist item"
msgstr ""
#: src/components/FeedbackModal.tsx:40
msgid "Unable to send feedback"
msgstr "Não foi possível enviar o feedback"
@@ -2516,42 +2457,42 @@ msgstr "Não foi possível atualizar a URL do quadro"
msgid "Unable to update board visibility"
msgstr "Não foi possível atualizar a visibilidade do quadro"
#: src/views/board/index.tsx:226
#: src/views/card/index.tsx:187
#: src/views/board/index.tsx:229
#: src/views/card/index.tsx:186
msgid "Unable to update card"
msgstr "Não foi possível atualizar o cartão"
#: src/views/card/components/ChecklistNameInput.tsx:46
#: src/views/card/components/ChecklistNameInput.tsx:45
msgid "Unable to update checklist"
msgstr "Não foi possível atualizar a checklist"
#: src/views/card/components/ChecklistItemRow.tsx:68
#: src/views/card/components/ChecklistItemRow.tsx:59
msgid "Unable to update checklist item"
msgstr "Não foi possível atualizar o item da checklist"
#: src/views/card/components/Comment.tsx:64
#: src/views/card/components/Comment.tsx:63
msgid "Unable to update comment"
msgstr "Não foi possível atualizar o comentário"
#: src/views/card/components/DueDateSelector.tsx:59
#: src/views/card/components/DueDateSelector.tsx:58
msgid "Unable to update due date"
msgstr "Não foi possível atualizar a data de vencimento"
#: src/views/card/components/LabelSelector.tsx:72
#: src/views/card/components/LabelSelector.tsx:71
msgid "Unable to update labels"
msgstr "Não foi possível atualizar as etiquetas"
#: src/views/board/index.tsx:170
#: src/views/card/components/ListSelector.tsx:52
#: src/views/board/index.tsx:173
#: src/views/card/components/ListSelector.tsx:51
msgid "Unable to update list"
msgstr "Não foi possível atualizar a lista"
#: src/views/card/components/MemberSelector.tsx:79
#: src/views/card/components/MemberSelector.tsx:78
msgid "Unable to update members"
msgstr "Não foi possível atualizar os membros"
#. placeholder {0}: truncate(memberName)
#: src/views/card/components/ActivityList.tsx:156
#: src/views/card/components/ActivityList.tsx:121
msgid "unassigned <0>{0}</0> from the card"
msgstr "removeu a atribuição de <0>{0}</0> do cartão"
@@ -2559,7 +2500,7 @@ msgstr "removeu a atribuição de <0>{0}</0> do cartão"
#~ msgid "unassigned <0>{memberName}</0> from the card"
#~ msgstr "unassigned <0>{memberName}</0> from the card"
#: src/views/card/components/ActivityList.tsx:153
#: src/views/card/components/ActivityList.tsx:118
msgid "unassigned themselves from the card"
msgstr "removeu a própria atribuição do cartão"
@@ -2618,24 +2559,24 @@ msgstr "Atualizar"
msgid "Update label"
msgstr "Atualizar etiqueta"
#: src/views/card/components/ActivityList.tsx:111
#: src/views/card/components/ActivityList.tsx:70
msgid "updated a checklist item"
msgstr "atualizou um item da checklist"
#: src/views/card/components/ActivityList.tsx:101
#: src/views/card/components/ActivityList.tsx:60
msgid "updated the description"
msgstr "atualizou a descrição"
#: src/views/card/components/ActivityList.tsx:116
#: src/views/card/components/ActivityList.tsx:75
msgid "updated the due date"
msgstr "atualizou a data de vencimento"
#: src/views/card/components/ActivityList.tsx:100
#: src/views/card/components/ActivityList.tsx:59
msgid "updated the title"
msgstr "atualizou o título"
#. placeholder {0}: truncate(toTitle)
#: src/views/card/components/ActivityList.tsx:125
#: src/views/card/components/ActivityList.tsx:90
msgid "updated the title to <0>{0}</0>"
msgstr "atualizou o título para <0>{0}</0>"
@@ -2661,8 +2602,8 @@ msgstr "Fazer upgrade para Pro (US$29/mês)"
#~ msgid "Upgrade to Team Plan"
#~ msgstr "Upgrade to Team Plan"
#: src/views/card/components/AttachmentUpload.tsx:33
#: src/views/card/components/AttachmentUpload.tsx:79
#: src/views/card/components/AttachmentUpload.tsx:32
#: src/views/card/components/AttachmentUpload.tsx:78
msgid "Upload failed"
msgstr "Falha no upload"
@@ -2715,7 +2656,7 @@ msgstr "Veja e gerencie seu faturamento e assinatura."
msgid "View docs"
msgstr "Ver documentação"
#: src/views/public/board/index.tsx:142
#: src/views/public/board/index.tsx:141
msgid "View only"
msgstr "Somente visualização"
@@ -2723,7 +2664,7 @@ msgstr "Somente visualização"
msgid "View our roadmap."
msgstr "Veja nosso roadmap."
#: src/views/public/board/index.tsx:172
#: src/views/public/board/index.tsx:171
msgid "View workspace"
msgstr "Ver workspace"
@@ -2776,10 +2717,10 @@ msgid "Why make an open source Trello?"
msgstr "Por que criar um Trello open source?"
#: src/components/SettingsLayout.tsx:39
#: src/views/board/index.tsx:390
#: src/views/board/index.tsx:386
#: src/views/boards/index.tsx:33
#: src/views/members/index.tsx:173
#: src/views/public/board/index.tsx:114
#: src/views/public/board/index.tsx:113
#: src/views/public/boards/index.tsx:66
msgid "Workspace"
msgstr "Workspace"
@@ -2917,7 +2858,7 @@ msgstr "Seus quadros foram importados."
msgid "Your display name has been updated."
msgstr "Seu nome de exibição foi atualizado."
#: src/views/card/components/AttachmentUpload.tsx:27
#: src/views/card/components/AttachmentUpload.tsx:26
msgid "Your file has been uploaded successfully."
msgstr "Seu arquivo foi enviado com sucesso."
@@ -2956,7 +2897,3 @@ msgstr "O nome do seu workspace foi atualizado."
#: src/views/settings/components/UpdateWorkspaceUrlForm.tsx:61
msgid "Your workspace slug has been updated."
msgstr "O slug do seu workspace foi atualizado."
#: src/components/YouTubeEmbed/EditYouTubeModal.tsx:147
msgid "YouTube URL"
msgstr ""

File diff suppressed because one or more lines are too long

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

View File

@@ -2,9 +2,34 @@ import { toNodeHandler } from "better-auth/node";
import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export const config = { api: { bodyParser: false } };
export const auth = initAuth(createDrizzleClient());
export default toNodeHandler(auth.handler);
const authHandler = toNodeHandler(auth.handler);
export default withRateLimit(
{ points: 100, duration: 60 },
async (req, res) => {
/**
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
*/
const forwardedProto = req.headers["x-forwarded-proto"];
if (forwardedProto) {
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
}
const forwardedHost = req.headers["x-forwarded-host"];
if (forwardedHost) {
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
req.headers["host"] = h?.split(",")[0]?.trim();
}
return await authHandler(req, res);
},
);

View File

@@ -1,9 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -44,4 +45,5 @@ export default async function handler(
console.error("Error downloading attachment:", error);
return res.status(500).json({ message: "Failed to download attachment" });
}
}
},
);

View File

@@ -1,9 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -20,4 +21,5 @@ export default async function handler(
console.error("Error fetching OSS friends:", error);
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}
},
);

View File

@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
@@ -31,4 +31,5 @@ export default async function handler(
console.error("Error:", error);
return res.status(500).json({ error: "Error creating portal session" });
}
}
},
);

View File

@@ -6,11 +6,12 @@ import { createNextApiContext } from "@kan/api/trpc";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z
.string()
.min(3)
.max(24)
.max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest {
@@ -21,10 +22,9 @@ interface CheckoutSessionRequest {
stripeCustomerId: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
@@ -115,4 +115,5 @@ export default async function handler(
console.error("Error:", error);
return res.status(500).json({ error: "Error creating checkout session" });
}
}
},
);

View File

@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { integrations } from "@kan/db/schema";
import { addYears } from "date-fns";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -48,4 +48,5 @@ export default async function handler(
console.error("Trello authentication error:", err);
return res.status(400).json({ message: "Trello authentication failed" });
}
}
},
);

View File

@@ -3,12 +3,14 @@ import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter } from "@kan/api/root";
import { createTRPCContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const nextApiHandler = createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
@@ -17,11 +19,16 @@ const nextApiHandler = createNextApiHandler({
: undefined,
});
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "OPTIONS") {
res.writeHead(200);
return res.end();
}
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
return nextApiHandler(req, res);
}
const result = await nextApiHandler(req, res);
return result;
},
);

View File

@@ -4,6 +4,7 @@ import { jwtVerify } from "jose";
import { z } from "zod";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const requestSchema = z.object({
token: z.string().min(1),
@@ -19,10 +20,9 @@ type ResponseData =
const textEncoder = new TextEncoder();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>,
) {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
@@ -101,4 +101,5 @@ export default async function handler(
}
return res.status(200).json({ success: true });
}
},
);

View 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" });
}
},
);

View File

@@ -0,0 +1,101 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {
api: {
bodyParser: false,
},
};
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Avatar bucket not configured" });
}
const contentType = req.headers["content-type"];
const contentLengthHeader = req.headers["content-length"];
const contentLength = contentLengthHeader
? Number.parseInt(contentLengthHeader, 10)
: NaN;
if (typeof contentType !== "string") {
return res.status(400).json({ error: "Missing content type" });
}
if (!allowedContentTypes.includes(contentType)) {
return res.status(400).json({ error: "Invalid content type" });
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
return res.status(400).json({ error: "File too large" });
}
const originalFilenameHeader =
(req.headers["x-original-filename"] as string | undefined) ?? "file";
const sanitizedFilename = originalFilenameHeader
.replace(/[^a-zA-Z0-9._-]/g, "_")
.substring(0, 200);
const s3Key = `${user.id}/${sanitizedFilename}`;
const client = createS3Client();
// Upload the file to S3
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
}),
);
// Update user image in database
const updatedUser = await userRepo.update(db, user.id, {
image: s3Key,
});
return res.status(200).json({
key: s3Key,
filename: sanitizedFilename,
contentType,
size: contentLength,
user: updatedUser,
});
} catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
);

View File

@@ -1,74 +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";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default async function handler(
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 });
}
}

View File

@@ -6,25 +6,26 @@ import { appRouter } from "@kan/api";
import { createRESTContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
await cors(req, res);
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res);
const openApiHandler = createOpenApiNextHandler({
router: appRouter,
createContext: createRESTContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
const openApiHandler = createOpenApiNextHandler({
router: appRouter,
createContext: createRESTContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,
});
return await openApiHandler(req, res);
}
return await openApiHandler(req, res);
},
);

View File

@@ -1,9 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { openApiDocument } from "@kan/api/openapi";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const handler = (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};
export default handler;
export default withRateLimit(
{ points: 100, duration: 60 },
(req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
},
);

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import ApiSettings from "~/views/settings/ApiSettings";
import Popup from "~/components/Popup";
const ApiSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="api">
<ApiSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import BillingSettings from "~/views/settings/BillingSettings";
import Popup from "~/components/Popup";
const BillingSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="billing">
<BillingSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
import Popup from "~/components/Popup";
const IntegrationsSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="integrations">
<IntegrationsSettings />
<Popup />
</SettingsLayout>
);
};

View 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;

View File

@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
import Popup from "~/components/Popup";
const WorkspaceSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="workspace">
<WorkspaceSettings />
<Popup />
</SettingsLayout>
);
};

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import React from "react";
import {
Dialog,
DialogBackdrop,
@@ -464,19 +465,37 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
? stroke.modifiers.map(stringifyModifier)
: [];
modifierStrings.forEach((mod) => {
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
modifierStrings.forEach((mod, index) => {
parts.push(
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
{mod}
</kbd>,
);
});
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
parts.push(
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
{stroke.key.toUpperCase()}
</kbd>,
);
return parts;
};
if (shortcut.type === "SEQUENCE") {
const parts: ReactNode[] = [];
shortcut.strokes.forEach((stroke) => {
parts.push(...formatStroke(stroke));
shortcut.strokes.forEach((stroke, strokeIndex) => {
const strokeParts = formatStroke(stroke);
// Add stroke index to keys to ensure uniqueness across multiple strokes
const keyedParts = strokeParts.map((part, partIndex) => {
if (React.isValidElement(part)) {
return React.cloneElement(part, {
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
});
}
return part;
});
parts.push(...keyedParts);
});
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
}

View File

@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
const initialAvailableWorkspaces: Workspace[] = [];
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
undefined,
);

View File

@@ -8,6 +8,8 @@ export async function invalidateCard(
utils: ReturnType<typeof api.useUtils>,
cardPublicId: string,
) {
if (!cardPublicId || cardPublicId.length < 12) return;
await Promise.all([
utils.card.byId.invalidate({ cardPublicId }),
utils.card.getActivities.invalidate({ cardPublicId }),

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("next-runtime-env", () => ({
env: vi.fn(),
}));
import { env } from "next-runtime-env";
import { getAvatarUrl } from "./helpers";
const mockEnv = env as ReturnType<typeof vi.fn>;
describe("getAvatarUrl", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty string for null input", () => {
expect(getAvatarUrl(null)).toBe("");
});
it("returns empty string for empty string input", () => {
expect(getAvatarUrl("")).toBe("");
});
it("returns URL unchanged if already absolute http", () => {
expect(getAvatarUrl("http://example.com/avatar.jpg")).toBe(
"http://example.com/avatar.jpg",
);
});
it("returns URL unchanged if already absolute https", () => {
expect(getAvatarUrl("https://example.com/avatar.jpg")).toBe(
"https://example.com/avatar.jpg",
);
});
describe("path-style URLs (MinIO/LocalStack)", () => {
it("constructs path-style URL when STORAGE_DOMAIN is not set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_STORAGE_URL: "http://s3.localtest.me:9000",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"http://s3.localtest.me:9000/kan/user123/avatar.jpg",
);
});
});
describe("virtual-hosted URLs (Tigris/AWS S3)", () => {
it("constructs virtual-hosted URL when USE_VIRTUAL_HOSTED_URLS is true and STORAGE_DOMAIN is set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "true",
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg",
);
});
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is false even if STORAGE_DOMAIN is set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "false",
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
);
});
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is not set even if STORAGE_DOMAIN is set", () => {
mockEnv.mockImplementation((key: string) => {
const vars: Record<string, string> = {
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
};
return vars[key];
});
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
);
});
});
});

View File

@@ -1,5 +1,3 @@
import { env } from "next-runtime-env";
export const formatToArray = (
value: string | string[] | undefined,
): string[] => {
@@ -52,5 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey;
}
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
return "";
};

View File

@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/ru/messages")).messages;
case "pl":
return (await import("~/locales/pl/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
case "pt-BR":
return (await import("~/locales/pt-BR/messages")).messages;
default:
return enMessages;
}

View File

@@ -58,7 +58,7 @@ export default function LoginPage() {
</div>
</div>
)}
{!isSignUpDisabled && (
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans>
Don't have an account?{" "}

View File

@@ -28,7 +28,9 @@ export default function SignUpPage() {
setMagicLinkRecipient(recipient);
};
if (isSignUpDisabled) {
const isInviteFlow = redirect?.startsWith("/invite/");
if (isSignUpDisabled && !isInviteFlow) {
return (
<>
<PageHead title={t`Sign up | kan.bn`} />

View File

@@ -4,9 +4,12 @@ import {
HiLink,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
HiOutlineStar,
HiStar,
} from "react-icons/hi2";
import { IoArchiveOutline } from "react-icons/io5";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -14,42 +17,137 @@ import { api } from "~/utils/api";
export default function BoardDropdown({
isTemplate,
isLoading,
isArchived,
boardPublicId,
workspacePublicId,
isFavorite,
boardName,
}: {
isTemplate: boolean;
isLoading: boolean;
boardPublicId: string;
workspacePublicId: string;
isArchived?: boolean;
isFavorite?: boolean;
boardName?: string;
}) {
const { openModal } = useModal();
return (
<Dropdown
disabled={isLoading}
items={[
...(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 { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
usePermissions();
const utils = api.useUtils();
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`,
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" />
</Dropdown>

View File

@@ -66,8 +66,8 @@ const Card = ({
const hasDueDate = !!dueDate;
return (
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
<span>{title}</span>
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
<span className="break-words">{title}</span>
{labels.length ||
members.length ||
checklists.length > 0 ||

View File

@@ -9,7 +9,11 @@ import {
HiOutlineTrash,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
@@ -23,6 +27,7 @@ interface ListProps {
interface List {
publicId: string;
name: string;
createdBy?: string | null;
}
interface FormValues {
@@ -39,8 +44,14 @@ export default function List({
setSelectedPublicListId,
}: ListProps) {
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) => {
if (!canCreateCard) return;
openModal("NEW_CARD");
setSelectedPublicListId(publicListId);
};
@@ -59,6 +70,7 @@ export default function List({
});
const onSubmit = (values: FormValues) => {
if (!canEdit) return;
updateList.mutate({
listPublicId: values.listPublicId,
name: values.name,
@@ -71,7 +83,12 @@ export default function List({
};
return (
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
<Draggable
key={list.publicId}
draggableId={list.publicId}
index={index}
isDragDisabled={!canDrag}
>
{(provided) => (
<div
key={list.publicId}
@@ -90,41 +107,65 @@ export default function List({
type="text"
{...register("name")}
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"
/>
</form>
<div className="flex items-center">
<button
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"
onClick={() => openNewCardForm(list.publicId)}
<Tooltip
content={
!canCreateCard ? t`You don't have permission` : undefined
}
>
<HiOutlinePlusSmall
className="h-5 w-5 text-dark-900"
aria-hidden="true"
/>
</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" />
),
},
]}
<button
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"
onClick={() => openNewCardForm(list.publicId)}
disabled={!canCreateCard}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
</div>
<HiOutlinePlusSmall
className="h-5 w-5 text-dark-900"
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>
{children}

View File

@@ -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 { HiLink } from "react-icons/hi";
import { Tooltip } from "~/components/Tooltip";
import { usePopup } from "~/providers/popup";
const displayBaseUrl =
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn"
: env("NEXT_PUBLIC_BASE_URL");
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
const pathSeparator = (
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
);
const UpdateBoardSlugButton = ({
handleOnClick,
workspaceSlug,
boardSlug,
boardPublicId,
visibility,
isLoading,
canEdit,
}: {
handleOnClick: () => void;
workspaceSlug: string;
boardSlug: string;
boardPublicId: string;
visibility: "public" | "private";
isLoading: boolean;
canEdit: boolean;
}) => {
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
const { showPopup } = usePopup();
if (isLoading) {
return (
@@ -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 (
<button
onClick={handleOnClick}
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"
<Tooltip
content={!canEdit ? t`You don't have permission` : undefined}
>
<div className="flex items-center">
<span>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn"
: env("NEXT_PUBLIC_BASE_URL")}
</span>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
<span>{workspaceSlug}</span>
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
<span>{boardSlug}</span>
</div>
<Link
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
<button
onClick={canEdit ? handleOnClick : undefined}
disabled={!canEdit || isLoading}
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
>
<HiLink className="h-[13px] w-[13px]" />
</Link>
</button>
<div className="flex items-center">
{pathSegments.map((segment, i) => (
<Fragment key={i}>
{i > 0 && pathSeparator}
<span>{segment}</span>
</Fragment>
))}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(boardUrl).then(
() =>
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Board URL copied to clipboard`,
}),
).catch(() => undefined);
}}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
aria-label={t`Copy board link`}
>
<HiLink className="h-[13px] w-[13px]" />
</button>
</button>
</Tooltip>
);
};

View File

@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
<div className="flex items-center gap-2">
<Button
variant="secondary"
href="/settings?tab=workspace"
href="/settings/workspace"
onClick={closeModal}
>
{t`Edit workspace URL`}

View File

@@ -4,6 +4,8 @@ import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -29,6 +31,7 @@ const VisibilityButton = ({
isAdmin: boolean;
}) => {
const { showPopup } = usePopup();
const { canEditBoard } = usePermissions();
const utils = api.useUtils();
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
visibility,
@@ -60,38 +63,47 @@ const VisibilityButton = ({
},
});
const canEdit = canEditBoard || isAdmin;
return (
<div className="relative">
<CheckboxDropdown
items={[
{
key: "public",
value: t`Public`,
selected: isPublic,
},
{
key: "private",
value: t`Private`,
selected: !isPublic,
},
]}
handleSelect={(_g, i) => {
setStateVisibility(isPublic ? "private" : "public");
updateBoardVisibility.mutate({
visibility: i.key as "public" | "private",
boardPublicId,
});
}}
menuSpacing="md"
<Tooltip
content={
!canEdit && !isLoading ? t`You don't have permission` : undefined
}
>
<Button
variant="secondary"
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !isAdmin}
<CheckboxDropdown
items={[
{
key: "public",
value: t`Public`,
selected: isPublic,
},
{
key: "private",
value: t`Private`,
selected: !isPublic,
},
]}
handleSelect={(_g, i) => {
if (!canEdit) return;
setStateVisibility(isPublic ? "private" : "public");
updateBoardVisibility.mutate({
visibility: i.key as "public" | "private",
boardPublicId,
});
}}
menuSpacing="md"
>
{t`Visibility`}
</Button>
</CheckboxDropdown>
<Button
variant="secondary"
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !canEdit}
>
{t`Visibility`}
</Button>
</CheckboxDropdown>
</Tooltip>
</div>
);
};

View File

@@ -25,6 +25,8 @@ import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -57,11 +59,19 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true,
direction: "horizontal",
});
const { canCreateList, canEditList, canEditCard, canEditBoard } =
usePermissions();
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => boardId && openNewListForm(boardId),
action: () => boardId && canCreateList && openNewListForm(boardId),
description: t`Create new list`,
group: "ACTIONS",
});
@@ -254,14 +264,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
return;
}
if (type === "LIST") {
if (type === "LIST" && canEditList) {
updateListMutation.mutate({
listPublicId: draggableId,
index: destination.index,
});
}
if (type === "CARD") {
if (type === "CARD" && canEditCard) {
updateCardMutation.mutate({
cardPublicId: draggableId,
@@ -406,8 +416,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
id="name"
type="text"
{...register("name")}
onBlur={handleSubmit(onSubmit)}
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
readOnly={!canEditBoard}
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none disabled:cursor-not-allowed dark:text-dark-1000 sm:text-[1.2rem]"
/>
</form>
)}
@@ -432,6 +443,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
boardPublicId={boardId ?? ""}
visibility={boardData?.visibility ?? "private"}
canEdit={canEditBoard}
/>
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
@@ -454,7 +468,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
)}
</>
)}
<Tooltip content={createListShortcutTooltipContent}>
<Tooltip
content={
!canCreateList
? t`You don't have permission`
: createListShortcutTooltipContent
}
>
<Button
iconLeft={
<HiOutlinePlusSmall
@@ -463,9 +483,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
if (boardId && canCreateList) openNewListForm(boardId);
}}
disabled={!boardData}
disabled={!boardData || !canCreateList}
>
{t`New list`}
</Button>
@@ -474,12 +494,18 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
isArchived={boardData?.isArchived ?? false}
isFavorite={boardData?.favorite}
boardName={boardData?.name}
/>
</div>
</div>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
<div
ref={scrollRef}
onMouseDown={onMouseDown}
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
>
{isLoading ? (
<div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
@@ -496,16 +522,25 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
{t`No lists`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{t`Get started by creating a new list`}
{canCreateList
? t`Get started by creating a new list`
: t`No lists have been created yet`}
</p>
</div>
<Button
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
<Tooltip
content={
!canCreateList ? t`You don't have permission` : undefined
}
>
{t`Create new list`}
</Button>
<Button
onClick={() => {
if (boardId && canCreateList) openNewListForm(boardId);
}}
disabled={!canCreateList}
>
{t`Create new list`}
</Button>
</Tooltip>
</div>
) : (
<DragDropContext onDragEnd={onDragEnd}>
@@ -545,6 +580,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
key={card.publicId}
draggableId={card.publicId}
index={index}
isDragDisabled={!canEditCard}
>
{(provided) => (
<Link
@@ -562,13 +598,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}`
}
className={`mb-2 flex !cursor-pointer flex-col ${
card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
"PLACEHOLDER",
)
? "pointer-events-none"
: ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}

View File

@@ -1,25 +1,50 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { HiOutlineRectangleStack } from "react-icons/hi2";
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
import { motion } from "framer-motion";
import Button from "~/components/Button";
import PatternedBackground from "~/components/PatternedBackground";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
const { workspace } = useWorkspace();
const { openModal } = useModal();
const { canCreateBoard } = usePermissions();
const utils = api.useUtils();
const updateBoard = api.board.update.useMutation({
onSuccess: () => {
void utils.board.all.invalidate();
},
});
const { data, isLoading } = api.board.all.useQuery(
{
workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular",
archived: archived,
},
{ enabled: workspace.publicId ? true : false },
);
const handleToggleFavorite = (
e: React.MouseEvent,
boardPublicId: string,
currentFavorite: boolean | undefined
) => {
e.preventDefault();
e.stopPropagation();
updateBoard.mutate({
boardPublicId,
favorite: !currentFavorite,
});
};
if (isLoading)
return (
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
@@ -35,33 +60,75 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
<div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{t`No ${isTemplate ? "templates" : "boards"}`}
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Button onClick={() => openModal("NEW_BOARD")}>
{t`Create new ${isTemplate ? "template" : "board"}`}
</Button>
<Tooltip
content={
!canCreateBoard ? t`You don't have permission` : undefined
}
>
<Button
onClick={() => {
if (canCreateBoard) openModal("NEW_BOARD");
}}
disabled={!canCreateBoard}
>
{t`Create new ${isTemplate ? "template" : "board"}`}
</Button>
</Tooltip>
</div>
);
return (
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
<motion.div
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
layout
>
{data?.map((board) => (
<Link
<motion.div
key={board.publicId}
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
layout: {
type: "spring",
stiffness: 300,
damping: 30,
mass: 1
},
opacity: { duration: 0.2 },
scale: { duration: 0.2 }
}}
>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>
</div>
</Link>
<Link
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
>
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
}`}
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
>
{board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
) : (
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)}
</button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name}
</p>
</div>
</Link>
</motion.div>
))}
</div>
</motion.div>
);
}

View File

@@ -23,6 +23,18 @@ export const getTemplates = (): Template[] => [
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
},
{
id: "roadmap-basic",
name: t`Basic Roadmap`,
lists: [t`Requested`, t`Planned`, t`In Progress`, t`Done`],
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
},
{
id: "roadmap-extended",
name: t`Extended Roadmap`,
lists: [t`Requested`, t`Under Review`, t`Planned`, t`In Progress`, t`Done`, t`Rejected`],
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
},
{
id: "content-creation",
name: t`Content Creation`,

View File

@@ -1,5 +1,12 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
@@ -7,6 +14,7 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
@@ -14,15 +22,22 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm";
const boardsTabs = [
{ key: "boards" as const, label: t`Active` },
{ key: "archived" as const, label: t`Archived` },
];
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => openModal("NEW_BOARD"),
action: () => canCreateBoard && openModal("NEW_BOARD"),
description: t`Create new ${isTemplate ? "template" : "board"}`,
group: "ACTIONS",
});
@@ -32,29 +47,47 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
/>
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="m-auto h-full max-w-[1100px] p-8 px-5 md:px-28 md:py-12">
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`${isTemplate ? "Templates" : "Boards"}`}
</h1>
<div className="flex gap-2">
{!isTemplate && (
<Button
type="button"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
<Tooltip
content={
!canCreateBoard ? t`You don't have permission` : undefined
}
>
{t`Import`}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
if (canCreateBoard) openModal("IMPORT_BOARDS");
}}
disabled={!canCreateBoard}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
{t`Import`}
</Button>
</Tooltip>
)}
<Tooltip content={createModalShortcutTooltipContent}>
<Tooltip
content={
!canCreateBoard
? t`You don't have permission`
: createModalShortcutTooltipContent
}
>
<Button
type="button"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
onClick={() => {
if (canCreateBoard) openModal("NEW_BOARD");
}}
disabled={!canCreateBoard}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}
@@ -95,9 +128,79 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal>
</>
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
{!isTemplate ? (
<div className="flex h-full w-full flex-col">
<div className="focus:outline-none">
<div className="sm:hidden">
<Listbox
value={activeTab}
onChange={(tab) => setActiveTab(tab)}
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
"Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
/>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
{boardsTabs.map((tab) => (
<ListboxOption
key={tab.key}
value={tab.key}
className={({ selected }) =>
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
? "font-bold text-light-1000 dark:text-dark-1000"
: "font-normal text-light-1000 dark:text-dark-1000"
}`
}
>
{tab.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="hidden sm:block">
<div>
<nav
aria-label="Tabs"
className="-mb-px flex space-x-8 focus:outline-none"
>
{boardsTabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
</div>
</div>
<div className="flex h-full flex-row focus:outline-none">
{activeTab === "boards" && (
<BoardsList isTemplate={false} archived={false} />
)}
{activeTab === "archived" && (
<BoardsList isTemplate={false} archived={true} />
)}
</div>
</div>
) : (
<div className="flex h-full flex-row">
<BoardsList isTemplate={!!isTemplate} />
</div>
)}
</div>
</>
);

View File

@@ -8,6 +8,7 @@ import {
HiOutlineArrowRight,
HiOutlineCheckCircle,
HiOutlineClock,
HiOutlinePaperClip,
HiOutlinePencil,
HiOutlinePlus,
HiOutlineTag,
@@ -25,34 +26,56 @@ import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import { useLocalisation } from "~/hooks/useLocalisation";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
import Comment from "./Comment";
type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
type ActivityWithMergedLabels =
GetCardActivitiesOutput["activities"][number] & {
mergedLabels?: string[];
attachment?: {
publicId: string;
filename: string;
originalFilename: string;
} | null;
};
const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
};
const getUserDisplayName = (
user: { name?: string | null; email?: string | null } | null | undefined,
): string => {
if (user?.name?.trim()) return user.name;
if (user?.email) return user.email;
return t`Member`;
};
const getActivityText = ({
type,
toTitle,
fromList,
toList,
memberName,
memberEmail,
isSelf,
label,
fromTitle,
toDueDate,
dateLocale,
mergedLabels,
attachmentName,
}: {
type: ActivityType;
toTitle: string | null;
fromList: string | null;
toList: string | null;
memberName: string | null;
memberEmail: string | null;
isSelf: boolean;
label: string | null;
fromTitle?: string | null;
@@ -60,7 +83,9 @@ const getActivityText = ({
toDueDate?: Date | null;
dateLocale: DateFnsLocale;
mergedLabels?: string[];
attachmentName?: string | null;
}) => {
const displayName = memberName ?? memberEmail ?? t`Member`;
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
@@ -112,6 +137,8 @@ const getActivityText = ({
"card.updated.checklist.item.completed": t`completed a checklist item`,
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
"card.updated.attachment.added": t`added an attachment`,
"card.updated.attachment.removed": t`removed an attachment`,
"card.updated.dueDate.added": t`set the due date`,
"card.updated.dueDate.updated": t`updated the due date`,
"card.updated.dueDate.removed": t`removed the due date`,
@@ -138,23 +165,23 @@ const getActivityText = ({
);
}
if (type === "card.updated.member.added" && memberName) {
if (type === "card.updated.member.added" && displayName) {
if (isSelf) return <Trans>self-assigned the card</Trans>;
return (
<Trans>
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
assigned <TextHighlight>{truncate(displayName)}</TextHighlight> to the
card
</Trans>
);
}
if (type === "card.updated.member.removed" && memberName) {
if (type === "card.updated.member.removed" && displayName) {
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
return (
<Trans>
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
unassigned <TextHighlight>{truncate(displayName)}</TextHighlight> from
the card
</Trans>
);
@@ -244,6 +271,27 @@ const getActivityText = ({
);
}
if (type === "card.updated.attachment.added") {
const filename = attachmentName ?? toTitle;
if (!filename) return baseText;
return (
<Trans>
added an attachment <TextHighlight>{truncate(filename)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.attachment.removed") {
const filename = attachmentName ?? fromTitle;
if (!filename) return baseText;
return (
<Trans>
removed an attachment{" "}
<TextHighlight>{truncate(filename)}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.dueDate.added" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format(
@@ -296,6 +344,8 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
"card.updated.attachment.added": <HiOutlinePaperClip />,
"card.updated.attachment.removed": <HiOutlinePaperClip />,
"card.updated.dueDate.added": <HiOutlineClock />,
"card.updated.dueDate.updated": <HiOutlineClock />,
"card.updated.dueDate.removed": <HiOutlineClock />,
@@ -329,7 +379,7 @@ const ActivityList = ({
isAdmin?: boolean;
isViewOnly?: boolean;
}) => {
const { dateLocale, locale } = useLocalisation();
const { dateLocale } = useLocalisation();
const { data: sessionData } = authClient.useSession();
const utils = api.useUtils();
const [allActivities, setAllActivities] = useState<
@@ -351,7 +401,7 @@ const ActivityList = ({
limit: ACTIVITIES_PAGE_SIZE,
},
{
enabled: !!cardPublicId,
enabled: !!cardPublicId && cardPublicId.length >= 12,
},
);
@@ -379,25 +429,21 @@ const ActivityList = ({
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(
currentActivities.map((a) => a.publicId),
);
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
currentActivities = [...currentActivities, ...newActivities];
currentHasMore = nextPage.hasMore;
} else {
break;
}
const existingIds = new Set(
currentActivities.map((a) => a.publicId),
);
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
currentActivities = [...currentActivities, ...newActivities];
currentHasMore = nextPage.hasMore;
}
setAllActivities(currentActivities);
setHasMore(false);
};
fetchAllRemaining();
void fetchAllRemaining();
} else {
setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore);
@@ -424,17 +470,15 @@ const ActivityList = ({
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(allActivities.map((a) => a.publicId));
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
setAllActivities((prev) => [...prev, ...newActivities]);
setHasMore(nextPage.hasMore);
const existingIds = new Set(allActivities.map((a) => a.publicId));
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
setAllActivities((prev) => [...prev, ...newActivities]);
setHasMore(nextPage.hasMore);
if (!nextPage.hasMore) {
isFullyExpandedRef.current = true;
}
if (!nextPage.hasMore) {
isFullyExpandedRef.current = true;
}
} finally {
setIsLoadingMore(false);
@@ -454,13 +498,17 @@ const ActivityList = ({
fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null,
memberEmail: activity.member?.user?.email ?? null,
isSelf: activity.member?.user?.id === sessionData?.user.id,
label: activity.label?.name ?? null,
fromTitle: activity.fromTitle ?? null,
fromDueDate: activity.fromDueDate ?? null,
toDueDate: activity.toDueDate ?? null,
dateLocale: dateLocale,
mergedLabels: (activity as any).mergedLabels,
mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels,
attachmentName:
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
null,
});
if (activity.type === "card.updated.comment.added")
@@ -471,6 +519,7 @@ const ActivityList = ({
cardPublicId={cardPublicId}
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
image={activity.user?.image ?? null}
isLoading={isLoading}
createdAt={activity.createdAt.toISOString()}
comment={activity.comment?.comment}
@@ -493,6 +542,7 @@ const ActivityList = ({
size="sm"
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
imageUrl={getAvatarUrl(activity.user?.image ?? null) || undefined}
icon={getActivityIcon(
activity.type,
activity.fromList?.index,
@@ -505,7 +555,7 @@ const ActivityList = ({
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
<span className="font-medium dark:text-dark-1000">{`${getUserDisplayName(activity.user)} `}</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{activityText}
</span>

View File

@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
const uploadFile = async (file: File) => {
setUploading(true);
try {
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch(
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
{
method: "POST",
headers: {
"Content-Type": file.type,
"x-original-filename": file.name,
},
body: file,
},
);
if (!response.ok) {
throw new Error("Upload failed");
}
await invalidateCard(utils, cardPublicId);
showPopup({
header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Upload failed`,
message: t`Failed to upload attachment. Please try again.`,
icon: "error",
});
},
onSettled: () => {
setUploading(false);
},
});
const uploadFile = async (file: File) => {
setUploading(true);
try {
// Generate presigned URL
const { url, key } = await generateUploadUrl.mutateAsync({
cardPublicId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// Upload file to S3
const uploadResponse = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Confirm attachment in database
await confirmAttachment.mutateAsync({
cardPublicId,
s3Key: key,
filename: file.name,
originalFilename: file.name,
contentType: file.type,
size: file.size,
});
} catch {
showPopup({
header: t`Upload failed`,

View File

@@ -1,17 +1,20 @@
import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns";
import { useState } from "react";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form";
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
import { getAvatarUrl } from "~/utils/helpers";
interface FormValues {
comment: string;
@@ -22,11 +25,11 @@ const Comment = ({
cardPublicId,
name,
email,
image,
isLoading,
createdAt,
comment,
isAuthor,
isAdmin,
isEdited = false,
isViewOnly = false,
}: {
@@ -34,11 +37,11 @@ const Comment = ({
cardPublicId: string;
name: string;
email: string;
image: string | null;
isLoading: boolean;
createdAt: string;
comment: string | undefined;
isAuthor: boolean;
isAdmin: boolean;
isEdited: boolean;
isViewOnly: boolean;
}) => {
@@ -46,12 +49,37 @@ const Comment = ({
const utils = api.useUtils();
const { showPopup } = usePopup();
const { openModal } = useModal();
const { canEditComment, canDeleteComment } = usePermissions();
const { handleSubmit, setValue, watch } = useForm<FormValues>({
defaultValues: {
comment,
},
});
const { data: cardData } = api.card.byId.useQuery(
{
cardPublicId,
},
{
enabled: !!cardPublicId && cardPublicId.length >= 12,
},
);
const workspaceMembers: WorkspaceMember[] =
cardData?.list.board.workspace.members
.filter((member) => member.email)
.map((member) => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.user.id,
name: member.user.name ?? null,
image: member.user.image ?? null,
}
: null,
})) ?? [];
if (!publicId) return null;
const updateCommentMutation = api.card.updateComment.useMutation({
@@ -77,7 +105,7 @@ const Comment = ({
};
const dropdownItems = [
...(isAuthor
...(isAuthor && canEditComment
? [
{
label: t`Edit comment`,
@@ -86,7 +114,7 @@ const Comment = ({
},
]
: []),
...(isAuthor || isAdmin
...((isAuthor || canDeleteComment)
? [
{
label: t`Delete comment`,
@@ -108,6 +136,7 @@ const Comment = ({
size="sm"
name={name ?? ""}
email={email ?? ""}
imageUrl={getAvatarUrl(image) || undefined}
isLoading={isLoading}
/>
@@ -136,21 +165,28 @@ const Comment = ({
)}
</div>
{!isEditing ? (
<ContentEditable
html={comment ?? ""}
disabled={true}
className="break-anywhere mt-2 text-sm"
/>
<div className="mt-2">
<Editor
content={comment ?? null}
readOnly={true}
workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false}
disableHeadings={true}
/>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable
placeholder={t`Add a comment...`}
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end space-x-2">
<div className="mt-2">
<Editor
content={watch("comment")}
onChange={(value) => setValue("comment", value)}
workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
disableHeadings={true}
/>
</div>
<div className="flex justify-end space-x-2 mt-2">
<Button
size="sm"
variant="ghost"

View File

@@ -1,33 +1,94 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiLink,
HiOutlineCheckCircle,
HiOutlineTrash,
} from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { authClient } from "@kan/auth/client";
export default function BoardDropdown() {
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
export default function CardDropdown({
cardPublicId,
isTemplate,
boardPublicId,
cardCreatedBy,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const handleCopyCardLink = async () => {
const path =
isTemplate && boardPublicId
? `/templates/${boardPublicId}/cards/${cardPublicId}`
: `/cards/${cardPublicId}`;
const url = `${window.location.origin}${path}`;
try {
await navigator.clipboard.writeText(url);
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(canEditCard
? [
{
label: t`Add checklist`,
action: () => openModal("ADD_CHECKLIST"),
icon: (
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
...(canDeleteCard || isCreator
? [
{
label: t`Delete card`,
action: () => openModal("DELETE_CARD"),
icon: (
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
),
},
]
: []),
];
if (items.length === 0) {
return null;
}
return (
<Dropdown
items={[
{
label: t`Add checklist`,
action: () => openModal("ADD_CHECKLIST"),
icon: (
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
),
},
{
label: t`Delete card`,
action: () => openModal("DELETE_CARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]}
>
<Dropdown items={items}>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -12,12 +12,14 @@ interface DueDateSelectorProps {
cardPublicId: string;
dueDate: Date | null | undefined;
isLoading?: boolean;
disabled?: boolean;
}
export function DueDateSelector({
cardPublicId,
dueDate,
isLoading = false,
disabled = false,
}: DueDateSelectorProps) {
const { showPopup } = usePopup();
const utils = api.useUtils();
@@ -105,9 +107,9 @@ export function DueDateSelector({
<div className="relative flex w-full items-center text-left">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={isLoading || disabled}
className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}
>
{dueDate ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
@@ -118,7 +120,7 @@ export function DueDateSelector({
</>
)}
</button>
{isOpen && (
{isOpen && !disabled && (
<>
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
<div

View File

@@ -17,12 +17,14 @@ interface LabelSelectorProps {
leftIcon: React.ReactNode;
}[];
isLoading: boolean;
disabled?: boolean;
}
export default function LabelSelector({
cardPublicId,
labels,
isLoading,
disabled = false,
}: LabelSelectorProps) {
const utils = api.useUtils();
const { openModal } = useModal();
@@ -93,9 +95,10 @@ export default function LabelSelector({
handleSelect={(_, label) => {
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
}}
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={() => openModal("NEW_LABEL")}
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
createNewItemLabel={t`Create new label`}
disabled={disabled}
asChild
>
{selectedLabels.length ? (
@@ -110,7 +113,7 @@ export default function LabelSelector({
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
</div>
) : (
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
<HiMiniPlus size={22} className="pr-2" />
{t`Add label`}
</div>

Some files were not shown because too many files have changed in this diff Show More