Compare commits

..

2 Commits

Author SHA1 Message Date
Henry
bedf49387b feat: open new checklist item form on enter 2025-12-18 22:08:23 +00:00
Henry
2694498335 fix: update checklist when pressing enter on edit 2025-12-18 22:00:25 +00:00
166 changed files with 3591 additions and 26062 deletions

View File

@@ -15,4 +15,4 @@ pnpm-debug.log
README.md
.next
# .git
.git

View File

@@ -32,23 +32,15 @@ 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

@@ -34,8 +34,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
@@ -76,42 +74,6 @@ jobs:
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
# Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
- name: Extract version
id: version
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
VERSION="${{ github.ref_name }}"
# Remove 'v' prefix if present
VERSION="${VERSION#v}"
else
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
if [[ -n "$GIT_DESCRIBE" ]]; then
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
# Format as tag+commit (SemVer build metadata)
TAG_VERSION="${BASH_REMATCH[1]}"
COMMIT_HASH="${BASH_REMATCH[2]}"
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
# Exactly on a tag
VERSION="${BASH_REMATCH[1]}"
else
# Fallback: just commit hash
COMMIT_SHA="${{ github.sha }}"
VERSION="${COMMIT_SHA:0:7}"
fi
else
# No tags exist, use commit hash
COMMIT_SHA="${{ github.sha }}"
VERSION="${COMMIT_SHA:0:7}"
fi
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
@@ -124,8 +86,6 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max

315
AGENTS.md
View File

