Compare commits
1 Commits
fix/hide-e
...
fix/delete
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fcd0e8688 |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
|||||||
|
|
||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
# .git
|
.git
|
||||||
@@ -32,23 +32,15 @@ NEXT_PUBLIC_STORAGE_URL=
|
|||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
|
||||||
|
|
||||||
# Auth config (optional)
|
# Auth config (optional)
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
|
|
||||||
# API configuration (optional)
|
|
||||||
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
|
|
||||||
|
|
||||||
# Integration providers (optional)
|
# Integration providers (optional)
|
||||||
TRELLO_APP_API_KEY=
|
TRELLO_APP_API_KEY=
|
||||||
TRELLO_APP_SECRET=
|
TRELLO_APP_SECRET=
|
||||||
|
|
||||||
# Redis (optional - for rate limiting)
|
|
||||||
# If not provided, rate limiting will use in-memory storage
|
|
||||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
|
||||||
|
|
||||||
# OAuth providers (optional)
|
# OAuth providers (optional)
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||||
|
|||||||
40
.github/workflows/docker-publish.yml
vendored
@@ -34,8 +34,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
# Install the cosign tool except on PR
|
||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
@@ -76,42 +74,6 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
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)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -124,8 +86,6 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
315
AGENTS.md
@@ -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
|
|
||||||
76
README.md
@@ -138,48 +138,44 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
| ----------------------------------------- | --------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `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>"` |
|
||||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||||
| `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_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
| `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_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
|
||||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ ARG PROJECT=@kan/web
|
|||||||
|
|
||||||
# 1. Alpine image
|
# 1. Alpine image
|
||||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update && \
|
RUN apk update
|
||||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true
|
|
||||||
|
|
||||||
# Setup pnpm and turbo on the alpine base
|
# Setup pnpm and turbo on the alpine base
|
||||||
FROM alpine AS base
|
FROM alpine AS base
|
||||||
@@ -19,45 +18,43 @@ RUN pnpm config set store-dir ~/.pnpm-store
|
|||||||
|
|
||||||
# 2. Prune projects
|
# 2. Prune projects
|
||||||
FROM base AS pruner
|
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
|
ARG PROJECT
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
# Set working directory
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# It might be the path to <ROOT> turborepo
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate version from git (fallback if APP_VERSION not provided)
|
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
|
||||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
|
||||||
sed 's/^v//' || \
|
|
||||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
|
||||||
echo "unknown") && \
|
|
||||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
|
||||||
|
|
||||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||||
|
|
||||||
# 3. Build the project
|
# 3. Build the project
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
ARG PROJECT
|
ARG PROJECT
|
||||||
ARG APP_VERSION
|
|
||||||
|
# Environment to skip .env validation on build
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy lockfile and package.json's of isolated subworkspace
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
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
|
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/ .
|
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')}" && \
|
RUN pnpm build --filter=${PROJECT}
|
||||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
|
||||||
|
|
||||||
# # Copy static files to standalone directory
|
# # Copy static files to standalone directory
|
||||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": 0,
|
"version": 0,
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"po": {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ checksums:
|
|||||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
||||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||||
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
|
|
||||||
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
||||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
||||||
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
||||||
@@ -37,11 +36,8 @@ checksums:
|
|||||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
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
|
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
||||||
Admin/singular: 90eb20f1400db82ab874744e47836dc6
|
|
||||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||||
All%20member%20permission%20overrides%20have%20been%20reset%20to%20their%20role%20defaults./singular: e5c38724a283373506d53afd22b6d096
|
|
||||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
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
|
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%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
||||||
@@ -90,31 +86,6 @@ checksums:
|
|||||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
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: 78bfe1746f961f37b1cde85e5a0e9a13
|
|
||||||
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
|
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||||
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
||||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||||
@@ -126,9 +97,6 @@ checksums:
|
|||||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
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
|
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||||
@@ -143,7 +111,6 @@ checksums:
|
|||||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
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%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
|
||||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||||
@@ -155,7 +122,6 @@ checksums:
|
|||||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||||
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
|
|
||||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
||||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||||
@@ -177,7 +143,6 @@ checksums:
|
|||||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||||
Custom%20permissions/singular: 6f1748601979e2e4548877b292b43a20
|
|
||||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||||
@@ -217,19 +182,14 @@ checksums:
|
|||||||
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
|
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
|
||||||
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
|
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
|
||||||
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
|
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||||
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
|
|
||||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||||
Edit%20permissions/singular: 244558dd716491b7ed72ba8ab73aa28f
|
|
||||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||||
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
|
||||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||||
email/singular: f31eb214738e037d58e26149797739df
|
email/singular: f31eb214738e037d58e26149797739df
|
||||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||||
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
|
|
||||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||||
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
|
|
||||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
||||||
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
||||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||||
@@ -259,7 +219,6 @@ checksums:
|
|||||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
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%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||||
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
||||||
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
||||||
@@ -295,7 +254,6 @@ checksums:
|
|||||||
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||||
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
||||||
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
||||||
Guest/singular: 2aec6d6ebe0d9a1db0a5c8cd5a98b8c3
|
|
||||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||||
@@ -356,9 +314,6 @@ checksums:
|
|||||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||||
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
|
||||||
Loading%20permissions.../singular: a5665279d4e439186825057c32d4d976
|
|
||||||
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
|
||||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||||
@@ -369,7 +324,6 @@ checksums:
|
|||||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||||
Member/singular: 1606dc30b369856b9dba1fe9aec425d2
|
|
||||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||||
@@ -402,9 +356,7 @@ checksums:
|
|||||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||||
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
||||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||||
No%20lists%20have%20been%20created%20yet/singular: f18ee3d7230cc33b68bd17b429d1d442
|
|
||||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||||
No%20roles%20found%20for%20this%20workspace%20yet./singular: 2eb502333aaf6c58e8ab23d881e2e357
|
|
||||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||||
@@ -417,7 +369,6 @@ checksums:
|
|||||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||||
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
||||||
Overrides%20cleared/singular: f2e380efbae31a6113cc0cbf0eadf7b0
|
|
||||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||||
@@ -430,10 +381,6 @@ checksums:
|
|||||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||||
Permission/singular: cc2ed7274bd8267f9e0a10b079584d8b
|
|
||||||
Permissions/singular: 2160be68b1d6b6577e64634e9feba2ed
|
|
||||||
Permissions%20reset/singular: dd4776f04aca858deb95887e807570fc
|
|
||||||
Permissions%20updated/singular: 0df44570b783b8b284610da8627d333d
|
|
||||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||||
Planning/singular: 353f58c75248275fe091740607501610
|
Planning/singular: 353f58c75248275fe091740607501610
|
||||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||||
@@ -441,7 +388,6 @@ checksums:
|
|||||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||||
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
|
|
||||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||||
@@ -463,7 +409,6 @@ checksums:
|
|||||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
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%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
||||||
@@ -472,14 +417,12 @@ checksums:
|
|||||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
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
|
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||||
Reset%20to%20role%20defaults/singular: fc9ff8ea0503e50da3e9b3e5d8bb75dd
|
|
||||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||||
Review/singular: 299f75db25382980b2895622d7712927
|
Review/singular: 299f75db25382980b2895622d7712927
|
||||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||||
Role%20updated/singular: 73606ae9c35101f1bb518961f68559d0
|
|
||||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||||
@@ -501,7 +444,6 @@ checksums:
|
|||||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
||||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||||
Settings%20%7C%20Permissions/singular: 8aa60ed978b9f45a705d99dc1ee04f37
|
|
||||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||||
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
||||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||||
@@ -536,8 +478,6 @@ checksums:
|
|||||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
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%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%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
|
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||||
@@ -547,14 +487,11 @@ checksums:
|
|||||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
||||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||||
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
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%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
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%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||||
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
||||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||||
Title/singular: 344e64395eaff6822a57d18623853e1a
|
|
||||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||||
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
||||||
@@ -565,8 +502,6 @@ checksums:
|
|||||||
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
||||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
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%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||||
@@ -578,8 +513,6 @@ checksums:
|
|||||||
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
||||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
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%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||||
@@ -591,8 +524,6 @@ checksums:
|
|||||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
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%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
||||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||||
@@ -651,7 +582,6 @@ checksums:
|
|||||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
||||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||||
Workspace%20permissions/singular: 72c0202f30e543eb81bf930d85647096
|
|
||||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||||
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
||||||
@@ -662,12 +592,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%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%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%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%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||||
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
|
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%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%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
||||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
||||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||||
@@ -683,4 +611,3 @@ checksums:
|
|||||||
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
||||||
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
||||||
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
||||||
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf";
|
import type { LinguiConfig } from "@lingui/conf";
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
const config: LinguiConfig = {
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,10 +26,8 @@ const config = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
typescript: { ignoreBuildErrors: true },
|
|
||||||
|
|
||||||
// temporarily ignore eslint errors during build until we fix all the errors sigh
|
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: (() => {
|
remotePatterns: (() => {
|
||||||
@@ -50,11 +48,6 @@ const config = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "*.googleusercontent.com",
|
hostname: "*.googleusercontent.com",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'cdn.discordapp.com',
|
|
||||||
pathname: '/avatars/**',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||||
@@ -82,25 +75,18 @@ const config = {
|
|||||||
return patterns;
|
return patterns;
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
turbopack: {
|
webpack(config) {
|
||||||
rules: {
|
config.module.rules.push({
|
||||||
"*.svg": {
|
test: /\.svg$/,
|
||||||
loaders: ["@svgr/webpack"],
|
use: ["@svgr/webpack"],
|
||||||
as: "*.js",
|
});
|
||||||
},
|
return config;
|
||||||
},
|
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
// instrumentationHook: true,
|
// instrumentationHook: true,
|
||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
},
|
},
|
||||||
|
|
||||||
api: {
|
|
||||||
bodyParser: {
|
|
||||||
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,14 +4,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build --turbo",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev --turbo",
|
"dev": "pnpm with-env next dev",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --",
|
"with-env": "dotenv -e ../../.env --",
|
||||||
"lingui:extract": "lingui extract",
|
"lingui:extract": "lingui extract",
|
||||||
@@ -47,13 +45,14 @@
|
|||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.26.2",
|
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"next": "15.5.9",
|
"next": "^15.3.4",
|
||||||
|
"next-logger": "^5.0.1",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-cors": "^2.2.0",
|
"nextjs-cors": "^2.2.0",
|
||||||
|
"pino": "^9.6.0",
|
||||||
"posthog-js": "^1.254.0",
|
"posthog-js": "^1.254.0",
|
||||||
"react": "catalog:react18",
|
"react": "catalog:react18",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
@@ -87,10 +86,10 @@
|
|||||||
"dotenv-cli": "^7.4.4",
|
"dotenv-cli": "^7.4.4",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^1.21.6",
|
"jiti": "^1.21.6",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:"
|
||||||
"vitest": "^3.0.0"
|
|
||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config"
|
"prettier": "@kan/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
1
apps/web/public/logos/bitwarden.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -31,7 +31,6 @@ interface CheckboxDropdownProps {
|
|||||||
handleEdit?: (key: string) => void;
|
handleEdit?: (key: string) => void;
|
||||||
handleCreate?: () => void;
|
handleCreate?: () => void;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckboxDropdown({
|
export default function CheckboxDropdown({
|
||||||
@@ -45,7 +44,6 @@ export default function CheckboxDropdown({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
asChild = true,
|
asChild = true,
|
||||||
disabled = false,
|
|
||||||
}: CheckboxDropdownProps) {
|
}: CheckboxDropdownProps) {
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -60,13 +58,13 @@ export default function CheckboxDropdown({
|
|||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Menu.Item key={item.key}>
|
<Menu.Item key={item.key}>
|
||||||
<div
|
<div
|
||||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={item.key}
|
id={item.key}
|
||||||
name={item.key}
|
name={item.key}
|
||||||
@@ -134,8 +132,7 @@ export default function CheckboxDropdown({
|
|||||||
<>
|
<>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
as={asChild ? "div" : undefined}
|
as={asChild ? "div" : undefined}
|
||||||
disabled={disabled}
|
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||||
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function Dashboard({
|
|||||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||||
>
|
>
|
||||||
<SideNavigation
|
<SideNavigation
|
||||||
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
|
user={{ email: session?.user.email, image: session?.user.image }}
|
||||||
isLoading={sessionLoading}
|
isLoading={sessionLoading}
|
||||||
onCloseSideNav={closeSideNav}
|
onCloseSideNav={closeSideNav}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
|||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -30,14 +30,13 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-[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">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
<Menu.Item key={item.label}>
|
||||||
<button
|
<button
|
||||||
onClick={item.action}
|
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 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 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Range as TiptapRange } from "@tiptap/core";
|
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import type {
|
import type {
|
||||||
SuggestionKeyDownProps,
|
SuggestionKeyDownProps,
|
||||||
@@ -45,7 +44,6 @@ import { Markdown } from "tiptap-markdown";
|
|||||||
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -58,7 +56,7 @@ declare module "@tiptap/core" {
|
|||||||
export interface SlashCommandItem {
|
export interface SlashCommandItem {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,14 +431,12 @@ export default function Editor({
|
|||||||
onBlur,
|
onBlur,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
enableYouTubeEmbed = true,
|
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
workspaceMembers: WorkspaceMember[];
|
||||||
enableYouTubeEmbed?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -488,17 +484,10 @@ export default function Editor({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
return all.filter(
|
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||||
(u) =>
|
|
||||||
u.label &&
|
|
||||||
typeof u.label === "string" &&
|
|
||||||
u.label.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
command: ({ editor, range, props }) => {
|
command: ({ editor, range, props }: any) => {
|
||||||
const id = props.id ?? "";
|
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||||
const label = props.label ?? "";
|
|
||||||
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span> `;
|
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
@@ -514,7 +503,6 @@ export default function Editor({
|
|||||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||||
@@ -571,9 +559,6 @@ export default function Editor({
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.tiptap [data-youtube] {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end 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">
|
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-auto w-full max-w-[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="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 relative">
|
<div className="p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 mt-1">
|
<div className="flex-shrink-0">
|
||||||
{popupIcon === "success" && (
|
{popupIcon === "success" && (
|
||||||
<HiOutlineCheckCircle
|
<HiOutlineCheckCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-green-400"
|
className="h-6 w-6 text-green-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{popupIcon === "error" && (
|
{popupIcon === "error" && (
|
||||||
<HiOutlineExclamationCircle
|
<HiOutlineExclamationCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-red-400"
|
className="h-6 w-6 text-red-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||||
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||||
{popupHeader}
|
{popupHeader}
|
||||||
</p>
|
</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}
|
{popupMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
<div className="ml-4 flex flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
hidePopup();
|
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>
|
<span className="sr-only">Close</span>
|
||||||
<HiXMark
|
<HiXMark
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-4 w-4 text-dark-900"
|
className="h-5 w-5 text-dark-900"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ const Button: React.FC<{
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
"group flex h-[34px] items-center 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",
|
||||||
isCollapsed ? "md:justify-center" : "justify-between",
|
|
||||||
current
|
current
|
||||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ import {
|
|||||||
HiOutlineBanknotes,
|
HiOutlineBanknotes,
|
||||||
HiOutlineCodeBracketSquare,
|
HiOutlineCodeBracketSquare,
|
||||||
HiOutlineRectangleGroup,
|
HiOutlineRectangleGroup,
|
||||||
HiOutlineShieldCheck,
|
|
||||||
HiOutlineUser,
|
HiOutlineUser,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -27,12 +24,8 @@ interface SettingsLayoutProps {
|
|||||||
|
|
||||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
|
||||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||||
|
|
||||||
const isAdmin = workspace.role === "admin";
|
|
||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{
|
{
|
||||||
key: "account",
|
key: "account",
|
||||||
@@ -44,19 +37,13 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "workspace",
|
key: "workspace",
|
||||||
icon: <HiOutlineRectangleGroup />,
|
icon: <HiOutlineRectangleGroup />,
|
||||||
label: t`Workspace`,
|
label: t`Workspace`,
|
||||||
condition: canViewWorkspace,
|
condition: true,
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "permissions",
|
|
||||||
icon: <HiOutlineShieldCheck />,
|
|
||||||
label: t`Permissions`,
|
|
||||||
condition: isAdmin,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
label: t`Billing`,
|
label: t`Billing`,
|
||||||
icon: <HiOutlineBanknotes />,
|
icon: <HiOutlineBanknotes />,
|
||||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "api",
|
key: "api",
|
||||||
@@ -68,7 +55,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "integrations",
|
key: "integrations",
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
label: t`Integrations`,
|
label: t`Integrations`,
|
||||||
condition: canEditWorkspace,
|
condition: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -110,7 +97,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
>
|
>
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||||
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
|
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
||||||
<HiChevronDown
|
<HiChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ interface SideNavigationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
displayName?: string | null | undefined;
|
|
||||||
email?: string | null | undefined;
|
email?: string | null | undefined;
|
||||||
image?: string | null | undefined;
|
image?: string | null | undefined;
|
||||||
}
|
}
|
||||||
@@ -207,8 +206,7 @@ export default function SideNavigation({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UserMenu
|
<UserMenu
|
||||||
displayName={user.displayName ?? undefined}
|
email={user.email ?? ""}
|
||||||
email={user.email ?? "Email not provided?"}
|
|
||||||
imageUrl={user.image ?? undefined}
|
imageUrl={user.image ?? undefined}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|||||||
@@ -6,20 +6,16 @@ const Toggle = ({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
showLabel = true,
|
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showLabel?: boolean;
|
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mr-4 flex items-center justify-end">
|
<div className="mr-4 flex items-center justify-end">
|
||||||
{showLabel && (
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
{label}
|
||||||
{label}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export function Tooltip({
|
|||||||
delay,
|
delay,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
theme: "tooltip",
|
theme: "tooltip",
|
||||||
touch: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
@@ -17,7 +16,6 @@ import { getAvatarUrl } from "~/utils/helpers";
|
|||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
displayName: string | undefined;
|
|
||||||
email: string;
|
email: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
@@ -27,7 +25,6 @@ interface UserMenuProps {
|
|||||||
export default function UserMenu({
|
export default function UserMenu({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
email,
|
email,
|
||||||
displayName,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
onCloseSideNav,
|
onCloseSideNav,
|
||||||
@@ -77,7 +74,7 @@ export default function UserMenu({
|
|||||||
) : (
|
) : (
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||||
title={isCollapsed ? (displayName || email) : undefined}
|
title={isCollapsed ? email : undefined}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -104,7 +101,7 @@ export default function UserMenu({
|
|||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayName || email}
|
{email}
|
||||||
</span>
|
</span>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
@@ -228,25 +225,6 @@ export default function UserMenu({
|
|||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
{env.NEXT_PUBLIC_APP_VERSION && (
|
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
|
||||||
<Menu.Item>
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
|
||||||
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
|
||||||
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
onClick={handleLinkClick}
|
|
||||||
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
|
||||||
</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ interface Props {
|
|||||||
positionFromTop?: "sm" | "md" | "lg";
|
positionFromTop?: "sm" | "md" | "lg";
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
centered?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
@@ -18,7 +17,6 @@ const Modal: React.FC<Props> = ({
|
|||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
isVisible,
|
||||||
closeOnClickOutside,
|
closeOnClickOutside,
|
||||||
centered = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -62,7 +60,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||||
<div className={`flex min-h-full 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
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
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"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel
|
<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}
|
{children}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const env = createEnv({
|
|||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
KAN_ADMIN_API_KEY: z.string().optional(),
|
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
@@ -78,7 +77,6 @@ export const env = createEnv({
|
|||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||||
EMAIL_FROM: z.string().optional(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,14 +94,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
|
|
||||||
.string()
|
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
|
||||||
.refine(
|
|
||||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
.transform((s) => (s === "" ? undefined : s))
|
||||||
@@ -141,9 +131,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
|
|
||||||
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
|
||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Locale as DateFnsLocale } from "date-fns";
|
import type { Locale as DateFnsLocale } from "date-fns";
|
||||||
import { useLingui } from "@lingui/react";
|
import { useLingui } from "@lingui/react";
|
||||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
import { de, enGB, es, fr, it, nl, pl, ru } from "date-fns/locale";
|
||||||
|
|
||||||
import type { Locale } from "~/locales";
|
import type { Locale } from "~/locales";
|
||||||
import { useLinguiContext } from "~/providers/lingui";
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
@@ -19,7 +19,6 @@ export function useLocalisation() {
|
|||||||
nl,
|
nl,
|
||||||
ru,
|
ru,
|
||||||
pl,
|
pl,
|
||||||
"pt-BR": ptBR,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
6
apps/web/src/instrumentation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await require("pino");
|
||||||
|
await require("next-logger");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ export const locales = [
|
|||||||
"nl",
|
"nl",
|
||||||
"ru",
|
"ru",
|
||||||
"pl",
|
"pl",
|
||||||
"pt-BR"
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
@@ -23,5 +22,4 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
"pt-BR": "Português",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,17 +2,9 @@ import { toNodeHandler } from "better-auth/node";
|
|||||||
|
|
||||||
import { initAuth } from "@kan/auth/server";
|
import { initAuth } from "@kan/auth/server";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
export const auth = initAuth(createDrizzleClient());
|
export const auth = initAuth(createDrizzleClient());
|
||||||
|
|
||||||
const authHandler = toNodeHandler(auth.handler);
|
export default toNodeHandler(auth.handler);
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
async (req, res) => {
|
|
||||||
return await authHandler(req, res);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
export default withRateLimit(
|
res: NextApiResponse,
|
||||||
{ points: 100, duration: 60 },
|
) {
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -45,5 +44,4 @@ export default withRateLimit(
|
|||||||
console.error("Error downloading attachment:", error);
|
console.error("Error downloading attachment:", error);
|
||||||
return res.status(500).json({ message: "Failed to download attachment" });
|
return res.status(500).json({ message: "Failed to download attachment" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
export default withRateLimit(
|
res: NextApiResponse,
|
||||||
{ points: 100, duration: 60 },
|
) {
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -21,5 +20,4 @@ export default withRateLimit(
|
|||||||
console.error("Error fetching OSS friends:", error);
|
console.error("Error fetching OSS friends:", error);
|
||||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
|
|||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -31,5 +31,4 @@ export default withRateLimit(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating portal session" });
|
return res.status(500).json({ error: "Error creating portal session" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createNextApiContext } from "@kan/api/trpc";
|
|||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const workspaceSlugSchema = z
|
const workspaceSlugSchema = z
|
||||||
.string()
|
.string()
|
||||||
@@ -22,9 +21,10 @@ interface CheckoutSessionRequest {
|
|||||||
stripeCustomerId: string;
|
stripeCustomerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -88,7 +88,6 @@ export default withRateLimit(
|
|||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
payment_method_collection: "always",
|
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||||
@@ -115,5 +114,4 @@ export default withRateLimit(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating checkout session" });
|
return res.status(500).json({ error: "Error creating checkout session" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { integrations } from "@kan/db/schema";
|
import { integrations } from "@kan/db/schema";
|
||||||
import { addYears } from "date-fns";
|
import { addYears } from "date-fns";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -48,5 +48,4 @@ export default withRateLimit(
|
|||||||
console.error("Trello authentication error:", err);
|
console.error("Trello authentication error:", err);
|
||||||
return res.status(400).json({ message: "Trello authentication failed" });
|
return res.status(400).json({ message: "Trello authentication failed" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
@@ -3,14 +3,12 @@ import { createNextApiHandler } from "@trpc/server/adapters/next";
|
|||||||
|
|
||||||
import { appRouter } from "@kan/api/root";
|
import { appRouter } from "@kan/api/root";
|
||||||
import { createTRPCContext } from "@kan/api/trpc";
|
import { createTRPCContext } from "@kan/api/trpc";
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const nextApiHandler = createNextApiHandler({
|
const nextApiHandler = createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createTRPCContext,
|
createContext: createTRPCContext,
|
||||||
onError:
|
onError:
|
||||||
env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||||
@@ -19,16 +17,11 @@ const nextApiHandler = createNextApiHandler({
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withRateLimit(
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
{ points: 100, duration: 60 },
|
if (req.method === "OPTIONS") {
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res.writeHead(200);
|
||||||
if (req.method === "OPTIONS") {
|
return res.end();
|
||||||
res.writeHead(200);
|
}
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await nextApiHandler(req, res);
|
return nextApiHandler(req, res);
|
||||||
return result;
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { jwtVerify } from "jose";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -20,9 +19,10 @@ type ResponseData =
|
|||||||
|
|
||||||
const textEncoder = new TextEncoder();
|
const textEncoder = new TextEncoder();
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
res: NextApiResponse<ResponseData>,
|
||||||
|
) {
|
||||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -101,5 +101,4 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({ success: true });
|
return res.status(200).json({ success: true });
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { env as nextRuntimeEnv } from "next-runtime-env";
|
|||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -71,5 +71,4 @@ export default withRateLimit(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ error: (error as Error).message });
|
return res.status(500).json({ error: (error as Error).message });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -6,26 +6,25 @@ import { appRouter } from "@kan/api";
|
|||||||
import { createRESTContext } from "@kan/api/trpc";
|
import { createRESTContext } from "@kan/api/trpc";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
await cors(req, res);
|
) {
|
||||||
|
await cors(req, res);
|
||||||
|
|
||||||
const openApiHandler = createOpenApiNextHandler({
|
const openApiHandler = createOpenApiNextHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createRESTContext,
|
createContext: createRESTContext,
|
||||||
onError:
|
onError:
|
||||||
env.NODE_ENV === "development"
|
env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await openApiHandler(req, res);
|
return await openApiHandler(req, res);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { openApiDocument } from "@kan/api/openapi";
|
import { openApiDocument } from "@kan/api/openapi";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
{ points: 100, duration: 60 },
|
res.status(200).send(openApiDocument);
|
||||||
(req: NextApiRequest, res: NextApiResponse) => {
|
};
|
||||||
res.status(200).send(openApiDocument);
|
|
||||||
},
|
export default handler;
|
||||||
);
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import ApiSettings from "~/views/settings/ApiSettings";
|
import ApiSettings from "~/views/settings/ApiSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="api">
|
<SettingsLayout currentTab="api">
|
||||||
<ApiSettings />
|
<ApiSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import BillingSettings from "~/views/settings/BillingSettings";
|
import BillingSettings from "~/views/settings/BillingSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="billing">
|
<SettingsLayout currentTab="billing">
|
||||||
<BillingSettings />
|
<BillingSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="integrations">
|
<SettingsLayout currentTab="integrations">
|
||||||
<IntegrationsSettings />
|
<IntegrationsSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="workspace">
|
<SettingsLayout currentTab="workspace">
|
||||||
<WorkspaceSettings />
|
<WorkspaceSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
|
|||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
|
|
||||||
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { api } from "~/utils/api";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidates all card-related queries for a given card.
|
|
||||||
* Use this after any mutation that affects card data or activities.
|
|
||||||
*/
|
|
||||||
export async function invalidateCard(
|
|
||||||
utils: ReturnType<typeof api.useUtils>,
|
|
||||||
cardPublicId: string,
|
|
||||||
) {
|
|
||||||
await Promise.all([
|
|
||||||
utils.card.byId.invalidate({ cardPublicId }),
|
|
||||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { addDays, endOfDay, startOfDay } from "date-fns";
|
import { addDays, endOfDay, startOfDay } from "date-fns";
|
||||||
|
|
||||||
export type DueDateFilterKey =
|
type DueDateFilterKey =
|
||||||
| "overdue"
|
| "overdue"
|
||||||
| "today"
|
| "today"
|
||||||
| "tomorrow"
|
| "tomorrow"
|
||||||
@@ -9,8 +9,8 @@ export type DueDateFilterKey =
|
|||||||
| "no-due-date";
|
| "no-due-date";
|
||||||
|
|
||||||
export interface DueDateFilter {
|
export interface DueDateFilter {
|
||||||
startDate?: Date;
|
startDate?: string;
|
||||||
endDate?: Date;
|
endDate?: string;
|
||||||
hasNoDueDate?: boolean;
|
hasNoDueDate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,35 +21,35 @@ export const convertDueDateFiltersToRanges = (
|
|||||||
|
|
||||||
const today = startOfDay(new Date());
|
const today = startOfDay(new Date());
|
||||||
const tomorrow = addDays(today, 1);
|
const tomorrow = addDays(today, 1);
|
||||||
const nextWeekEnd = addDays(today, 8);
|
const nextWeekEnd = addDays(today, 8); // 7 days ahead
|
||||||
const nextMonthEnd = addDays(today, 31);
|
const nextMonthEnd = addDays(today, 31); // 30 days ahead
|
||||||
|
|
||||||
return filters.map((filter) => {
|
return filters.map((filter) => {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case "overdue":
|
case "overdue":
|
||||||
return {
|
return {
|
||||||
endDate: today,
|
endDate: today.toISOString(),
|
||||||
};
|
};
|
||||||
case "today":
|
case "today":
|
||||||
return {
|
return {
|
||||||
startDate: today,
|
startDate: today.toISOString(),
|
||||||
endDate: endOfDay(today),
|
endDate: endOfDay(today).toISOString(),
|
||||||
};
|
};
|
||||||
case "tomorrow":
|
case "tomorrow":
|
||||||
return {
|
return {
|
||||||
startDate: tomorrow,
|
startDate: tomorrow.toISOString(),
|
||||||
endDate: endOfDay(tomorrow),
|
endDate: endOfDay(tomorrow).toISOString(),
|
||||||
};
|
};
|
||||||
case "next-week": {
|
case "next-week": {
|
||||||
return {
|
return {
|
||||||
startDate: today,
|
startDate: today.toISOString(),
|
||||||
endDate: nextWeekEnd,
|
endDate: nextWeekEnd.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "next-month": {
|
case "next-month": {
|
||||||
return {
|
return {
|
||||||
startDate: nextWeekEnd,
|
startDate: nextWeekEnd.toISOString(),
|
||||||
endDate: nextMonthEnd,
|
endDate: nextMonthEnd.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "no-due-date":
|
case "no-due-date":
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -52,14 +52,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
|||||||
return imageOrKey;
|
return imageOrKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||||
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
|
|
||||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
|
||||||
|
|
||||||
if (useVirtualHostedUrls === "true" && storageDomain) {
|
|
||||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
|
||||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ const loadMessages = async (locale: Locale) => {
|
|||||||
return (await import("~/locales/ru/messages")).messages;
|
return (await import("~/locales/ru/messages")).messages;
|
||||||
case "pl":
|
case "pl":
|
||||||
return (await import("~/locales/pl/messages")).messages;
|
return (await import("~/locales/pl/messages")).messages;
|
||||||
case "pt-BR":
|
|
||||||
return (await import("~/locales/pt-BR/messages")).messages;
|
|
||||||
default:
|
default:
|
||||||
return enMessages;
|
return enMessages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import {
|
|||||||
HiLink,
|
HiLink,
|
||||||
HiOutlineDocumentDuplicate,
|
HiOutlineDocumentDuplicate,
|
||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
HiOutlineStar,
|
|
||||||
HiStar,
|
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -18,104 +15,42 @@ export default function BoardDropdown({
|
|||||||
isTemplate,
|
isTemplate,
|
||||||
isLoading,
|
isLoading,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
isFavorite,
|
workspacePublicId,
|
||||||
boardName,
|
|
||||||
}: {
|
}: {
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
isFavorite?: boolean;
|
workspacePublicId: string;
|
||||||
boardName?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
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 (
|
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" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -27,7 +23,6 @@ interface ListProps {
|
|||||||
interface List {
|
interface List {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
name: string;
|
name: string;
|
||||||
createdBy?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
@@ -44,14 +39,8 @@ export default function List({
|
|||||||
setSelectedPublicListId,
|
setSelectedPublicListId,
|
||||||
}: ListProps) {
|
}: ListProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
|
||||||
const canEdit = canEditList || isCreator;
|
|
||||||
const canDrag = canEditList || isCreator;
|
|
||||||
|
|
||||||
const openNewCardForm = (publicListId: PublicListId) => {
|
const openNewCardForm = (publicListId: PublicListId) => {
|
||||||
if (!canCreateCard) return;
|
|
||||||
openModal("NEW_CARD");
|
openModal("NEW_CARD");
|
||||||
setSelectedPublicListId(publicListId);
|
setSelectedPublicListId(publicListId);
|
||||||
};
|
};
|
||||||
@@ -70,7 +59,6 @@ export default function List({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
if (!canEdit) return;
|
|
||||||
updateList.mutate({
|
updateList.mutate({
|
||||||
listPublicId: values.listPublicId,
|
listPublicId: values.listPublicId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
@@ -83,12 +71,7 @@ export default function List({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Draggable
|
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
|
||||||
key={list.publicId}
|
|
||||||
draggableId={list.publicId}
|
|
||||||
index={index}
|
|
||||||
isDragDisabled={!canDrag}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
key={list.publicId}
|
key={list.publicId}
|
||||||
@@ -107,65 +90,41 @@ export default function List({
|
|||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
readOnly={!canEdit}
|
|
||||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Tooltip
|
<button
|
||||||
content={
|
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"
|
||||||
!canCreateCard ? t`You don't have permission` : undefined
|
onClick={() => openNewCardForm(list.publicId)}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<button
|
<HiOutlinePlusSmall
|
||||||
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"
|
className="h-5 w-5 text-dark-900"
|
||||||
onClick={() => openNewCardForm(list.publicId)}
|
aria-hidden="true"
|
||||||
disabled={!canCreateCard}
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="relative mr-1 inline-block">
|
||||||
|
<Dropdown
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: t`Add a card`,
|
||||||
|
action: () => openNewCardForm(list.publicId),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Delete list`,
|
||||||
|
action: handleOpenDeleteListConfirmation,
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<HiOutlinePlusSmall
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
className="h-5 w-5 text-dark-900"
|
</Dropdown>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
{(() => {
|
|
||||||
const dropdownItems = [
|
|
||||||
...(canCreateCard
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: t`Add a card`,
|
|
||||||
action: () => openNewCardForm(list.publicId),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(canDeleteList || isCreator
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: t`Delete list`,
|
|
||||||
action: handleOpenDeleteListConfirmation,
|
|
||||||
icon: (
|
|
||||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (dropdownItems.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative mr-1 inline-block">
|
|
||||||
<Dropdown items={dropdownItems}>
|
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export function NewCardForm({
|
|||||||
saveFormState({ ...formState, description: value });
|
saveFormState({ ...formState, description: value });
|
||||||
}}
|
}}
|
||||||
workspaceMembers={
|
workspaceMembers={
|
||||||
boardData?.workspace.members.map(
|
boardData?.workspace.members?.map(
|
||||||
(member): WorkspaceMember => ({
|
(member): WorkspaceMember => ({
|
||||||
publicId: member.publicId,
|
publicId: member.publicId,
|
||||||
email: member.email,
|
email: member.email,
|
||||||
@@ -349,7 +349,6 @@ export function NewCardForm({
|
|||||||
}),
|
}),
|
||||||
) ?? []
|
) ?? []
|
||||||
}
|
}
|
||||||
enableYouTubeEmbed={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { HiLink } from "react-icons/hi";
|
import { HiLink } from "react-icons/hi";
|
||||||
|
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
|
||||||
|
|
||||||
const UpdateBoardSlugButton = ({
|
const UpdateBoardSlugButton = ({
|
||||||
handleOnClick,
|
handleOnClick,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
boardSlug,
|
boardSlug,
|
||||||
isLoading,
|
isLoading,
|
||||||
canEdit,
|
|
||||||
}: {
|
}: {
|
||||||
handleOnClick: () => void;
|
handleOnClick: () => void;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
boardSlug: string;
|
boardSlug: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canEdit: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||||
|
|
||||||
@@ -27,14 +22,10 @@ const UpdateBoardSlugButton = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<button
|
||||||
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
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">
|
<div className="flex items-center">
|
||||||
<span>
|
<span>
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||||
@@ -50,20 +41,13 @@ const UpdateBoardSlugButton = ({
|
|||||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
|
||||||
if (!canEdit) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
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]" />
|
<HiLink className="h-[13px] w-[13px]" />
|
||||||
</Link>
|
</Link>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default UpdateBoardSlugButton;
|
export default UpdateBoardSlugButton;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
|||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -31,7 +29,6 @@ const VisibilityButton = ({
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { canEditBoard } = usePermissions();
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
||||||
visibility,
|
visibility,
|
||||||
@@ -63,47 +60,38 @@ const VisibilityButton = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const canEdit = canEditBoard || isAdmin;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Tooltip
|
<CheckboxDropdown
|
||||||
content={
|
items={[
|
||||||
!canEdit && !isLoading ? t`You don't have permission` : undefined
|
{
|
||||||
}
|
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
|
<Button
|
||||||
items={[
|
variant="secondary"
|
||||||
{
|
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||||
key: "public",
|
disabled={isLoading || !isAdmin}
|
||||||
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
|
{t`Visibility`}
|
||||||
variant="secondary"
|
</Button>
|
||||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
</CheckboxDropdown>
|
||||||
disabled={isLoading || !canEdit}
|
|
||||||
>
|
|
||||||
{t`Visibility`}
|
|
||||||
</Button>
|
|
||||||
</CheckboxDropdown>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,14 +24,12 @@ import { PageHead } from "~/components/PageHead";
|
|||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
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 { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters";
|
||||||
import { formatToArray } from "~/utils/helpers";
|
import { formatToArray } from "~/utils/helpers";
|
||||||
import BoardDropdown from "./components/BoardDropdown";
|
import BoardDropdown from "./components/BoardDropdown";
|
||||||
import Card from "./components/Card";
|
import Card from "./components/Card";
|
||||||
@@ -58,19 +56,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
const [selectedPublicListId, setSelectedPublicListId] =
|
const [selectedPublicListId, setSelectedPublicListId] =
|
||||||
useState<PublicListId>("");
|
useState<PublicListId>("");
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
|
||||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
|
||||||
enabled: true,
|
|
||||||
direction: "horizontal",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
|
||||||
|
|
||||||
const { tooltipContent: createListShortcutTooltipContent } =
|
const { tooltipContent: createListShortcutTooltipContent } =
|
||||||
useKeyboardShortcut({
|
useKeyboardShortcut({
|
||||||
type: "PRESS",
|
type: "PRESS",
|
||||||
stroke: { key: "C" },
|
stroke: { key: "C" },
|
||||||
action: () => boardId && canCreateList && openNewListForm(boardId),
|
action: () => boardId && openNewListForm(boardId),
|
||||||
description: t`Create new list`,
|
description: t`Create new list`,
|
||||||
group: "ACTIONS",
|
group: "ACTIONS",
|
||||||
});
|
});
|
||||||
@@ -106,17 +97,20 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
| "no-due-date"
|
| "no-due-date"
|
||||||
)[];
|
)[];
|
||||||
|
|
||||||
const boardType: "regular" | "template" = isTemplate ? "template" : "regular";
|
const queryParams: {
|
||||||
|
boardPublicId: string;
|
||||||
const queryParams = {
|
members: string[];
|
||||||
|
labels: string[];
|
||||||
|
lists: string[];
|
||||||
|
dueDate: ReturnType<typeof convertDueDateFiltersToRanges>;
|
||||||
|
type: "regular" | "template";
|
||||||
|
} = {
|
||||||
boardPublicId: boardId ?? "",
|
boardPublicId: boardId ?? "",
|
||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
lists: formatToArray(router.query.lists),
|
lists: formatToArray(router.query.lists),
|
||||||
...(semanticFilters.length > 0 && {
|
dueDate: convertDueDateFiltersToRanges(semanticFilters),
|
||||||
dueDateFilters: semanticFilters,
|
type: isTemplate ? "template" : "regular",
|
||||||
}),
|
|
||||||
type: boardType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -263,14 +257,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "LIST" && canEditList) {
|
if (type === "LIST") {
|
||||||
updateListMutation.mutate({
|
updateListMutation.mutate({
|
||||||
listPublicId: draggableId,
|
listPublicId: draggableId,
|
||||||
index: destination.index,
|
index: destination.index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "CARD" && canEditCard) {
|
if (type === "CARD") {
|
||||||
updateCardMutation.mutate({
|
updateCardMutation.mutate({
|
||||||
cardPublicId: draggableId,
|
cardPublicId: draggableId,
|
||||||
|
|
||||||
@@ -382,13 +376,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
sourceBoardName={boardData?.name ?? ""}
|
sourceBoardName={boardData?.name ?? ""}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
|
||||||
modalSize="sm"
|
|
||||||
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
|
||||||
>
|
|
||||||
<EditYouTubeModal />
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -415,12 +402,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
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]"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
{!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">
|
<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 +428,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
workspaceSlug={workspace.slug ?? ""}
|
workspaceSlug={workspace.slug ?? ""}
|
||||||
boardSlug={boardData?.slug ?? ""}
|
boardSlug={boardData?.slug ?? ""}
|
||||||
canEdit={canEditBoard}
|
|
||||||
/>
|
/>
|
||||||
<VisibilityButton
|
<VisibilityButton
|
||||||
visibility={boardData?.visibility ?? "private"}
|
visibility={boardData?.visibility ?? "private"}
|
||||||
@@ -466,13 +450,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Tooltip
|
<Tooltip content={createListShortcutTooltipContent}>
|
||||||
content={
|
|
||||||
!canCreateList
|
|
||||||
? t`You don't have permission`
|
|
||||||
: createListShortcutTooltipContent
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall
|
<HiOutlinePlusSmall
|
||||||
@@ -481,9 +459,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (boardId && canCreateList) openNewListForm(boardId);
|
if (boardId) openNewListForm(boardId);
|
||||||
}}
|
}}
|
||||||
disabled={!boardData || !canCreateList}
|
disabled={!boardData}
|
||||||
>
|
>
|
||||||
{t`New list`}
|
{t`New list`}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -493,17 +471,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
isLoading={!boardData}
|
isLoading={!boardData}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
isFavorite={boardData?.favorite}
|
|
||||||
boardName={boardData?.name}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||||
ref={scrollRef}
|
|
||||||
onMouseDown={onMouseDown}
|
|
||||||
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="ml-[2rem] flex">
|
<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" />
|
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||||
@@ -520,25 +492,16 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{t`No lists`}
|
{t`No lists`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||||
{canCreateList
|
{t`Get started by creating a new list`}
|
||||||
? t`Get started by creating a new list`
|
|
||||||
: t`No lists have been created yet`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip
|
<Button
|
||||||
content={
|
onClick={() => {
|
||||||
!canCreateList ? t`You don't have permission` : undefined
|
if (boardId) openNewListForm(boardId);
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
{t`Create new list`}
|
||||||
onClick={() => {
|
</Button>
|
||||||
if (boardId && canCreateList) openNewListForm(boardId);
|
|
||||||
}}
|
|
||||||
disabled={!canCreateList}
|
|
||||||
>
|
|
||||||
{t`Create new list`}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
@@ -578,7 +541,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
key={card.publicId}
|
key={card.publicId}
|
||||||
draggableId={card.publicId}
|
draggableId={card.publicId}
|
||||||
index={index}
|
index={index}
|
||||||
isDragDisabled={!canEditCard}
|
|
||||||
>
|
>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -596,12 +558,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
? `/templates/${boardId}/cards/${card.publicId}`
|
? `/templates/${boardId}/cards/${card.publicId}`
|
||||||
: `/cards/${card.publicId}`
|
: `/cards/${card.publicId}`
|
||||||
}
|
}
|
||||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||||
"PLACEHOLDER",
|
card.publicId.startsWith(
|
||||||
)
|
"PLACEHOLDER",
|
||||||
? "pointer-events-none"
|
)
|
||||||
: ""
|
? "pointer-events-none"
|
||||||
}`}
|
: ""
|
||||||
|
}`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
|
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -13,14 +11,6 @@ import { api } from "~/utils/api";
|
|||||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { openModal } = useModal();
|
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(
|
const { data, isLoading } = api.board.all.useQuery(
|
||||||
{
|
{
|
||||||
@@ -30,20 +20,6 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{ enabled: workspace.publicId ? true : false },
|
{ 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)
|
if (isLoading)
|
||||||
return (
|
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">
|
<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"}`}
|
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip
|
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||||
content={
|
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||||
!canCreateBoard ? t`You don't have permission` : undefined
|
</Button>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (canCreateBoard) openModal("NEW_BOARD");
|
|
||||||
}}
|
|
||||||
disabled={!canCreateBoard}
|
|
||||||
>
|
|
||||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<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">
|
||||||
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
|
|
||||||
layout
|
|
||||||
>
|
|
||||||
{data?.map((board) => (
|
{data?.map((board) => (
|
||||||
<motion.div
|
<Link
|
||||||
key={board.publicId}
|
key={board.publicId}
|
||||||
layout
|
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||||
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 }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link
|
<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">
|
||||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
<PatternedBackground />
|
||||||
>
|
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||||
<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">
|
{board.name}
|
||||||
<PatternedBackground />
|
</p>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
</Link>
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Modal from "~/components/modal";
|
|||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
@@ -18,13 +17,12 @@ import { NewBoardForm } from "./components/NewBoardForm";
|
|||||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { openModal, modalContentType, isOpen } = useModal();
|
const { openModal, modalContentType, isOpen } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { canCreateBoard } = usePermissions();
|
|
||||||
|
|
||||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||||
useKeyboardShortcut({
|
useKeyboardShortcut({
|
||||||
type: "PRESS",
|
type: "PRESS",
|
||||||
stroke: { key: "C" },
|
stroke: { key: "C" },
|
||||||
action: () => canCreateBoard && openModal("NEW_BOARD"),
|
action: () => openModal("NEW_BOARD"),
|
||||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||||
group: "ACTIONS",
|
group: "ACTIONS",
|
||||||
});
|
});
|
||||||
@@ -41,40 +39,22 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
<Tooltip
|
<Button
|
||||||
content={
|
type="button"
|
||||||
!canCreateBoard ? t`You don't have permission` : undefined
|
variant="secondary"
|
||||||
|
onClick={() => openModal("IMPORT_BOARDS")}
|
||||||
|
iconLeft={
|
||||||
|
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
{t`Import`}
|
||||||
type="button"
|
</Button>
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
if (canCreateBoard) openModal("IMPORT_BOARDS");
|
|
||||||
}}
|
|
||||||
disabled={!canCreateBoard}
|
|
||||||
iconLeft={
|
|
||||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t`Import`}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
<Tooltip
|
<Tooltip content={createModalShortcutTooltipContent}>
|
||||||
content={
|
|
||||||
!canCreateBoard
|
|
||||||
? t`You don't have permission`
|
|
||||||
: createModalShortcutTooltipContent
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => {
|
onClick={() => openModal("NEW_BOARD")}
|
||||||
if (canCreateBoard) openModal("NEW_BOARD");
|
|
||||||
}}
|
|
||||||
disabled={!canCreateBoard}
|
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Locale as DateFnsLocale } from "date-fns";
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { format, formatDistanceToNow, isSameYear } from "date-fns";
|
import { format, formatDistanceToNow, isSameYear } from "date-fns";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
HiOutlineArrowLeft,
|
HiOutlineArrowLeft,
|
||||||
HiOutlineArrowRight,
|
HiOutlineArrowRight,
|
||||||
@@ -16,16 +15,11 @@ import {
|
|||||||
HiOutlineUserPlus,
|
HiOutlineUserPlus,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import type {
|
import type { GetCardByIdOutput } from "@kan/api/types";
|
||||||
GetCardActivitiesOutput,
|
|
||||||
GetCardByIdOutput,
|
|
||||||
} from "@kan/api/types";
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
|
||||||
import Comment from "./Comment";
|
import Comment from "./Comment";
|
||||||
|
|
||||||
type ActivityType =
|
type ActivityType =
|
||||||
@@ -47,7 +41,6 @@ const getActivityText = ({
|
|||||||
fromTitle,
|
fromTitle,
|
||||||
toDueDate,
|
toDueDate,
|
||||||
dateLocale,
|
dateLocale,
|
||||||
mergedLabels,
|
|
||||||
}: {
|
}: {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
toTitle: string | null;
|
toTitle: string | null;
|
||||||
@@ -60,42 +53,7 @@ const getActivityText = ({
|
|||||||
fromDueDate?: Date | null;
|
fromDueDate?: Date | null;
|
||||||
toDueDate?: Date | null;
|
toDueDate?: Date | null;
|
||||||
dateLocale: DateFnsLocale;
|
dateLocale: DateFnsLocale;
|
||||||
mergedLabels?: string[];
|
|
||||||
}) => {
|
}) => {
|
||||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
type === "card.updated.label.added" &&
|
|
||||||
mergedLabels &&
|
|
||||||
mergedLabels.length > 1
|
|
||||||
) {
|
|
||||||
const labelList = mergedLabels.join(", ");
|
|
||||||
return (
|
|
||||||
<Trans>
|
|
||||||
added {mergedLabels.length} labels:{" "}
|
|
||||||
<TextHighlight>{labelList}</TextHighlight>
|
|
||||||
</Trans>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
type === "card.updated.label.removed" &&
|
|
||||||
mergedLabels &&
|
|
||||||
mergedLabels.length > 1
|
|
||||||
) {
|
|
||||||
const labelList = mergedLabels.join(", ");
|
|
||||||
return (
|
|
||||||
<Trans>
|
|
||||||
removed {mergedLabels.length} labels:{" "}
|
|
||||||
<TextHighlight>{labelList}</TextHighlight>
|
|
||||||
</Trans>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTIVITY_TYPE_MAP = {
|
const ACTIVITY_TYPE_MAP = {
|
||||||
"card.created": t`created the card`,
|
"card.created": t`created the card`,
|
||||||
"card.updated.title": t`updated the title`,
|
"card.updated.title": t`updated the title`,
|
||||||
@@ -121,6 +79,12 @@ const getActivityText = ({
|
|||||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||||
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
|
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
|
||||||
|
|
||||||
|
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
if (type === "card.updated.title" && toTitle) {
|
if (type === "card.updated.title" && toTitle) {
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
@@ -317,151 +281,37 @@ const getActivityIcon = (
|
|||||||
return ACTIVITY_ICON_MAP[type] ?? null;
|
return ACTIVITY_ICON_MAP[type] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVITIES_PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
const ActivityList = ({
|
const ActivityList = ({
|
||||||
|
activities,
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
isLoading: cardIsLoading,
|
isLoading,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isViewOnly,
|
isViewOnly,
|
||||||
}: {
|
}: {
|
||||||
|
activities: NonNullable<GetCardByIdOutput>["activities"];
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
isViewOnly?: boolean;
|
isViewOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { dateLocale, locale } = useLocalisation();
|
const { data } = authClient.useSession();
|
||||||
const { data: sessionData } = authClient.useSession();
|
const { dateLocale } = useLocalisation();
|
||||||
const utils = api.useUtils();
|
|
||||||
const [allActivities, setAllActivities] = useState<
|
|
||||||
GetCardActivitiesOutput["activities"]
|
|
||||||
>([]);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
||||||
|
|
||||||
const isFullyExpandedRef = useRef(false);
|
|
||||||
const lastDataUpdatedAtRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: firstPageData,
|
|
||||||
isFetching: isFetchingFirst,
|
|
||||||
dataUpdatedAt,
|
|
||||||
} = api.card.getActivities.useQuery(
|
|
||||||
{
|
|
||||||
cardPublicId,
|
|
||||||
limit: ACTIVITIES_PAGE_SIZE,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!cardPublicId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (firstPageData && dataUpdatedAt !== lastDataUpdatedAtRef.current) {
|
|
||||||
lastDataUpdatedAtRef.current = dataUpdatedAt;
|
|
||||||
|
|
||||||
if (isFullyExpandedRef.current && firstPageData.hasMore) {
|
|
||||||
setAllActivities(firstPageData.activities);
|
|
||||||
setHasMore(firstPageData.hasMore);
|
|
||||||
|
|
||||||
const fetchAllRemaining = async () => {
|
|
||||||
let currentActivities = [...firstPageData.activities];
|
|
||||||
let currentHasMore = firstPageData.hasMore;
|
|
||||||
|
|
||||||
while (currentHasMore) {
|
|
||||||
const lastActivity =
|
|
||||||
currentActivities[currentActivities.length - 1];
|
|
||||||
if (!lastActivity) break;
|
|
||||||
|
|
||||||
const nextCursor = new Date(lastActivity.createdAt).toISOString();
|
|
||||||
const nextPage = await utils.card.getActivities.fetch({
|
|
||||||
cardPublicId,
|
|
||||||
limit: ACTIVITIES_PAGE_SIZE,
|
|
||||||
cursor: nextCursor,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextPage) {
|
|
||||||
const existingIds = new Set(
|
|
||||||
currentActivities.map((a) => a.publicId),
|
|
||||||
);
|
|
||||||
const newActivities = nextPage.activities.filter(
|
|
||||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
|
||||||
);
|
|
||||||
currentActivities = [...currentActivities, ...newActivities];
|
|
||||||
currentHasMore = nextPage.hasMore;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAllActivities(currentActivities);
|
|
||||||
setHasMore(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAllRemaining();
|
|
||||||
} else {
|
|
||||||
setAllActivities(firstPageData.activities);
|
|
||||||
setHasMore(firstPageData.hasMore);
|
|
||||||
|
|
||||||
if (!firstPageData.hasMore) {
|
|
||||||
isFullyExpandedRef.current = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [firstPageData, dataUpdatedAt, cardPublicId, utils.card.getActivities]);
|
|
||||||
|
|
||||||
const handleLoadMore = async () => {
|
|
||||||
if (isLoadingMore || !hasMore || allActivities.length === 0) return;
|
|
||||||
|
|
||||||
const lastActivity = allActivities[allActivities.length - 1];
|
|
||||||
if (!lastActivity) return;
|
|
||||||
|
|
||||||
setIsLoadingMore(true);
|
|
||||||
try {
|
|
||||||
const nextCursor = new Date(lastActivity.createdAt).toISOString();
|
|
||||||
const nextPage = await utils.card.getActivities.fetch({
|
|
||||||
cardPublicId,
|
|
||||||
limit: ACTIVITIES_PAGE_SIZE,
|
|
||||||
cursor: nextCursor,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextPage) {
|
|
||||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
|
||||||
const newActivities = nextPage.activities.filter(
|
|
||||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
|
||||||
);
|
|
||||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
|
||||||
setHasMore(nextPage.hasMore);
|
|
||||||
|
|
||||||
if (!nextPage.hasMore) {
|
|
||||||
isFullyExpandedRef.current = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoadingMore(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFetching = isFetchingFirst || isLoadingMore;
|
|
||||||
const isLoading =
|
|
||||||
cardIsLoading || (isFetchingFirst && allActivities.length === 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-4 pt-4">
|
<div className="flex flex-col space-y-4 pt-4">
|
||||||
{allActivities.map((activity, index) => {
|
{activities.map((activity, index) => {
|
||||||
const activityText = getActivityText({
|
const activityText = getActivityText({
|
||||||
type: activity.type,
|
type: activity.type,
|
||||||
toTitle: activity.toTitle,
|
toTitle: activity.toTitle,
|
||||||
fromList: activity.fromList?.name ?? null,
|
fromList: activity.fromList?.name ?? null,
|
||||||
toList: activity.toList?.name ?? null,
|
toList: activity.toList?.name ?? null,
|
||||||
memberName: activity.member?.user?.name ?? null,
|
memberName: activity.member?.user?.name ?? null,
|
||||||
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
isSelf: activity.member?.user?.id === data?.user.id,
|
||||||
label: activity.label?.name ?? null,
|
label: activity.label?.name ?? null,
|
||||||
fromTitle: activity.fromTitle ?? null,
|
fromTitle: activity.fromTitle ?? null,
|
||||||
fromDueDate: activity.fromDueDate ?? null,
|
fromDueDate: activity.fromDueDate ?? null,
|
||||||
toDueDate: activity.toDueDate ?? null,
|
toDueDate: activity.toDueDate ?? null,
|
||||||
dateLocale: dateLocale,
|
dateLocale: dateLocale,
|
||||||
mergedLabels: (activity as any).mergedLabels,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activity.type === "card.updated.comment.added")
|
if (activity.type === "card.updated.comment.added")
|
||||||
@@ -472,12 +322,11 @@ const ActivityList = ({
|
|||||||
cardPublicId={cardPublicId}
|
cardPublicId={cardPublicId}
|
||||||
name={activity.user?.name ?? ""}
|
name={activity.user?.name ?? ""}
|
||||||
email={activity.user?.email ?? ""}
|
email={activity.user?.email ?? ""}
|
||||||
image={activity.user?.image ?? null}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
createdAt={activity.createdAt.toISOString()}
|
createdAt={activity.createdAt.toISOString()}
|
||||||
comment={activity.comment?.comment}
|
comment={activity.comment?.comment}
|
||||||
isEdited={!!activity.comment?.updatedAt}
|
isEdited={!!activity.comment?.updatedAt}
|
||||||
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
|
isAuthor={activity.comment?.createdBy === data?.user.id}
|
||||||
isAdmin={isAdmin ?? false}
|
isAdmin={isAdmin ?? false}
|
||||||
isViewOnly={!!isViewOnly}
|
isViewOnly={!!isViewOnly}
|
||||||
/>
|
/>
|
||||||
@@ -495,7 +344,6 @@ const ActivityList = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
name={activity.user?.name ?? ""}
|
name={activity.user?.name ?? ""}
|
||||||
email={activity.user?.email ?? ""}
|
email={activity.user?.email ?? ""}
|
||||||
imageUrl={getAvatarUrl(activity.user?.image ?? null) || undefined}
|
|
||||||
icon={getActivityIcon(
|
icon={getActivityIcon(
|
||||||
activity.type,
|
activity.type,
|
||||||
activity.fromList?.index,
|
activity.fromList?.index,
|
||||||
@@ -503,9 +351,11 @@ const ActivityList = ({
|
|||||||
)}
|
)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
{index !== allActivities.length - 1 && (
|
{index !== activities.length - 1 &&
|
||||||
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
|
activities[index + 1]?.type !==
|
||||||
)}
|
"card.updated.comment.added" && (
|
||||||
|
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
|
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
|
||||||
@@ -523,17 +373,6 @@ const ActivityList = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{hasMore && (
|
|
||||||
<div className="flex justify-center pt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isFetching}
|
|
||||||
className="text-sm font-medium text-light-900 hover:text-light-1000 disabled:opacity-50 dark:text-dark-800 dark:hover:text-dark-1000"
|
|
||||||
>
|
|
||||||
{isFetching ? t`Loading...` : t`Load more activities`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
@@ -82,7 +81,7 @@ export function AttachmentThumbnails({
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Button from "~/components/Button";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
@@ -21,7 +20,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
||||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
const confirmAttachment = api.attachment.confirm.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Attachment uploaded`,
|
header: t`Attachment uploaded`,
|
||||||
message: t`Your file has been uploaded successfully.`,
|
message: t`Your file has been uploaded successfully.`,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import type { DraggableProvided } from "react-beautiful-dnd";
|
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ContentEditable from "react-contenteditable";
|
import ContentEditable from "react-contenteditable";
|
||||||
import { HiXMark } from "react-icons/hi2";
|
import { HiXMark } from "react-icons/hi2";
|
||||||
import { RiDraggable } from "react-icons/ri";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
interface ChecklistItemRowProps {
|
interface ChecklistItemRowProps {
|
||||||
item: {
|
item: {
|
||||||
@@ -17,19 +14,13 @@ interface ChecklistItemRowProps {
|
|||||||
completed: boolean;
|
completed: boolean;
|
||||||
};
|
};
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
onCreateNewItem?: () => void;
|
|
||||||
viewOnly?: boolean;
|
viewOnly?: boolean;
|
||||||
dragHandleProps?: DraggableProvided["dragHandleProps"];
|
|
||||||
isDragging?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChecklistItemRow({
|
export default function ChecklistItemRow({
|
||||||
item,
|
item,
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
onCreateNewItem,
|
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
dragHandleProps,
|
|
||||||
isDragging = false,
|
|
||||||
}: ChecklistItemRowProps) {
|
}: ChecklistItemRowProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
@@ -71,7 +62,7 @@ export default function ChecklistItemRow({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +90,7 @@ export default function ChecklistItemRow({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,23 +138,7 @@ export default function ChecklistItemRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
|
||||||
className={twMerge(
|
|
||||||
"group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100",
|
|
||||||
isDragging && "opacity-80",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!viewOnly && (
|
|
||||||
<div
|
|
||||||
{...dragHandleProps}
|
|
||||||
className="absolute left-0 top-1/2 flex h-[20px] w-[20px] -translate-x-full -translate-y-1/2 cursor-grab items-center justify-center pr-1 opacity-0 transition-opacity group-hover:opacity-75 hover:opacity-100 active:cursor-grabbing"
|
|
||||||
>
|
|
||||||
<RiDraggable className="h-4 w-4 text-light-700 dark:text-dark-700" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewOnly && <div className="w-[20px] flex-shrink-0" />}
|
|
||||||
|
|
||||||
<label
|
<label
|
||||||
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
|
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
|
||||||
>
|
>
|
||||||
@@ -189,10 +164,7 @@ export default function ChecklistItemRow({
|
|||||||
disabled={viewOnly}
|
disabled={viewOnly}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
// @ts-expect-error - valid event
|
// @ts-expect-error - valid event
|
||||||
onBlur={(e: Event) => {
|
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)}
|
||||||
const innerHTML = (e.target as HTMLElement).innerHTML;
|
|
||||||
commitTitle(innerHTML);
|
|
||||||
}}
|
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
|
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
|
||||||
viewOnly && "cursor-default",
|
viewOnly && "cursor-default",
|
||||||
@@ -202,9 +174,7 @@ export default function ChecklistItemRow({
|
|||||||
if (viewOnly) return;
|
if (viewOnly) return;
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
|
commitTitle(title);
|
||||||
commitTitle(innerHTML);
|
|
||||||
onCreateNewItem?.();
|
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
|
|
||||||
export default function ChecklistNameInput({
|
export default function ChecklistNameInput({
|
||||||
checklistPublicId,
|
checklistPublicId,
|
||||||
@@ -49,7 +48,7 @@ export default function ChecklistNameInput({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.invalidate({ cardPublicId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import type { DropResult } from "react-beautiful-dnd";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
|
||||||
import { HiPlus, HiXMark } from "react-icons/hi2";
|
import { HiPlus, HiXMark } from "react-icons/hi2";
|
||||||
|
|
||||||
import CircularProgress from "~/components/CircularProgress";
|
import CircularProgress from "~/components/CircularProgress";
|
||||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import ChecklistItemRow from "./ChecklistItemRow";
|
import ChecklistItemRow from "./ChecklistItemRow";
|
||||||
import ChecklistNameInput from "./ChecklistNameInput";
|
import ChecklistNameInput from "./ChecklistNameInput";
|
||||||
import NewChecklistItemForm from "./NewChecklistItemForm";
|
import NewChecklistItemForm from "./NewChecklistItemForm";
|
||||||
@@ -40,206 +34,109 @@ export default function Checklists({
|
|||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
}: ChecklistsProps) {
|
}: ChecklistsProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
if (!checklists || checklists.length === 0) return null;
|
||||||
|
|
||||||
const reorderItemMutation = api.checklist.updateItem.useMutation({
|
|
||||||
onMutate: async (vars) => {
|
|
||||||
await utils.card.byId.cancel({ cardPublicId });
|
|
||||||
const previous = utils.card.byId.getData({ cardPublicId });
|
|
||||||
|
|
||||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
|
|
||||||
const updatedChecklists = old.checklists.map((cl) => {
|
|
||||||
const itemIndex = cl.items.findIndex(
|
|
||||||
(item) => item.publicId === vars.checklistItemPublicId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (itemIndex === -1 || vars.index === undefined) return cl;
|
|
||||||
|
|
||||||
const newIndex = vars.index;
|
|
||||||
const items = Array.from(cl.items);
|
|
||||||
const [movedItem] = items.splice(itemIndex, 1);
|
|
||||||
if (!movedItem) return cl;
|
|
||||||
items.splice(newIndex, 0, movedItem);
|
|
||||||
|
|
||||||
return { ...cl, items };
|
|
||||||
});
|
|
||||||
|
|
||||||
return { ...old, checklists: updatedChecklists } as typeof old;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { previous };
|
|
||||||
},
|
|
||||||
onError: (_err, _vars, ctx) => {
|
|
||||||
if (ctx?.previous)
|
|
||||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
|
||||||
showPopup({
|
|
||||||
header: t`Unable to reorder checklist item`,
|
|
||||||
message: t`Please try again later, or contact customer support.`,
|
|
||||||
icon: "error",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSettled: async () => {
|
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDragEnd = (result: DropResult) => {
|
|
||||||
if (!result.destination) return;
|
|
||||||
|
|
||||||
const { source, destination, draggableId } = result;
|
|
||||||
|
|
||||||
if (source.droppableId !== destination.droppableId) return;
|
|
||||||
|
|
||||||
if (source.index === destination.index) return;
|
|
||||||
|
|
||||||
reorderItemMutation.mutate({
|
|
||||||
checklistItemPublicId: draggableId,
|
|
||||||
index: destination.index,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (checklists.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<div className="border-light-300 pb-4 dark:border-dark-300">
|
||||||
<div className="border-light-300 pb-4 dark:border-dark-300">
|
<div>
|
||||||
<div>
|
{checklists.map((checklist) => {
|
||||||
{checklists.map((checklist) => {
|
const completedItems = checklist.items.filter(
|
||||||
const completedItems = checklist.items.filter(
|
(item) => item.completed,
|
||||||
(item) => item.completed,
|
);
|
||||||
);
|
const progress =
|
||||||
const progress =
|
checklist.items.length > 0 && completedItems.length > 0
|
||||||
checklist.items.length > 0 && completedItems.length > 0
|
? (completedItems.length / checklist.items.length) * 100
|
||||||
? (completedItems.length / checklist.items.length) * 100
|
: 2;
|
||||||
: 2;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={checklist.publicId} className="mb-4">
|
<div key={checklist.publicId} className="mb-4">
|
||||||
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
|
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<ChecklistNameInput
|
<ChecklistNameInput
|
||||||
checklistPublicId={checklist.publicId}
|
checklistPublicId={checklist.publicId}
|
||||||
initialName={checklist.name}
|
initialName={checklist.name}
|
||||||
cardPublicId={cardPublicId}
|
cardPublicId={cardPublicId}
|
||||||
viewOnly={viewOnly}
|
viewOnly={viewOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{!viewOnly && (
|
|
||||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
|
||||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
|
||||||
<CircularProgress
|
|
||||||
progress={progress}
|
|
||||||
size="sm"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
|
||||||
{completedItems.length}/{checklist.items.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
|
||||||
onClick={() =>
|
|
||||||
openModal("DELETE_CHECKLIST", checklist.publicId)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<HiXMark size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setActiveChecklistForm?.(checklist.publicId)
|
|
||||||
}
|
|
||||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
|
||||||
>
|
|
||||||
<HiPlus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{viewOnly && (
|
|
||||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
|
||||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
|
||||||
<CircularProgress
|
|
||||||
progress={progress}
|
|
||||||
size="sm"
|
|
||||||
className="flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
|
||||||
{completedItems.length}/{checklist.items.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{!viewOnly && (
|
||||||
<Droppable
|
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||||
droppableId={checklist.publicId}
|
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||||
type="CHECKLIST_ITEM"
|
<CircularProgress
|
||||||
isDropDisabled={viewOnly}
|
progress={progress}
|
||||||
>
|
size="sm"
|
||||||
{(provided) => (
|
className="flex-shrink-0"
|
||||||
<div
|
/>
|
||||||
ref={provided.innerRef}
|
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||||
{...provided.droppableProps}
|
{completedItems.length}/{checklist.items.length}
|
||||||
className="ml-1"
|
</span>
|
||||||
>
|
</div>
|
||||||
{checklist.items.map((item, index) => (
|
<div>
|
||||||
<Draggable
|
<button
|
||||||
key={item.publicId}
|
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||||
draggableId={item.publicId}
|
onClick={() =>
|
||||||
index={index}
|
openModal("DELETE_CHECKLIST", checklist.publicId)
|
||||||
isDragDisabled={viewOnly}
|
}
|
||||||
>
|
>
|
||||||
{(provided, snapshot) => (
|
<HiXMark size={16} />
|
||||||
<div
|
</button>
|
||||||
ref={provided.innerRef}
|
<button
|
||||||
{...provided.draggableProps}
|
onClick={() =>
|
||||||
style={{
|
setActiveChecklistForm?.(checklist.publicId)
|
||||||
...provided.draggableProps.style,
|
}
|
||||||
opacity: snapshot.isDragging ? 0.8 : 1,
|
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||||
}}
|
>
|
||||||
>
|
<HiPlus size={16} />
|
||||||
<ChecklistItemRow
|
</button>
|
||||||
item={{
|
</div>
|
||||||
publicId: item.publicId,
|
</div>
|
||||||
title: item.title,
|
)}
|
||||||
completed: item.completed,
|
{viewOnly && (
|
||||||
}}
|
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||||
cardPublicId={cardPublicId}
|
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||||
onCreateNewItem={() =>
|
<CircularProgress
|
||||||
setActiveChecklistForm?.(checklist.publicId)
|
progress={progress}
|
||||||
}
|
size="sm"
|
||||||
viewOnly={viewOnly}
|
className="flex-shrink-0"
|
||||||
dragHandleProps={provided.dragHandleProps}
|
/>
|
||||||
isDragging={snapshot.isDragging}
|
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||||
/>
|
{completedItems.length}/{checklist.items.length}
|
||||||
</div>
|
</span>
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
{activeChecklistForm === checklist.publicId && !viewOnly && (
|
|
||||||
<div className="ml-1">
|
|
||||||
<NewChecklistItemForm
|
|
||||||
checklistPublicId={checklist.publicId}
|
|
||||||
cardPublicId={cardPublicId}
|
|
||||||
onCancel={() => setActiveChecklistForm?.(null)}
|
|
||||||
readOnly={viewOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
<div className="ml-1">
|
||||||
</div>
|
{checklist.items.map((item) => (
|
||||||
|
<ChecklistItemRow
|
||||||
|
key={item.publicId}
|
||||||
|
item={{
|
||||||
|
publicId: item.publicId,
|
||||||
|
title: item.title,
|
||||||
|
completed: item.completed,
|
||||||
|
}}
|
||||||
|
cardPublicId={cardPublicId}
|
||||||
|
viewOnly={viewOnly}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeChecklistForm === checklist.publicId && !viewOnly && (
|
||||||
|
<div className="ml-1">
|
||||||
|
<NewChecklistItemForm
|
||||||
|
checklistPublicId={checklist.publicId}
|
||||||
|
cardPublicId={cardPublicId}
|
||||||
|
onCancel={() => setActiveChecklistForm?.(null)}
|
||||||
|
readOnly={viewOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</DragDropContext>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,9 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -24,7 +21,6 @@ const Comment = ({
|
|||||||
cardPublicId,
|
cardPublicId,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
image,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
createdAt,
|
createdAt,
|
||||||
comment,
|
comment,
|
||||||
@@ -37,7 +33,6 @@ const Comment = ({
|
|||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
image: string | null;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
comment: string | undefined;
|
comment: string | undefined;
|
||||||
@@ -50,7 +45,6 @@ const Comment = ({
|
|||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { canEditComment, canDeleteComment } = usePermissions();
|
|
||||||
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
comment,
|
comment,
|
||||||
@@ -61,7 +55,7 @@ const Comment = ({
|
|||||||
|
|
||||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await invalidateCard(utils, cardPublicId);
|
await utils.card.byId.refetch();
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -82,7 +76,7 @@ const Comment = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
...(isAuthor && canEditComment
|
...(isAuthor
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Edit comment`,
|
label: t`Edit comment`,
|
||||||
@@ -91,7 +85,7 @@ const Comment = ({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...((isAuthor || canDeleteComment)
|
...(isAuthor || isAdmin
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Delete comment`,
|
label: t`Delete comment`,
|
||||||
@@ -113,7 +107,6 @@ const Comment = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
name={name ?? ""}
|
name={name ?? ""}
|
||||||
email={email ?? ""}
|
email={email ?? ""}
|
||||||
imageUrl={getAvatarUrl(image) || undefined}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||