@@ -1,315 +0,0 @@
# AGENTS.md
## Project Overview
Kan is an open-source project management tool (Trello alternative) built with:
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
- **Backend**: tRPC, Node.js
- **Database**: PostgreSQL with Drizzle ORM
- **Monorepo**: pnpm workspaces with Turbo
- **Auth**: Better Auth
- **Internationalization**: Lingui
## Setup Commands
- Install deps: `pnpm install`
- Start dev server: `pnpm dev`
- Create migrations: `cd packages/db && pnpm drizzle-kit generate --name "AddFieldToTable"`
- Run database migrations: `pnpm db:migrate`
- Run linter: `pnpm lint`
- Run type check: `pnpm typecheck`
- Format code: `pnpm format:fix`
- Extract i18n strings: `pnpm lingui:extract`
## Project Structure
- `apps/web/` - Next.js web application
- `packages/api/` - tRPC API routers
- `packages/db/` - Database schema, migrations, and repositories
- `packages/auth/` - Authentication package
- `packages/shared/` - Shared utilities
- `packages/email/` - Email templates and sending
- `packages/stripe/` - Stripe integration
- `tooling/` - Shared tooling configs (ESLint, Prettier, TypeScript)
## Code Style
### TypeScript
- Use TypeScript strictly - avoid `any` types
- Prefer explicit types over inference when it improves clarity
- Use `as const` for literal types when appropriate
- Follow existing patterns for type definitions
### Naming Conventions
- **Files**: kebab-case for files (e.g., `card-repo.ts`)
- **Components**: PascalCase for React components
- **Functions**: camelCase for functions
- **Constants**: UPPER_SNAKE_CASE for constants
- **Types/Interfaces**: PascalCase
### Database Layer (`packages/db/`)
- **Schema**: Define schemas in `src/schema/` using Drizzle ORM
- **Migrations**: Create migrations with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
- **Repositories**: Put database queries in `src/repository/` files
- **Soft Deletes**: Use `deletedAt` timestamp for soft deletion (not hard deletes)
- **Index Management**: Cards have `index` fields that must be maintained sequentially per list
- **Activity Logging**: Use `card_activity` table to track all card changes
### API Layer (`packages/api/`)
- **Routers**: Create tRPC routers in `src/routers/`
- **Procedures**: Use `protectedProcedure` for authenticated endpoints, `publicProcedure` for public
- **Validation**: Use Zod schemas for input validation
- **Error Handling**: Use `TRPCError` with appropriate error codes
- **OpenAPI**: Add OpenAPI metadata for all endpoints
- **Authorization**: Always check workspace membership with `assertUserInWorkspace`
### Frontend (`apps/web/`)
- **Components**: React components in `src/components/`
- **Views**: Page-level components in `src/views/`
- **Hooks**: Custom hooks in `src/hooks/`
- **i18n**: Use `t` template literal for translations (Lingui)
- **Styling**: Use Tailwind CSS classes
- **State Management**: Use tRPC React Query hooks for server state
- **Modals**: Use `useModal` hook for modal management
- **Popups**: Use `usePopup` hook for toast notifications
## Key Concepts
### Cards
- Cards are the main entity in Kan
- Cards belong to Lists, which belong to Boards
- Cards have: title, description, labels, members, checklists, comments, attachments, due dates
- Cards use soft deletion (`deletedAt` field)
- Cards have an `index` field that must be maintained sequentially per list
- All card changes are tracked in `card_activity` table
### Activity Tracking
- Every significant card change creates an activity record
- Activity types include: created, updated (various fields), etc.
- Activities are displayed in card activity feeds
### Workspaces & Boards
- Users belong to Workspaces
- Boards belong to Workspaces
- Workspace members have different permission levels
- Boards can be public or private
### Soft Deletion Pattern
- Entities use `deletedAt` timestamp for soft deletion
- Queries filter with `isNull(table.deletedAt)` to exclude deleted items
## File Locations Reference
### Database
- Schema: `packages/db/src/schema/*.ts`
- Repositories: `packages/db/src/repository/*.repo.ts`
- Migrations: `packages/db/migrations/`
### API
- Routers: `packages/api/src/routers/*.ts`
- Utils: `packages/api/src/utils/`
- Types: `packages/api/src/types/`
### Frontend
- Components: `apps/web/src/components/`
- Views: `apps/web/src/views/`
- Pages: `apps/web/src/pages/`
- Hooks: `apps/web/src/hooks/`
- Utils: `apps/web/src/utils/`
- Locales: `apps/web/src/locales/`
## Database Patterns
- **Soft Deletes**: Always filter with `isNull(table.deletedAt)` in queries
- **Public IDs**: Use 12-character public IDs (`publicId`) for all user-facing entities
- **Internal IDs**: Never expose internal database IDs (e.g., `id`, `cardId`, `listId`) in API responses or URLs - always use `publicId` externally
- **Transactions**: Use database transactions for multi-step operations
- **Index Management**: When deleting/moving cards, maintain sequential indices
- **Activity Tracking**: Create activity records for all significant changes
## API Patterns
- **Input Validation**: Always validate inputs with Zod
- **Error Messages**: Provide clear, user-friendly error messages
- **Optimistic Updates**: Use tRPC's `onMutate` for optimistic UI updates
- **Cache Invalidation**: Properly invalidate queries after mutations
- **ID Exposure**: Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` for external communication
## Important Patterns
### Card Index Management
When cards are created, moved, or deleted, their indices must be maintained:
- New cards: Append to end (max index + 1) or insert at position
- Moving cards: Adjust indices of affected cards
- Deleting cards: Decrement indices of cards after deleted one
- Always use transactions for index updates
### Activity Logging
Create activity records for:
- Card creation
- Card updates (title, description, list, etc.)
- Label/member additions/removals
- Comments
- Checklists and items
- Attachments
- Due dates
### Authorization
Always check:
1. User is authenticated
2. User has access to workspace
3. User has permission for the operation
Use `assertUserInWorkspace` helper for workspace checks.
### Error Handling
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
- Provide user-friendly error messages
- Log errors appropriately
- Show popup notifications for user-facing errors
## Common Patterns
### Creating a Card
1. Create card in repository with proper index management
2. Create `card.created` activity
3. Handle label/member relationships if provided
4. Return the created card
### Updating a Card
1. Validate user has workspace access
2. Update card fields
3. Create appropriate activity records
4. Invalidate relevant queries
### Querying Cards
- Always filter by `isNull(cards.deletedAt)` in queries
- Include related data (labels, members, checklists) via Drizzle relations
- Order by `index` for proper card ordering
## Adding a New Feature
1. **Database**: Update schema in `packages/db/src/schema/`
2. **Migration**: Create migration with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
3. **Repository**: Add repository functions in `packages/db/src/repository/`
4. **API**: Add tRPC router procedures in `packages/api/src/routers/`
5. **Frontend**: Add UI components in `apps/web/src/`
6. **i18n**: Add translations for new strings
## Database Changes
- Always create migrations (never modify existing migrations)
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
- Run migrations with: `pnpm db:migrate`
- Update schema files in `packages/db/src/schema/`
- Test migrations on development database first
- Update TypeScript types after schema changes
- Consider index management for card operations
## API Endpoints
- Use tRPC procedures (not REST)
- Add OpenAPI metadata for documentation
- Validate inputs with Zod
- Check workspace permissions
- Create activity records for significant changes
## Frontend Components
- Use Tailwind for styling
- Follow existing component patterns
- Use tRPC hooks for data fetching
- Implement optimistic updates where appropriate
- Add proper loading and error states
## Testing Instructions
- Test database operations in transactions that rollback
- Test authorization checks
- Test index management when moving/deleting cards
- Test activity logging
- Test UI interactions
- Run `pnpm lint` and `pnpm typecheck` before committing
## Performance Considerations
- Use database indexes appropriately
- Batch operations when possible
- Avoid N+1 queries
- Use transactions for related operations
- Implement optimistic updates in UI
## Security
- Always check workspace membership before operations
- Validate all inputs
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
- Sanitize user input
## Internationalization
- All user-facing strings must use `t` template literal
- Add translations to locale files in `apps/web/src/locales/`
- Run `pnpm lingui:extract` to update translation files
- Update locale files for all languages
## Dependencies
- Use workspace dependencies (`workspace:*`) for internal packages
- Keep dependencies up to date
- Use catalog for shared dependency versions
## When Implementing Features
### Adding New Card Fields
1. Update schema in `packages/db/src/schema/cards.ts`
2. Create migration
3. Update repository functions
4. Add API endpoints
5. Update frontend components
6. Add activity tracking if needed
### Adding New Activity Types
1. Add to `activityTypes` array in schema
2. Create migration to update enum
3. Use in activity creation code
4. Update activity display components if needed
## Git & Commits
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
- Keep commits focused on single changes
- Reference issue numbers when applicable
## PR Instructions
- Title format: `feat: description` or `fix: description`
- Always run `pnpm lint` and `pnpm typecheck` before committing
- Provide clear description of changes
- Include screenshots for UI changes
- Keep PRs focused on a single feature/fix

View File

@@ -47,17 +47,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳
### One-click Deployments
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
</a>
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
1. Create a new file called `docker-compose.yml` and paste the following configuration:
@@ -151,7 +141,6 @@ 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` |
@@ -161,7 +150,6 @@ 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` |
@@ -183,7 +171,6 @@ 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

@@ -19,45 +19,43 @@ RUN pnpm config set store-dir ~/.pnpm-store
# 2. Prune projects
FROM base AS pruner
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
# RUN export VERSION=$(npm run version)
ARG PROJECT
RUN apk add --no-cache git
# Set working directory
WORKDIR /app
# It might be the path to <ROOT> turborepo
COPY . .
# 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
# 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
WORKDIR /app
# Copy lockfile and package.json's of isolated subworkspace
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
ENV CI=true
# First install the dependencies (as they change less often)
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
# Copy source code of isolated subworkspace
COPY --from=pruner /app/out/full/ .
# Use provided APP_VERSION or auto-generated from pruner stage
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
RUN pnpm build --filter=${PROJECT}
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \

View File

@@ -2,7 +2,7 @@
"version": 0,
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
},
"buckets": {
"po": {

View File

@@ -5,8 +5,6 @@ checksums:
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
"%7B0%7D%20has%20been%20added%20to%20your%20favorites./singular": 0f752180183c80703b3f9f39dc97195f
"%7B0%7D%20has%20been%20removed%20from%20your%20favorites./singular": 26e4b4997ad8a711d0b3e46337799454
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
@@ -29,7 +27,6 @@ checksums:
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
Add%20label/singular: 0be732d46df263265935fda342097a08
Add%20member/singular: 11979625770516ca287e929381778e02
Add%20to%20favorites/singular: 532155acb0a46f2b6c37e15afb3ad21e
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
@@ -38,14 +35,10 @@ checksums:
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
Added%20to%20favorites/singular: 848046ec65d15913112b466c544722eb
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
Admin/singular: 90eb20f1400db82ab874744e47836dc6
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20member%20permission%20overrides%20have%20been%20reset%20to%20their%20role%20defaults./singular: e5c38724a283373506d53afd22b6d096
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
@@ -94,30 +87,6 @@ checksums:
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
Bug/singular: 4509fffdb5931f8905063c80cf802d71
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
Can%20add%20comments/singular: d7d70b75780156312701f4c5eed1b285
Can%20create%20boards/singular: 8538236f8caafd4eaa751b4ec45f1699
Can%20create%20cards/singular: dd1cce2d52e0fb261676750bfc46da22
Can%20create%20lists/singular: 53c3c1343efff494640b1227e4444de2
Can%20delete%20boards/singular: 818ff50f9f21d8ed3741a7933d66b794
Can%20delete%20cards/singular: c62309a7e52958aa4bdb477c07d1855d
Can%20delete%20comments/singular: 99884fcaf89c47aa33c6ddfd9f94ea5f
Can%20delete%20lists/singular: 8d6ff6cd43c6b9fcbc9fc7954d19409e
Can%20delete%20workspace/singular: 41bd5a6636500b1a6e04184e6f566198
Can%20edit%20boards/singular: 8b77fabfd955d5507b1826dc4a5c1108
Can%20edit%20cards/singular: 47e6c92c1056fd6e8b517ba456b3f607
Can%20edit%20comments/singular: a0caeec2b2b64e9f371f07dabed5733d
Can%20edit%20lists/singular: 1ce5feb15139c881b615edab065b3e12
Can%20edit%20member%20roles%20and%20permissions/singular: c364e8e8866111a471f4081db0730049
Can%20edit%20workspace/singular: 9768f990844e215c06910fed250da23a
Can%20invite%20members/singular: e02e562bcb7f46008d9595e485951413
Can%20manage%20workspace%20settings/singular: 27eb18d3be5b3d813996b7d5071e0317
Can%20remove%20members/singular: 97a452b0fb4b661eaca7e5b3d5b49dcd
Can%20view%20boards/singular: 14977bbbb566f72fc74e17d99bad09bb
Can%20view%20cards/singular: 5e728083853948c9d618f2276732d5cd
Can%20view%20comments/singular: 76dbbc9ae4bff589d391c67c84ac6470
Can%20view%20lists/singular: e36331c4a49f376befc9f0aa42492b01
Can%20view%20members/singular: 5b75a467257a1db29d466c0d9ccea3df
Can%20view%20workspace/singular: 79a12c55fcd04ea69cbb85b906794e9b
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
Card/singular: bba0beaced7ea954ceb980f2b022ffee
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
@@ -129,9 +98,6 @@ checksums:
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
Clear%20all%20custom%20permissions%3F/singular: 31d0962985c83a29558c98a765bb1b16
Clear%20any%20custom%20member%20permissions%20so%20that%20all%20members%20only%20inherit%20permissions%20from%20their%20role%20defaults./singular: e493291818059bfd51f2c87b938616c4
Clear%20custom%20permissions/singular: 288ce8688fd09c5a01eda5a6ba4bc98a
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
Close/singular: 2c2e22f8424a1031de89063bd0022e16
@@ -146,7 +112,6 @@ checksums:
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
Configure%20which%20actions%20are%20allowed%20for%20each%20workspace%20role.%20These%20permissions%20apply%20to%20all%20members%20with%20that%20role./singular: 0a46a8a30c6c0ebdcd01e72a7d64ec64
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
@@ -158,7 +123,6 @@ checksums:
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
@@ -180,7 +144,6 @@ checksums:
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
Custom%20permissions/singular: 6f1748601979e2e4548877b292b43a20
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
@@ -220,19 +183,14 @@ checksums:
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
Edit%20permissions/singular: 244558dd716491b7ed72ba8ab73aa28f
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
email/singular: f31eb214738e037d58e26149797739df
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
@@ -262,7 +220,6 @@ checksums:
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
@@ -298,7 +255,6 @@ checksums:
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
Guest/singular: 2aec6d6ebe0d9a1db0a5c8cd5a98b8c3
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
@@ -360,7 +316,6 @@ checksums:
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
Lists/singular: 9f4a73afc8de321175d71935134ef066
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
Loading%20permissions.../singular: a5665279d4e439186825057c32d4d976
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
@@ -372,7 +327,6 @@ checksums:
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
Member/singular: 1606dc30b369856b9dba1fe9aec425d2
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
Monthly/singular: 818f1192e32bb855597f930d3e78806e
@@ -405,9 +359,7 @@ checksums:
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
No%20lists%20have%20been%20created%20yet/singular: f18ee3d7230cc33b68bd17b429d1d442
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
No%20roles%20found%20for%20this%20workspace%20yet./singular: 2eb502333aaf6c58e8ab23d881e2e357
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
@@ -420,7 +372,6 @@ checksums:
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
Overrides%20cleared/singular: f2e380efbae31a6113cc0cbf0eadf7b0
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
@@ -433,10 +384,6 @@ checksums:
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
Permission/singular: cc2ed7274bd8267f9e0a10b079584d8b
Permissions/singular: 2160be68b1d6b6577e64634e9feba2ed
Permissions%20reset/singular: dd4776f04aca858deb95887e807570fc
Permissions%20updated/singular: 0df44570b783b8b284610da8627d333d
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
Planning/singular: 353f58c75248275fe091740607501610
Platform/singular: c68862170146325333c7f25af11a3fa2
@@ -444,7 +391,6 @@ checksums:
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
@@ -465,26 +411,22 @@ checksums:
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
Remove%20from%20favorites/singular: 74d9cc2d2f3c7a7dc42a7e62c8ee06d5
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
Removed%20from%20favorites/singular: 540609ab686ed67b1cfd7649467b8bc1
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
Research/singular: 3368e9638d1619babd6df9fad592274f
Reset%20to%20role%20defaults/singular: fc9ff8ea0503e50da3e9b3e5d8bb75dd
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
Resources/singular: ec7fb05ed963bb6781a35782b3475502
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
Review/singular: 299f75db25382980b2895622d7712927
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
Role/singular: 53743bbb6ca938f5b893552e839d067f
Role%20updated/singular: 73606ae9c35101f1bb518961f68559d0
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
@@ -506,7 +448,6 @@ checksums:
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
Settings%20%7C%20Permissions/singular: 8aa60ed978b9f45a705d99dc1ee04f37
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
@@ -541,8 +482,6 @@ checksums:
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
The%20member's%20permissions%20have%20been%20updated./singular: 6ae91be2c5c0504bf521335094c50fa4
The%20member's%20role%20has%20been%20updated./singular: e30c30a2beaac7d046c35f628102f83b
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
Theme/singular: 21fe00b7a518089576fb83c08631107a
@@ -552,14 +491,11 @@ checksums:
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
This%20member's%20permissions%20have%20been%20reset%20to%20their%20role%20defaults./singular: 730f33b8f1fc002c4f393ccd262b6664
This%20will%20remove%20all%20custom%20member%20permissions%20in%20this%20workspace.%20Members%20will%20inherit%20permissions%20only%20from%20their%20roles./singular: 1c989752736fa41ecdd68ea890f16a15
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
Title/singular: 344e64395eaff6822a57d18623853e1a
To%20Do/singular: d60813ea824f373462471e092d136eed
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
@@ -571,7 +507,6 @@ checksums:
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
Unable%20to%20clear%20overrides/singular: dfeac280858332082ad34d2623a59863
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
@@ -584,9 +519,7 @@ checksums:
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
Unable%20to%20reset%20permissions/singular: fb09d88ff4eb32afb852d733bafd515b
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
Unable%20to%20update%20board/singular: 62899897d7bffcb1df3af77d460f0881
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
@@ -597,8 +530,6 @@ checksums:
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
Unable%20to%20update%20permissions/singular: 134f20c5f2463167509562b284ef1c61
Unable%20to%20update%20role/singular: 4f2240aeff6f7275feb33ca3f608e48c
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
@@ -657,7 +588,6 @@ checksums:
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
Workspace%20permissions/singular: 72c0202f30e543eb81bf930d85647096
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
@@ -668,12 +598,10 @@ checksums:
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
You%20don't%20have%20permission/singular: 11d928b1993d95d54a95f85f8ae5016d
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
You%20need%20to%20be%20an%20admin%20to%20manage%20workspace%20permissions./singular: e2e816f2b7a13b1056d79e7f25088664
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
@@ -689,4 +617,3 @@ checksums:
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148

View File

@@ -1,7 +1,7 @@
import type { LinguiConfig } from "@lingui/conf";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
sourceLocale: "en",
catalogs: [
{

View File

@@ -50,10 +50,6 @@ const config = {
protocol: "https",
hostname: "*.googleusercontent.com",
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
},
];
// Extract root domain from S3_ENDPOINT and add wildcard pattern

View File

@@ -10,8 +10,6 @@
"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",
@@ -47,7 +45,6 @@
"@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",
@@ -89,8 +86,7 @@
"jiti": "^1.21.6",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vitest": "^3.0.0"
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

View File

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

View File

@@ -31,7 +31,6 @@ interface CheckboxDropdownProps {
handleEdit?: (key: string) => void;
handleCreate?: () => void;
asChild?: boolean;
disabled?: boolean;
}
export default function CheckboxDropdown({
@@ -45,7 +44,6 @@ export default function CheckboxDropdown({
handleEdit,
handleCreate,
asChild = true,
disabled = false,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
@@ -60,13 +58,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}
@@ -134,8 +132,7 @@ export default function CheckboxDropdown({
<>
<Menu.Button
as={asChild ? "div" : undefined}
disabled={disabled}
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
className="h-full w-full cursor-pointer focus-visible:outline-none"
>
{children}
</Menu.Button>

View File

@@ -12,7 +12,6 @@ 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 {
@@ -45,12 +44,6 @@ 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);
@@ -162,12 +155,8 @@ 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={{
displayName: user?.name ?? session?.user.name,
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
user={{ email: session?.user.email, image: session?.user.image }}
isLoading={sessionLoading}
onCloseSideNav={closeSideNav}
/>
</div>

View File

@@ -6,7 +6,7 @@ export default function Dropdown({
children,
disabled,
}: {
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
items: { label: string; action: () => void; icon?: React.ReactNode }[];
children: React.ReactNode;
disabled?: boolean;
}) {
@@ -30,14 +30,13 @@ export default function Dropdown({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<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">
<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">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label} disabled={item.disabled}>
<Menu.Item key={item.label}>
<button
onClick={item.action}
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"
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"
>
{item.icon}
{item.label}

View File

@@ -1,4 +1,3 @@
import type { Range as TiptapRange } from "@tiptap/core";
import type { Editor as TiptapEditor } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
@@ -45,7 +44,6 @@ import { Markdown } from "tiptap-markdown";
import { getAvatarUrl } from "~/utils/helpers";
import Avatar from "./Avatar";
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -58,7 +56,7 @@ declare module "@tiptap/core" {
export interface SlashCommandItem {
title: string;
icon?: React.ReactNode;
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
command?: (props: { editor: TiptapEditor; range: Range }) => void;
disabled?: boolean;
}
@@ -433,14 +431,12 @@ export default function Editor({
onBlur,
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -488,17 +484,10 @@ export default function Editor({
}),
);
const q = query.toLowerCase();
return all.filter(
(u) =>
u.label &&
typeof u.label === "string" &&
u.label.toLowerCase().includes(q),
);
return all.filter((u) => u.label.toLowerCase().includes(q));
},
command: ({ editor, range, props }) => {
const id = props.id ?? "";
const label = props.label ?? "";
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span>&nbsp;`;
command: ({ editor, range, props }: any) => {
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span>&nbsp;`;
editor
.chain()
@@ -514,7 +503,6 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
@@ -571,9 +559,6 @@ export default function Editor({
text-decoration: none;
font-weight: 500;
}
.tiptap [data-youtube] {
margin: 1rem 0;
}
`}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent

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 m-3"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
>
<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-[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="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="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="flex-shrink-0">
{popupIcon === "success" && (
<HiOutlineCheckCircle
aria-hidden="true"
className="h-5 w-5 text-green-400"
className="h-6 w-6 text-green-400"
/>
)}
{popupIcon === "error" && (
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-5 w-5 text-red-400"
className="h-6 w-6 text-red-400"
/>
)}
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
{popupHeader}
</p>
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
{popupMessage}
</p>
</div>
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
<div className="ml-4 flex flex-shrink-0">
<button
type="button"
onClick={() => {
hidePopup();
}}
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"
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"
>
<span className="sr-only">Close</span>
<HiXMark
aria-hidden="true"
className="h-4 w-4 text-dark-900"
className="h-5 w-5 text-dark-900"
/>
</button>
</div>

View File

@@ -46,8 +46,7 @@ const Button: React.FC<{
onMouseEnter={handleMouseEnter}
onClick={handleClick}
className={twMerge(
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
isCollapsed ? "md:justify-center" : "justify-between",
"group flex h-[34px] items-center justify-between rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
current
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",

View File

@@ -14,11 +14,8 @@ 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;
@@ -27,12 +24,8 @@ 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",
@@ -44,19 +37,13 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "workspace",
icon: <HiOutlineRectangleGroup />,
label: t`Workspace`,
condition: canViewWorkspace,
},
{
key: "permissions",
icon: <HiOutlineShieldCheck />,
label: t`Permissions`,
condition: isAdmin,
condition: true,
},
{
key: "billing",
label: t`Billing`,
icon: <HiOutlineBanknotes />,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
},
{
key: "api",
@@ -68,7 +55,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
label: t`Integrations`,
condition: canEditWorkspace,
condition: true,
},
];
@@ -110,7 +97,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,7 +39,6 @@ interface SideNavigationProps {
}
interface UserType {
displayName?: string | null | undefined;
email?: string | null | undefined;
image?: string | null | undefined;
}
@@ -55,10 +54,9 @@ export default function SideNavigation({
const [isInitialised, setIsInitialised] = useState(false);
const { openModal } = useModal();
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const { data: workspaceData } = api.workspace.byId.useQuery({
workspacePublicId: workspace.publicId,
});
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
@@ -208,8 +206,7 @@ export default function SideNavigation({
<div className="space-y-2">
<UserMenu
displayName={user.displayName ?? undefined}
email={user.email ?? "Email not provided?"}
email={user.email ?? ""}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}

View File

@@ -6,20 +6,16 @@ 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">
{showLabel && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
<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,8 +24,6 @@ export function Tooltip({
useEffect(() => {
if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div");
const root = createRoot(container);
rootRef.current = root;
@@ -37,7 +35,6 @@ export function Tooltip({
delay,
interactive: false,
theme: "tooltip",
touch: false,
});
return () => {

View File

@@ -9,7 +9,6 @@ import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { env } from "~/env";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
@@ -17,7 +16,6 @@ import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
displayName: string | undefined;
email: string;
isLoading: boolean;
isCollapsed?: boolean;
@@ -27,7 +25,6 @@ interface UserMenuProps {
export default function UserMenu({
imageUrl,
email,
displayName,
isLoading,
isCollapsed = false,
onCloseSideNav,
@@ -77,7 +74,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 ? (displayName || email) : undefined}
title={isCollapsed ? email : undefined}
>
{avatarUrl ? (
<Image
@@ -104,7 +101,7 @@ export default function UserMenu({
isCollapsed && "md:hidden",
)}
>
{displayName || email}
{email}
</span>
</Menu.Button>
)}
@@ -228,25 +225,6 @@ export default function UserMenu({
</button>
</Menu.Item>
</div>
{env.NEXT_PUBLIC_APP_VERSION && (
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<Link
href={
env.NEXT_PUBLIC_APP_VERSION.includes("+")
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
}
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
>
Version: {env.NEXT_PUBLIC_APP_VERSION}
</Link>
</Menu.Item>
</div>
)}
</div>
</Menu.Items>
</Transition>

View File

@@ -1,189 +0,0 @@
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
interface EditYouTubeFormInput {
url: string;
title: string;
}
interface EditYouTubeModalState {
url: string;
title: string;
onSave: (url: string, title: string) => void;
}
export function EditYouTubeModal() {
const { closeModal, getModalState } = useModal();
const [isValidating, setIsValidating] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
// Get initial values and callback from modal state
const modalState = getModalState("EDIT_YOUTUBE") as
| EditYouTubeModalState
| undefined;
const initialUrl = modalState?.url ?? "";
const initialTitle = modalState?.title ?? "";
const onSave = modalState?.onSave;
const { register, handleSubmit, watch, reset } =
useForm<EditYouTubeFormInput>({
defaultValues: {
url: initialUrl,
title: initialTitle,
},
});
// Reset form when modal state changes (when modal opens with new values)
useEffect(() => {
if (modalState) {
reset({
url: modalState.url,
title: modalState.title,
});
}
}, [modalState, reset]);
const currentUrl = watch("url");
const onSubmit = async (values: EditYouTubeFormInput) => {
// Validate URL
if (!isYouTubeUrl(values.url)) {
setUrlError(t`Please enter a valid YouTube URL`);
return;
}
setUrlError(null);
setIsValidating(true);
try {
// If title is empty and URL changed, fetch new title
let finalTitle = values.title;
if (!finalTitle.trim() && values.url !== initialUrl) {
const metadata = await fetchYouTubeMetadata(values.url);
finalTitle = metadata?.title ?? "YouTube Video";
} else if (!finalTitle.trim()) {
// Keep existing title if no new title provided and URL unchanged
finalTitle = initialTitle || "YouTube Video";
}
if (onSave) {
onSave(values.url, finalTitle);
}
closeModal();
} catch (error) {
console.error(error);
setUrlError(t`Failed to fetch video information`);
} finally {
setIsValidating(false);
}
};
// Auto-focus on title input (more useful for editing)
useEffect(() => {
const titleElement: HTMLElement | null =
document.querySelector<HTMLElement>("#youtube-title");
if (titleElement) titleElement.focus();
}, []);
// Validate URL on change
useEffect(() => {
if (currentUrl && !isYouTubeUrl(currentUrl)) {
setUrlError(t`Please enter a valid YouTube URL`);
} else {
setUrlError(null);
}
}, [currentUrl]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<div className="space-y-4">
<div>
<label
htmlFor="youtube-title"
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
>
{t`Title`}
</label>
<Input
id="youtube-title"
placeholder={t`Enter a custom title`}
{...register("title")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
<div>
<label
htmlFor="youtube-url"
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
>
{t`YouTube URL`}
</label>
<Input
id="youtube-url"
placeholder={t`https://www.youtube.com/watch?v=...`}
{...register("url", { required: true })}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
{urlError && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{urlError}
</p>
)}
</div>
</div>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="space-x-2">
<Button
type="button"
variant="secondary"
onClick={() => closeModal()}
>
{t`Cancel`}
</Button>
<Button
type="submit"
isLoading={isValidating}
disabled={!watch("url") || !!urlError}
>
{t`Save`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -1,53 +0,0 @@
import YouTubeDropdown from "./YouTubeDropdown";
interface YouTubeCardProps {
videoId: string;
url: string;
title: string;
showEmbed?: boolean;
onConvertToLink: () => void;
onDelete: () => void;
onUpdate: (url: string, title: string) => void;
}
const YouTubeCard = ({
videoId,
url,
title,
showEmbed = true,
onConvertToLink,
onDelete,
onUpdate,
}: YouTubeCardProps) => {
return (
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
<div className="flex items-center justify-between gap-6 p-0 px-6">
<h3 className="truncate text-sm font-medium">{title}</h3>
<div className="mt-3">
<YouTubeDropdown
url={url}
title={title}
onConvertToLink={onConvertToLink}
onDelete={onDelete}
onUpdate={onUpdate}
/>
</div>
</div>
{showEmbed && videoId && (
<div className="p-6 pt-1">
<iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={title}
className="aspect-video w-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
)}
</div>
);
};
export default YouTubeCard;

View File

@@ -1,63 +0,0 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiLink,
HiPencil,
HiTrash,
} from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import Dropdown from "../Dropdown";
interface YouTubeDropdownProps {
url: string;
title: string;
onConvertToLink: () => void;
onDelete: () => void;
onUpdate: (url: string, title: string) => void;
}
const YouTubeDropdown = ({
url,
title,
onConvertToLink,
onDelete,
onUpdate,
}: YouTubeDropdownProps) => {
const { openModal, setModalState } = useModal();
const handleEdit = () => {
setModalState("EDIT_YOUTUBE", {
url,
title,
onSave: onUpdate,
});
openModal("EDIT_YOUTUBE");
};
return (
<Dropdown
items={[
{
label: t`Edit`,
action: handleEdit,
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
},
{
label: t`Convert to link`,
action: onConvertToLink,
icon: <HiLink className="h-4 w-4 text-dark-900" />,
},
{
label: t`Delete`,
action: onDelete,
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);
};
export default YouTubeDropdown;

View File

@@ -1,196 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
import YouTubeNodeView from "./YouTubeNodeView";
export interface YouTubeOptions {
inline: boolean;
HTMLAttributes: Record<string, undefined>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
youTube: {
setYouTubeEmbed: (options: {
videoId: string;
url: string;
title: string;
thumbnailUrl?: string;
showEmbed?: boolean;
}) => ReturnType;
};
}
}
export const YouTubeNode = Node.create<YouTubeOptions>({
name: "youtube",
group: "block",
atom: true,
addOptions() {
return {
inline: false,
HTMLAttributes: {},
};
},
addAttributes() {
return {
videoId: {
default: null,
parseHTML: (element) => element.getAttribute("data-video-id"),
renderHTML: (attributes) => {
if (!attributes.videoId) return {};
return { "data-video-id": attributes.videoId as string };
},
},
url: {
default: null,
parseHTML: (element) => element.getAttribute("data-url"),
renderHTML: (attributes) => {
if (!attributes.url) return {};
return { "data-url": attributes.url as string };
},
},
title: {
default: "YouTube Video",
parseHTML: (element) => element.getAttribute("data-title"),
renderHTML: (attributes) => {
return { "data-title": attributes.title as string };
},
},
thumbnailUrl: {
default: null,
parseHTML: (element) => element.getAttribute("data-thumbnail"),
renderHTML: (attributes) => {
if (!attributes.thumbnailUrl) return {};
return { "data-thumbnail": attributes.thumbnailUrl as string };
},
},
showEmbed: {
default: true,
parseHTML: (element) => element.getAttribute("data-show-embed"),
renderHTML: (attributes) => {
return { "data-show-embed": attributes.showEmbed as string };
},
},
};
},
parseHTML() {
return [
{
tag: "div[data-youtube]",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-youtube": "" },
this.options.HTMLAttributes,
HTMLAttributes,
),
];
},
addNodeView() {
return ReactNodeViewRenderer(YouTubeNodeView);
},
addProseMirrorPlugins() {
const nodeType = this.type;
return [
new Plugin({
key: new PluginKey("youtubePaste"),
props: {
handlePaste: (view, event) => {
const text = event.clipboardData?.getData("text/plain");
if (!text) return false;
// Check if the pasted text is a YouTube URL
const youtubeRegex =
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
const match = youtubeRegex.exec(text);
if (!match || !isYouTubeUrl(text)) return false;
const videoId = extractVideoId(text);
if (!videoId) return false;
const { state, dispatch } = view;
const { tr } = state;
const node = nodeType.create({
videoId,
url: text,
title: "Loading...",
showEmbed: true,
});
tr.replaceSelectionWith(node);
dispatch(tr);
// Fetch metadata asynchronously and update the node
void fetchYouTubeMetadata(text).then((metadata) => {
const { state: newState, dispatch: newDispatch } = view;
const { tr: newTr } = newState;
newState.doc.descendants((n, pos) => {
if (
n.type.name === "youtube" &&
n.attrs.videoId === videoId &&
n.attrs.title === "Loading..."
) {
newTr.setNodeMarkup(pos, undefined, {
...n.attrs,
title: metadata?.title ?? "YouTube Video",
thumbnailUrl: metadata?.thumbnail_url ?? null,
});
return false;
}
return true;
});
if (newTr.docChanged) {
newDispatch(newTr);
}
});
return true;
},
},
}),
];
},
// commands for the YouTube embed node
addCommands() {
return {
setYouTubeEmbed:
(options: {
videoId: string;
url: string;
title?: string;
thumbnailUrl?: string;
showEmbed?: boolean;
}) =>
({ editor }) => {
return editor.commands.insertContent({
type: this.name,
attrs: {
videoId: options.videoId,
url: options.url,
title: options.title ?? "YouTube Video",
thumbnailUrl: options.thumbnailUrl,
showEmbed: options.showEmbed ?? true,
},
});
},
};
},
});

View File

@@ -1,82 +0,0 @@
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper } from "@tiptap/react";
import { extractVideoId } from "./utils";
import YouTubeCard from "./YouTubeCard";
export default function YouTubeNodeView({
node,
editor,
getPos,
deleteNode,
updateAttributes,
}: NodeViewProps) {
const { videoId, title, url, showEmbed } = node.attrs as {
videoId: string;
title: string;
url: string;
showEmbed: boolean;
};
const handleConvertToLink = () => {
const pos = getPos();
if (typeof pos !== "number") return;
const linkContent = {
type: "paragraph",
content: [
{
type: "text",
text: url,
marks: [
{
type: "link",
attrs: {
href: url,
target: "_blank",
},
},
],
},
],
};
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContentAt(pos, linkContent)
.run();
};
const handleDelete = () => {
deleteNode();
};
const handleUpdate = (newUrl: string, newTitle: string) => {
if (newUrl !== url) {
const newVideoId = extractVideoId(newUrl);
updateAttributes({
url: newUrl,
title: newTitle,
videoId: newVideoId,
});
} else {
updateAttributes({ title: newTitle });
}
};
return (
<NodeViewWrapper>
<YouTubeCard
videoId={videoId}
url={url}
title={title}
showEmbed={showEmbed}
onConvertToLink={handleConvertToLink}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
</NodeViewWrapper>
);
}

View File

@@ -1,61 +0,0 @@
export function extractVideoId(url: string): string | null {
if (!url) return null;
url = url.trim();
// youtube.com/watch?v=VIDEO_ID
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
if (watchMatch) return watchMatch[1] ?? null;
// youtu.be/VIDEO_ID
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
if (shortMatch) return shortMatch[1] ?? null;
// youtube.com/embed/VIDEO_ID
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
if (embedMatch) return embedMatch[1] ?? null;
return null;
}
/**
* Check if a URL is a valid YouTube link
*/
export function isYouTubeUrl(url: string): boolean {
if (!url) return false;
const videoId = extractVideoId(url);
return videoId !== null;
}
/**
* Fetch YouTube video metadata using oEmbed API
* Returns title, author, thumbnail URL, etc.
* No API key required
*/
export async function fetchYouTubeMetadata(url: string): Promise<{
title: string;
author_name: string;
thumbnail_url: string;
} | null> {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const response = await fetch(oembedUrl);
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
title: string;
author_name: string;
thumbnail_url: string;
};
return {
title: data.title || "YouTube Video",
author_name: data.author_name || "",
thumbnail_url: data.thumbnail_url || "",
};
} catch (error) {
console.error("Failed to fetch YouTube metadata:", error);
return null;
}
}

View File

@@ -9,7 +9,6 @@ interface Props {
positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
closeOnClickOutside?: boolean;
centered?: boolean;
}
const Modal: React.FC<Props> = ({
@@ -18,7 +17,6 @@ const Modal: React.FC<Props> = ({
positionFromTop = "md",
isVisible,
closeOnClickOutside,
centered = false,
}) => {
const {
isOpen,
@@ -62,7 +60,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 justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -73,7 +71,7 @@ const Modal: React.FC<Props> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
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]}`}
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]}`}
>
{children}
</Dialog.Panel>

View File

@@ -78,7 +78,6 @@ 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("")),
},
/**
@@ -96,14 +95,6 @@ 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()
.transform((s) => (s === "" ? undefined : s))
@@ -141,9 +132,6 @@ 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,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:

View File

@@ -1,101 +0,0 @@
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,
"pt-BR": ptBR,
ptbr: ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;

View File

@@ -1,109 +0,0 @@
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;
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,
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"),
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 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

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

@@ -7,7 +7,7 @@ export const locales = [
"nl",
"ru",
"pl",
"pt-BR"
"ptbr"
] as const;
export type Locale = (typeof locales)[number];
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
nl: "Nederlands",
ru: "Русский",
pl: "Polski",
"pt-BR": "Português",
ptbr: "Português",
};

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

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 one or more lines are too long

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

@@ -2,17 +2,9 @@ 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());
const authHandler = toNodeHandler(auth.handler);
export default withRateLimit(
{ points: 100, duration: 60 },
async (req, res) => {
return await authHandler(req, res);
},
);
export default toNodeHandler(auth.handler);

View File

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

View File

@@ -1,10 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -21,5 +20,4 @@ export default withRateLimit(
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 withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const stripe = createStripeClient();
if (req.method !== "POST") {
@@ -31,5 +31,4 @@ export default withRateLimit(
console.error("Error:", error);
return res.status(500).json({ error: "Error creating portal session" });
}
},
);
}

View File

@@ -6,7 +6,6 @@ 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()
@@ -22,9 +21,10 @@ interface CheckoutSessionRequest {
stripeCustomerId: string;
}
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const stripe = createStripeClient();
if (req.method !== "POST") {
@@ -115,5 +115,4 @@ export default withRateLimit(
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 withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -48,5 +48,4 @@ export default withRateLimit(
console.error("Trello authentication error:", err);
return res.status(400).json({ message: "Trello authentication failed" });
}
},
);
}

View File

@@ -3,14 +3,12 @@ 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:
env.NODE_ENV === "development"
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
@@ -19,16 +17,11 @@ const nextApiHandler = createNextApiHandler({
: undefined,
});
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "OPTIONS") {
res.writeHead(200);
return res.end();
}
const result = await nextApiHandler(req, res);
return result;
},
);
return nextApiHandler(req, res);
}

View File

@@ -4,7 +4,6 @@ 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),
@@ -20,9 +19,10 @@ type ResponseData =
const textEncoder = new TextEncoder();
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>,
) {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
@@ -101,5 +101,4 @@ export default withRateLimit(
}
return res.status(200).json({ success: true });
},
);
}

View File

@@ -1,128 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
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 { generateUID } from "@kan/shared/utils";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createS3Client } from "@kan/shared/utils";
import { assertPermission } from "@kan/api/utils/permissions";
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();
// Upload the file to S3
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: s3Key,
Body: req,
ContentType: contentType,
ContentLength: contentLength,
}),
);
// 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,
});
await cardActivityRepo.create(db, {
type: "card.updated.attachment.added",
cardId: card.id,
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

@@ -1,101 +0,0 @@
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

@@ -0,0 +1,74 @@
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,26 +6,25 @@ import { appRouter } from "@kan/api";
import { createRESTContext } from "@kan/api/trpc";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res);
export default async function handler(
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,11 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { openApiDocument } from "@kan/api/openapi";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit(
{ points: 100, duration: 60 },
(req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
},
);
const handler = (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).send(openApiDocument);
};
export default handler;

View File

@@ -2,13 +2,11 @@ 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,13 +2,11 @@ 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,13 +2,11 @@ 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

@@ -1,23 +0,0 @@
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,13 +2,11 @@ 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,5 +1,4 @@
import type { ReactNode } from "react";
import React from "react";
import {
Dialog,
DialogBackdrop,
@@ -465,37 +464,19 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
? stroke.modifiers.map(stringifyModifier)
: [];
modifierStrings.forEach((mod, index) => {
parts.push(
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
{mod}
</kbd>,
);
modifierStrings.forEach((mod) => {
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
});
parts.push(
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
{stroke.key.toUpperCase()}
</kbd>,
);
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
return parts;
};
if (shortcut.type === "SEQUENCE") {
const parts: ReactNode[] = [];
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);
shortcut.strokes.forEach((stroke) => {
parts.push(...formatStroke(stroke));
});
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
}

View File

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

View File

@@ -8,8 +8,6 @@ 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

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

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 "pt-BR":
return (await import("~/locales/pt-BR/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
default:
return enMessages;
}

View File

@@ -4,12 +4,9 @@ import {
HiLink,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
HiOutlineStar,
HiStar,
} from "react-icons/hi2";
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";
@@ -18,104 +15,42 @@ export default function BoardDropdown({
isTemplate,
isLoading,
boardPublicId,
isFavorite,
boardName,
workspacePublicId,
}: {
isTemplate: boolean;
isLoading: boolean;
boardPublicId: string;
isFavorite?: boolean;
boardName?: string;
workspacePublicId: string;
}) {
const { openModal } = useModal();
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
const { showPopup } = usePopup();
const utils = api.useUtils();
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const updateBoard = api.board.update.useMutation({
onSuccess: (data, variables) => {
void utils.board.all.invalidate();
void utils.board.byId.invalidate();
// Show popup notification
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 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" />,
},
]
: []),
{
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" />,
},
]
: []),
];
if (items.length === 0) {
return null;
}
return (
<Dropdown disabled={isLoading} items={items}>
<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" />,
},
]),
{
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -9,11 +9,7 @@ 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";
@@ -27,7 +23,6 @@ interface ListProps {
interface List {
publicId: string;
name: string;
createdBy?: string | null;
}
interface FormValues {
@@ -44,14 +39,8 @@ 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);
};
@@ -70,7 +59,6 @@ export default function List({
});
const onSubmit = (values: FormValues) => {
if (!canEdit) return;
updateList.mutate({
listPublicId: values.listPublicId,
name: values.name,
@@ -83,12 +71,7 @@ export default function List({
};
return (
<Draggable
key={list.publicId}
draggableId={list.publicId}
index={index}
isDragDisabled={!canDrag}
>
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
{(provided) => (
<div
key={list.publicId}
@@ -107,65 +90,41 @@ 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">
<Tooltip
content={
!canCreateCard ? t`You don't have permission` : undefined
}
<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)}
>
<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}
<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" />
),
},
]}
>
<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>
);
})()}
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
</div>
</div>
</div>
{children}

View File

@@ -335,7 +335,7 @@ export function NewCardForm({
saveFormState({ ...formState, description: value });
}}
workspaceMembers={
boardData?.workspace.members.map(
boardData?.workspace.members?.map(
(member): WorkspaceMember => ({
publicId: member.publicId,
email: member.email,
@@ -349,7 +349,6 @@ export function NewCardForm({
}),
) ?? []
}
enableYouTubeEmbed={false}
/>
</div>
</div>

View File

@@ -1,22 +1,17 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { HiLink } from "react-icons/hi";
import { Tooltip } from "~/components/Tooltip";
const UpdateBoardSlugButton = ({
handleOnClick,
workspaceSlug,
boardSlug,
isLoading,
canEdit,
}: {
handleOnClick: () => void;
workspaceSlug: string;
boardSlug: string;
isLoading: boolean;
canEdit: boolean;
}) => {
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
@@ -27,14 +22,10 @@ const UpdateBoardSlugButton = ({
}
return (
<Tooltip
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
<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"
>
<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"
>
<div className="flex items-center">
<span>
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
@@ -50,20 +41,13 @@ const UpdateBoardSlugButton = ({
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.stopPropagation();
if (!canEdit) {
e.preventDefault();
}
}}
onClick={(e) => e.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
>
<HiLink className="h-[13px] w-[13px]" />
</Link>
</button>
</Tooltip>
);
};
export default UpdateBoardSlugButton;

View File

@@ -4,8 +4,6 @@ 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";
@@ -31,7 +29,6 @@ const VisibilityButton = ({
isAdmin: boolean;
}) => {
const { showPopup } = usePopup();
const { canEditBoard } = usePermissions();
const utils = api.useUtils();
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
visibility,
@@ -63,47 +60,38 @@ const VisibilityButton = ({
},
});
const canEdit = canEditBoard || isAdmin;
return (
<div className="relative">
<Tooltip
content={
!canEdit && !isLoading ? t`You don't have permission` : undefined
}
<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"
>
<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"
<Button
variant="secondary"
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !isAdmin}
>
<Button
variant="secondary"
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
disabled={isLoading || !canEdit}
>
{t`Visibility`}
</Button>
</CheckboxDropdown>
</Tooltip>
{t`Visibility`}
</Button>
</CheckboxDropdown>
</div>
);
};

View File

@@ -24,9 +24,6 @@ import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { Tooltip } from "~/components/Tooltip";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { usePermissions } from "~/hooks/usePermissions";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -58,19 +55,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true,
direction: "horizontal",
});
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => boardId && canCreateList && openNewListForm(boardId),
action: () => boardId && openNewListForm(boardId),
description: t`Create new list`,
group: "ACTIONS",
});
@@ -263,14 +253,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
return;
}
if (type === "LIST" && canEditList) {
if (type === "LIST") {
updateListMutation.mutate({
listPublicId: draggableId,
index: destination.index,
});
}
if (type === "CARD" && canEditCard) {
if (type === "CARD") {
updateCardMutation.mutate({
cardPublicId: draggableId,
@@ -382,13 +372,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
sourceBoardName={boardData?.name ?? ""}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
>
<EditYouTubeModal />
</Modal>
</>
);
};
@@ -415,12 +398,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
id="name"
type="text"
{...register("name")}
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 dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
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]"
/>
</form>
)}
{!boardData && !isLoading && (
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
@@ -443,7 +424,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
canEdit={canEditBoard}
/>
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
@@ -466,13 +446,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
)}
</>
)}
<Tooltip
content={
!canCreateList
? t`You don't have permission`
: createListShortcutTooltipContent
}
>
<Tooltip content={createListShortcutTooltipContent}>
<Button
iconLeft={
<HiOutlinePlusSmall
@@ -481,9 +455,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
/>
}
onClick={() => {
if (boardId && canCreateList) openNewListForm(boardId);
if (boardId) openNewListForm(boardId);
}}
disabled={!boardData || !canCreateList}
disabled={!boardData}
>
{t`New list`}
</Button>
@@ -493,17 +467,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
isFavorite={boardData?.favorite}
boardName={boardData?.name}
/>
</div>
</div>
<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`}
>
<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">
{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" />
@@ -520,25 +488,16 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
{t`No lists`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{canCreateList
? t`Get started by creating a new list`
: t`No lists have been created yet`}
{t`Get started by creating a new list`}
</p>
</div>
<Tooltip
content={
!canCreateList ? t`You don't have permission` : undefined
}
<Button
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
>
<Button
onClick={() => {
if (boardId && canCreateList) openNewListForm(boardId);
}}
disabled={!canCreateList}
>
{t`Create new list`}
</Button>
</Tooltip>
{t`Create new list`}
</Button>
</div>
) : (
<DragDropContext onDragEnd={onDragEnd}>
@@ -578,7 +537,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
key={card.publicId}
draggableId={card.publicId}
index={index}
isDragDisabled={!canEditCard}
>
{(provided) => (
<Link
@@ -596,12 +554,13 @@ 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,11 +1,9 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
import { motion } from "framer-motion";
import { HiOutlineRectangleStack } from "react-icons/hi2";
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";
@@ -13,14 +11,6 @@ import { api } from "~/utils/api";
export function BoardsList({ isTemplate }: { isTemplate?: 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(
{
@@ -30,20 +20,6 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{ 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">
@@ -65,69 +41,27 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<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>
<Button onClick={() => openModal("NEW_BOARD")}>
{t`Create new ${isTemplate ? "template" : "board"}`}
</Button>
</div>
);
return (
<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
>
<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">
{data?.map((board) => (
<motion.div
<Link
key={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 }
}}
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
>
<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 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>
))}
</motion.div>
</div>
);
}

View File

@@ -7,7 +7,6 @@ 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";
@@ -18,13 +17,12 @@ import { NewBoardForm } from "./components/NewBoardForm";
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => canCreateBoard && openModal("NEW_BOARD"),
action: () => openModal("NEW_BOARD"),
description: t`Create new ${isTemplate ? "template" : "board"}`,
group: "ACTIONS",
});
@@ -41,40 +39,22 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</h1>
<div className="flex gap-2">
{!isTemplate && (
<Tooltip
content={
!canCreateBoard ? t`You don't have permission` : undefined
<Button
type="button"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
<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>
{t`Import`}
</Button>
)}
<Tooltip
content={
!canCreateBoard
? t`You don't have permission`
: createModalShortcutTooltipContent
}
>
<Tooltip content={createModalShortcutTooltipContent}>
<Button
type="button"
variant="primary"
onClick={() => {
if (canCreateBoard) openModal("NEW_BOARD");
}}
disabled={!canCreateBoard}
onClick={() => openModal("NEW_BOARD")}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}

View File

@@ -25,7 +25,6 @@ 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 =
@@ -36,21 +35,12 @@ const truncate = (value: string | null, maxLength = 50) => {
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,
@@ -63,7 +53,6 @@ const getActivityText = ({
fromList: string | null;
toList: string | null;
memberName: string | null;
memberEmail: string | null;
isSelf: boolean;
label: string | null;
fromTitle?: string | null;
@@ -72,7 +61,6 @@ const getActivityText = ({
dateLocale: DateFnsLocale;
mergedLabels?: string[];
}) => {
const displayName = memberName ?? memberEmail ?? t`Member`;
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
@@ -150,23 +138,23 @@ const getActivityText = ({
);
}
if (type === "card.updated.member.added" && displayName) {
if (type === "card.updated.member.added" && memberName) {
if (isSelf) return <Trans>self-assigned the card</Trans>;
return (
<Trans>
assigned <TextHighlight>{truncate(displayName)}</TextHighlight> to the
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
card
</Trans>
);
}
if (type === "card.updated.member.removed" && displayName) {
if (type === "card.updated.member.removed" && memberName) {
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
return (
<Trans>
unassigned <TextHighlight>{truncate(displayName)}</TextHighlight> from
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
the card
</Trans>
);
@@ -363,7 +351,7 @@ const ActivityList = ({
limit: ACTIVITIES_PAGE_SIZE,
},
{
enabled: !!cardPublicId && cardPublicId.length >= 12,
enabled: !!cardPublicId,
},
);
@@ -466,7 +454,6 @@ 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,
@@ -484,7 +471,6 @@ 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}
@@ -507,7 +493,6 @@ 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,
@@ -520,7 +505,7 @@ const ActivityList = ({
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${getUserDisplayName(activity.user)} `}</span>
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{activityText}
</span>

View File

@@ -7,7 +7,6 @@ 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";
@@ -19,33 +18,62 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
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");
}
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
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

@@ -8,12 +8,10 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
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;
@@ -24,7 +22,6 @@ const Comment = ({
cardPublicId,
name,
email,
image,
isLoading,
createdAt,
comment,
@@ -37,7 +34,6 @@ const Comment = ({
cardPublicId: string;
name: string;
email: string;
image: string | null;
isLoading: boolean;
createdAt: string;
comment: string | undefined;
@@ -50,7 +46,6 @@ const Comment = ({
const utils = api.useUtils();
const { showPopup } = usePopup();
const { openModal } = useModal();
const { canEditComment, canDeleteComment } = usePermissions();
const { handleSubmit, setValue, watch } = useForm<FormValues>({
defaultValues: {
comment,
@@ -82,7 +77,7 @@ const Comment = ({
};
const dropdownItems = [
...(isAuthor && canEditComment
...(isAuthor
? [
{
label: t`Edit comment`,
@@ -91,7 +86,7 @@ const Comment = ({
},
]
: []),
...((isAuthor || canDeleteComment)
...(isAuthor || isAdmin
? [
{
label: t`Delete comment`,
@@ -113,7 +108,6 @@ const Comment = ({
size="sm"
name={name ?? ""}
email={email ?? ""}
imageUrl={getAvatarUrl(image) || undefined}
isLoading={isLoading}
/>

View File

@@ -5,53 +5,29 @@ import {
HiOutlineTrash,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
export default function CardDropdown({
cardCreatedBy,
}: {
cardCreatedBy?: string | null;
}) {
export default function BoardDropdown() {
const { openModal } = useModal();
const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const items = [
...(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={items}>
<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" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);

View File

@@ -12,14 +12,12 @@ 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();
@@ -107,9 +105,9 @@ export function DueDateSelector({
<div className="relative flex w-full items-center text-left">
<button
type="button"
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"}`}
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
>
{dueDate ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
@@ -120,7 +118,7 @@ export function DueDateSelector({
</>
)}
</button>
{isOpen && !disabled && (
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
<div

View File

@@ -17,14 +17,12 @@ 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();
@@ -95,10 +93,9 @@ export default function LabelSelector({
handleSelect={(_, label) => {
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
}}
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={() => openModal("NEW_LABEL")}
createNewItemLabel={t`Create new label`}
disabled={disabled}
asChild
>
{selectedLabels.length ? (
@@ -113,7 +110,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 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"}`}>
<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">
<HiMiniPlus size={22} className="pr-2" />
{t`Add label`}
</div>

View File

@@ -13,14 +13,12 @@ interface ListSelectorProps {
selected: boolean;
}[];
isLoading: boolean;
disabled?: boolean;
}
export default function ListSelector({
cardPublicId,
lists,
isLoading,
disabled = false,
}: ListSelectorProps) {
const utils = api.useUtils();
@@ -79,10 +77,9 @@ export default function ListSelector({
index: 0,
});
}}
disabled={disabled}
asChild
>
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 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"}`}>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedList?.value}
</div>
</CheckboxDropdown>

View File

@@ -19,14 +19,12 @@ interface MemberSelectorProps {
imageUrl: string | undefined;
}[];
isLoading: boolean;
disabled?: boolean;
}
export default function MemberSelector({
cardPublicId,
members,
isLoading,
disabled = false,
}: MemberSelectorProps) {
const router = useRouter();
const utils = api.useUtils();
@@ -110,12 +108,11 @@ export default function MemberSelector({
workspaceMemberPublicId: member.key,
});
}}
handleCreate={disabled ? undefined : handleInviteMember}
handleCreate={handleInviteMember}
createNewItemLabel={t`Invite member`}
disabled={disabled}
asChild
>
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 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"}`}>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedMembers.length ? (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{selectedMembers.map(({ value, imageUrl }) => (

View File

@@ -4,7 +4,6 @@ import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -16,7 +15,6 @@ interface FormValues {
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { canCreateComment } = usePermissions();
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
values: {
comment: "",
@@ -48,10 +46,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
});
};
if (!canCreateComment) {
return null;
}
return (
<form
onSubmit={handleSubmit(onSubmit)}

View File

@@ -13,10 +13,6 @@ import LabelIcon from "~/components/LabelIcon";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { authClient } from "@kan/auth/client";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
@@ -47,19 +43,13 @@ interface FormValues {
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
const router = useRouter();
const { canEditCard } = usePermissions();
const { data: session } = authClient.useSession();
const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0]
: router.query.cardId;
const { data: card } = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
const { data: card } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const board = card?.list.board;
const labels = board?.labels;
@@ -125,7 +115,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
lists={formattedLists}
isLoading={!card}
disabled={!canEdit}
/>
</div>
<div className="mb-4 flex w-full flex-row">
@@ -134,7 +123,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
labels={formattedLabels}
isLoading={!card}
disabled={!canEdit}
/>
</div>
{!isTemplate && (
@@ -144,7 +132,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
members={formattedMembers}
isLoading={!card}
disabled={!canEdit}
/>
</div>
)}
@@ -154,7 +141,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId ?? ""}
dueDate={card?.dueDate}
isLoading={!card}
disabled={!canEdit}
/>
</div>
</div>
@@ -175,8 +161,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
} = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { canEditCard } = usePermissions();
const { data: session } = authClient.useSession();
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
null,
);
@@ -185,13 +169,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading } = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
const canEdit = canEditCard || isCreator;
const { data: card, isLoading } = api.card.byId.useQuery({
cardPublicId: cardId ?? "",
});
const refetchCard = async () => {
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
@@ -317,7 +297,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</Link>
</div>
<div className="flex items-center gap-2">
<Dropdown cardCreatedBy={card?.createdBy} />
<Dropdown />
</div>
</>
)}
@@ -345,10 +325,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<textarea
id="title"
{...register("title")}
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
onBlur={handleSubmit(onSubmit)}
rows={1}
disabled={!canEdit}
className={`block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem] ${!canEdit ? "cursor-default" : ""}`}
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
@@ -374,10 +353,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2">
<Editor
content={card.description}
onChange={canEdit ? (e) => setValue("description", e) : undefined}
onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
onChange={(e) => setValue("description", e)}
onBlur={() => handleSubmit(onSubmit)()}
workspaceMembers={board?.workspace.members ?? []}
readOnly={!canEdit}
/>
</div>
</form>
@@ -387,7 +365,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
viewOnly={!canEdit}
/>
{!isTemplate && (
<>
@@ -396,15 +373,12 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<AttachmentThumbnails
attachments={card.attachments}
cardPublicId={cardId ?? ""}
isReadOnly={!canEdit}
/>
</div>
)}
{canEdit && (
<div className="mt-6">
<AttachmentUpload cardPublicId={cardId} />
</div>
)}
<div className="mt-6">
<AttachmentUpload cardPublicId={cardId} />
</div>
</>
)}
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
@@ -509,13 +483,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
checklistPublicId={entityId}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
>
<EditYouTubeModal />
</Modal>
</>
</div>
</>

View File

@@ -1,253 +0,0 @@
import { t } from "@lingui/core/macro";
import { HiXMark } from "react-icons/hi2";
import type { Permission } from "@kan/shared";
import { permissionCategories } from "@kan/shared";
import Button from "~/components/Button";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
export function EditMemberPermissionsModal() {
const { workspace } = useWorkspace();
const { modalContentType, entityId, entityLabel, closeModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const { data, isLoading } = api.permission.getMemberPermissions.useQuery(
{
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
},
{
enabled:
modalContentType === "EDIT_MEMBER_PERMISSIONS" && !!entityId,
},
);
const grantMutation = api.permission.grantPermission.useMutation({
onSuccess: () => {
showPopup({
header: t`Permissions updated`,
message: t`The member's permissions have been updated.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Unable to update permissions`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.permission.getMemberPermissions.invalidate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
},
});
const revokeMutation = api.permission.revokePermission.useMutation({
onSuccess: () => {
showPopup({
header: t`Permissions updated`,
message: t`The member's permissions have been updated.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Unable to update permissions`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.permission.getMemberPermissions.invalidate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
},
});
const resetMutation = api.permission.resetMemberPermissions.useMutation({
onSuccess: async () => {
showPopup({
header: t`Permissions reset`,
message: t`This member's permissions have been reset to their role defaults.`,
icon: "success",
});
await utils.permission.getMemberPermissions.invalidate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
},
onError: () => {
showPopup({
header: t`Unable to reset permissions`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const effectivePermissions = (data?.permissions ?? []) as Permission[];
const hasOverrides = (data?.overrides?.length ?? 0) > 0;
const isBusy =
grantMutation.isPending ||
revokeMutation.isPending ||
resetMutation.isPending;
const handleToggle = (permission: Permission, nextState: boolean) => {
if (!workspace.publicId || !entityId) return;
if (nextState) {
grantMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
permission,
});
} else {
revokeMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
permission,
});
}
};
const permissionLabels: Record<Permission, string> = {
"workspace:view": t`Can view workspace`,
"workspace:edit": t`Can edit workspace`,
"workspace:delete": t`Can delete workspace`,
"workspace:manage": t`Can manage workspace settings`,
"board:view": t`Can view boards`,
"board:create": t`Can create boards`,
"board:edit": t`Can edit boards`,
"board:delete": t`Can delete boards`,
"list:view": t`Can view lists`,
"list:create": t`Can create lists`,
"list:edit": t`Can edit lists`,
"list:delete": t`Can delete lists`,
"card:view": t`Can view cards`,
"card:create": t`Can create cards`,
"card:edit": t`Can edit cards`,
"card:delete": t`Can delete cards`,
"comment:view": t`Can view comments`,
"comment:create": t`Can add comments`,
"comment:edit": t`Can edit comments`,
"comment:delete": t`Can delete comments`,
"member:view": t`Can view members`,
"member:invite": t`Can invite members`,
"member:edit": t`Can edit member roles and permissions`,
"member:remove": t`Can remove members`,
};
return (
<div className="w-full rounded-md bg-light-50 text-light-1000 dark:bg-dark-100 dark:text-dark-1000">
<div className="px-5 pt-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="mb-1 text-sm font-semibold">
{t`Edit permissions`}
</h2>
<p className="min-h-[16px] text-xs text-light-900 dark:text-dark-900">
{entityLabel}
</p>
</div>
<button
type="button"
onClick={closeModal}
className="ml-2 inline-flex h-6 w-6 items-center justify-center rounded-md text-light-900 hover:bg-light-200 focus:outline-none dark:text-dark-900 dark:hover:bg-dark-200"
aria-label={t`Close`}
>
<HiXMark className="h-3.5 w-3.5" />
</button>
</div>
{isLoading ? (
<p className="text-xs text-light-900 dark:text-dark-900">
{t`Loading permissions...`}
</p>
) : (
<div className="max-h-80 pb-4 space-y-3 overflow-y-auto pr-1">
{Object.values(permissionCategories).map((category, index) => (
<div
key={category.label}
className={`py-2 ${
index > 0
? "border-t border-light-300 dark:border-dark-300"
: ""
}`}
>
<div className="my-2 text-[12px] font-semibold text-light-900 dark:text-dark-950">
{category.label}
</div>
<div className="space-y-1.5">
{category.permissions.map((permission) => {
const label =
permissionLabels[permission] ?? (permission as string);
return (
<div
key={permission}
className="flex items-center justify-between gap-3 py-0.5"
>
<span className="text-xs text-light-900 dark:text-dark-900">
{label}
</span>
<Toggle
label={label}
showLabel={false}
isChecked={effectivePermissions.includes(permission)}
disabled={isBusy}
onChange={() =>
handleToggle(
permission,
!effectivePermissions.includes(permission),
)
}
/>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
<div className="flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
variant="secondary"
size="sm"
onClick={() => {
if (!workspace.publicId || !entityId || isBusy) return;
resetMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId: entityId,
});
}}
disabled={isBusy || !hasOverrides}
isLoading={resetMutation.isPending}
>
{t`Reset to role defaults`}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,6 @@ import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import {
HiBolt,
HiChevronDown,
HiEllipsisHorizontal,
HiOutlinePlusSmall,
} from "react-icons/hi2";
@@ -20,55 +19,24 @@ import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
export default function MembersPage() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();
const { showPopup } = usePopup();
const { data, isLoading } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
// { enabled: workspace?.publicId ? true : false },
);
const { data: session } = authClient.useSession();
const { canEditMember } = usePermissions();
const utils = api.useUtils();
const updateRoleMutation = api.member.updateRole.useMutation({
onSuccess: async () => {
if (workspace.publicId && workspace.publicId.length >= 12) {
await utils.workspace.byId.invalidate({
workspacePublicId: workspace.publicId,
});
}
showPopup({
header: t`Role updated`,
message: t`The member's role has been updated.`,
icon: "success",
});
},
onError: () => {
showPopup({
header: t`Unable to update role`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const subscriptions = data?.subscriptions as Subscription[] | undefined;
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
@@ -86,7 +54,6 @@ export default function MembersPage() {
memberStatus,
isLastRow,
showSkeleton,
showPendingIcon,
}: {
memberPublicId?: string;
memberId?: string | null | undefined;
@@ -97,18 +64,7 @@ export default function MembersPage() {
memberStatus?: string;
isLastRow?: boolean;
showSkeleton?: boolean;
showPendingIcon?: boolean;
}) => {
const handleRoleChange = (newRole: "admin" | "member" | "guest") => {
if (!memberPublicId) return;
updateRoleMutation.mutate({
workspacePublicId: workspace.publicId,
memberPublicId,
role: newRole,
});
};
return (
<tr className="rounded-b-lg">
<td
@@ -137,26 +93,20 @@ export default function MembersPage() {
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
showSkeleton &&
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
showPendingIcon &&
"italic text-neutral-500 dark:text-dark-900",
)}
>
{memberName}
</p>
</div>
{((workspace.role === "admin" ||
data?.showEmailsToMembers === true) ||
showSkeleton) && (
<p
className={twMerge(
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberEmail}
</p>
)}
<p
className={twMerge(
"truncate text-xs text-dark-900 sm:text-sm",
showSkeleton &&
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
)}
>
{memberEmail}
</p>
</div>
</div>
</div>
@@ -168,67 +118,32 @@ export default function MembersPage() {
)}
>
<div className="flex w-full items-center justify-between px-2 sm:px-3">
<div className="flex items-center gap-2">
{showSkeleton ? (
<span
className={twMerge(
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
<div className="flex flex-col sm:flex-row sm:items-center">
<span
className={twMerge(
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
showSkeleton &&
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
)}
/>
) : (
<div className="relative inline-flex items-center">
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
{memberRole &&
memberRole.charAt(0).toUpperCase() +
memberRole.slice(1)}
{canEditMember && session?.user.id !== memberId && (
<HiChevronDown className="h-3 w-3" />
)}
</span>
{canEditMember && session?.user.id !== memberId && (
<select
value={memberRole}
onChange={(e) =>
handleRoleChange(
e.target.value as "admin" | "member" | "guest",
)
}
disabled={updateRoleMutation.isPending}
className="absolute inset-0 h-full w-full cursor-pointer appearance-none border-none bg-transparent p-0 text-[10px] leading-none opacity-0 focus:outline-none focus-visible:outline-none sm:text-[11px]"
>
<option value="admin">{t`Admin`}</option>
<option value="member">{t`Member`}</option>
<option value="guest">{t`Guest`}</option>
</select>
)}
</div>
)}
)}
>
{memberRole &&
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
</span>
{(memberStatus === "invited" || memberStatus === "paused") && (
<span className="inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:text-[11px]">
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
{memberStatus === "invited" ? t`Pending` : t`Paused`}
</span>
)}
</div>
<div
className={twMerge(
"relative",
"relative z-50",
(workspace.role !== "admin" || showSkeleton) && "hidden",
)}
>
{session?.user.id !== memberId && (
<Dropdown
items={[
{
label: t`Edit permissions`,
action: () =>
openModal(
"EDIT_MEMBER_PERMISSIONS",
memberPublicId,
memberEmail ?? "",
),
},
{
label: t`Remove member`,
action: () =>
@@ -242,7 +157,7 @@ export default function MembersPage() {
>
<HiEllipsisHorizontal
size={20}
className="text-light-900 dark:text-dark-900 sm:size-[20px]"
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
/>
</Dropdown>
)}
@@ -331,24 +246,19 @@ export default function MembersPage() {
</thead>
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{!isLoading &&
data?.members.map((member, index) => {
const isPendingInvite = member.status === "invited";
return (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}
memberRole={member.role}
memberStatus={member.status}
isLastRow={index === data.members.length - 1}
showPendingIcon={isPendingInvite}
/>
);
})}
data?.members.map((member, index) => (
<TableRow
key={member.publicId}
memberPublicId={member.publicId}
memberId={member.user?.id}
memberName={member.user?.name}
memberEmail={member.user?.email ?? member.email}
memberImage={member.user?.image}
memberRole={member.role}
memberStatus={member.status}
isLastRow={index === data.members.length - 1}
/>
))}
{isLoading && (
<>
@@ -397,14 +307,6 @@ export default function MembersPage() {
>
<DeleteMemberConfirmation />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
centered
>
<EditMemberPermissionsModal />
</Modal>
</>
</div>
</>

View File

@@ -32,7 +32,7 @@ export function CardModal({
cardPublicId: cardPublicId ?? "",
},
{
enabled: isOpen && !!cardPublicId && cardPublicId.length >= 12,
enabled: isOpen && !!cardPublicId,
},
);

View File

@@ -12,7 +12,6 @@ import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import Popup from "~/components/Popup";
import ThemeToggle from "~/components/ThemeToggle";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -30,11 +29,6 @@ export default function PublicBoardView() {
const { showPopup } = usePopup();
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
const { openModal } = useModal();
const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true,
direction: "horizontal",
});
const boardSlug = Array.isArray(router.query.boardSlug)
? router.query.boardSlug[0]
@@ -157,11 +151,7 @@ export default function PublicBoardView() {
)}
</div>
<div
ref={scrollRef}
onMouseDown={onMouseDown}
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"
>
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
{isLoading || !router.isReady ? (
<div className="ml-[2rem] flex">
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />

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