Compare commits
39 Commits
fix/api-ke
...
fix/hide-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de87f6237e | ||
|
|
2269d23c94 | ||
|
|
e437d075e3 | ||
|
|
b422907b53 | ||
|
|
672dfe6540 | ||
|
|
ef5bf87fdf | ||
|
|
ec37f5480a | ||
|
|
7f5a1ab513 | ||
|
|
5f2d409773 | ||
|
|
210e44db5c | ||
|
|
befe7ab7f4 | ||
|
|
53a33c68fc | ||
|
|
3bae03613d | ||
|
|
c42ee7cf2c | ||
|
|
07f997c3ad | ||
|
|
d084d323bc | ||
|
|
6729c2e228 | ||
|
|
b18ef10313 | ||
|
|
03bd2d771b | ||
|
|
ff7944a4a2 | ||
|
|
0ca9c1d0e6 | ||
|
|
927bf2fe69 | ||
|
|
ccd84385a2 | ||
|
|
58c5e92155 | ||
|
|
885f119404 | ||
|
|
b17a24455a | ||
|
|
0c9561467d | ||
|
|
47b6f06be4 | ||
|
|
89c8961176 | ||
|
|
a5432c60b7 | ||
|
|
2b8fe3d3a2 | ||
|
|
73c9b326a1 | ||
|
|
a55c8cd52b | ||
|
|
cca8a9d424 | ||
|
|
57186b6b4d | ||
|
|
3cb40d0f9a | ||
|
|
6ebab28606 | ||
|
|
5765c71ebf | ||
|
|
86ecdca7f1 |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
|||||||
|
|
||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
.git
|
# .git
|
||||||
@@ -32,15 +32,23 @@ 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
40
.github/workflows/docker-publish.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
# Install the cosign tool except on PR
|
||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
@@ -74,6 +76,42 @@ 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
|
||||||
@@ -86,6 +124,8 @@ 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
1
.gitignore
vendored
@@ -34,6 +34,7 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
315
AGENTS.md
Normal file
315
AGENTS.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# 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
76
README.md
@@ -138,44 +138,48 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
|
| `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_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
|
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||||
|
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -19,43 +19,45 @@ 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
|
||||||
|
|
||||||
# Set working directory
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# It might be the path to <ROOT> turborepo
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
# Generate version from git (fallback if APP_VERSION not provided)
|
||||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
||||||
|
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
||||||
|
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
||||||
|
sed 's/^v//' || \
|
||||||
|
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
||||||
|
echo "unknown") && \
|
||||||
|
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||||
|
|
||||||
RUN turbo prune --scope=${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 pnpm build --filter=${PROJECT}
|
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||||
|
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||||
|
|
||||||
# # 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", "ptbr"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"po": {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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
|
||||||
@@ -36,8 +37,11 @@ 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
|
||||||
@@ -86,6 +90,31 @@ 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
|
||||||
@@ -97,6 +126,9 @@ 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
|
||||||
@@ -111,6 +143,7 @@ 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
|
||||||
@@ -122,6 +155,7 @@ 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
|
||||||
@@ -143,6 +177,7 @@ 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
|
||||||
@@ -182,14 +217,19 @@ 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
|
||||||
@@ -219,6 +259,7 @@ 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
|
||||||
@@ -254,6 +295,7 @@ 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
|
||||||
@@ -314,6 +356,9 @@ 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
|
||||||
@@ -324,6 +369,7 @@ 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
|
||||||
@@ -356,7 +402,9 @@ 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
|
||||||
@@ -369,6 +417,7 @@ 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
|
||||||
@@ -381,6 +430,10 @@ 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
|
||||||
@@ -388,6 +441,7 @@ 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
|
||||||
@@ -409,6 +463,7 @@ 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
|
||||||
@@ -417,12 +472,14 @@ 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
|
||||||
@@ -444,6 +501,7 @@ 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
|
||||||
@@ -478,6 +536,8 @@ 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
|
||||||
@@ -487,11 +547,14 @@ 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
|
||||||
@@ -502,6 +565,8 @@ 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
|
||||||
@@ -513,6 +578,8 @@ 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
|
||||||
@@ -524,6 +591,8 @@ 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
|
||||||
@@ -582,6 +651,7 @@ 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
|
||||||
@@ -592,10 +662,12 @@ 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
|
||||||
@@ -611,3 +683,4 @@ 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","ptbr"],
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ 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
|
||||||
@@ -90,6 +95,12 @@ const config = {
|
|||||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: env("NEXT_API_BODY_SIZE_LIMIT") || '1mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"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",
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.26.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
@@ -86,7 +89,8 @@
|
|||||||
"jiti": "^1.21.6",
|
"jiti": "^1.21.6",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config"
|
"prettier": "@kan/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
|
|||||||
handleEdit?: (key: string) => void;
|
handleEdit?: (key: string) => void;
|
||||||
handleCreate?: () => void;
|
handleCreate?: () => void;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckboxDropdown({
|
export default function CheckboxDropdown({
|
||||||
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
asChild = true,
|
asChild = true,
|
||||||
|
disabled = false,
|
||||||
}: CheckboxDropdownProps) {
|
}: CheckboxDropdownProps) {
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
|
|||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Menu.Item key={item.key}>
|
<Menu.Item key={item.key}>
|
||||||
<div
|
<div
|
||||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={item.key}
|
id={item.key}
|
||||||
name={item.key}
|
name={item.key}
|
||||||
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
|
|||||||
<>
|
<>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
as={asChild ? "div" : undefined}
|
as={asChild ? "div" : undefined}
|
||||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
disabled={disabled}
|
||||||
|
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|||||||
@@ -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={{ email: session?.user.email, image: session?.user.image }}
|
user={{ displayName: session?.user.name, 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 }[];
|
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -30,13 +30,14 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label}>
|
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||||
<button
|
<button
|
||||||
onClick={item.action}
|
onClick={item.action}
|
||||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
disabled={item.disabled ?? !item.action}
|
||||||
|
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Range as TiptapRange } from "@tiptap/core";
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import type {
|
import type {
|
||||||
SuggestionKeyDownProps,
|
SuggestionKeyDownProps,
|
||||||
@@ -44,6 +45,7 @@ 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> {
|
||||||
@@ -56,7 +58,7 @@ declare module "@tiptap/core" {
|
|||||||
export interface SlashCommandItem {
|
export interface SlashCommandItem {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,12 +433,14 @@ 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);
|
||||||
|
|
||||||
@@ -484,10 +488,17 @@ export default function Editor({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
return all.filter(
|
||||||
|
(u) =>
|
||||||
|
u.label &&
|
||||||
|
typeof u.label === "string" &&
|
||||||
|
u.label.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
command: ({ editor, range, props }: any) => {
|
command: ({ editor, range, props }) => {
|
||||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
const id = props.id ?? "";
|
||||||
|
const label = props.label ?? "";
|
||||||
|
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span> `;
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
@@ -503,6 +514,7 @@ 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()),
|
||||||
@@ -559,6 +571,9 @@ export default function Editor({
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.tiptap [data-youtube] {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||||
<div className="p-4">
|
<div className="p-4 relative">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 mt-1">
|
||||||
{popupIcon === "success" && (
|
{popupIcon === "success" && (
|
||||||
<HiOutlineCheckCircle
|
<HiOutlineCheckCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-6 w-6 text-green-400"
|
className="h-5 w-5 text-green-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{popupIcon === "error" && (
|
{popupIcon === "error" && (
|
||||||
<HiOutlineExclamationCircle
|
<HiOutlineExclamationCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-6 w-6 text-red-400"
|
className="h-5 w-5 text-red-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
||||||
{popupHeader}
|
{popupHeader}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
||||||
{popupMessage}
|
{popupMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex flex-shrink-0">
|
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
<HiXMark
|
<HiXMark
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-dark-900"
|
className="h-4 w-4 text-dark-900"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ const Button: React.FC<{
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"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",
|
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||||
|
isCollapsed ? "md:justify-center" : "justify-between",
|
||||||
current
|
current
|
||||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ 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;
|
||||||
@@ -24,8 +27,12 @@ interface SettingsLayoutProps {
|
|||||||
|
|
||||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
||||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||||
|
|
||||||
|
const isAdmin = workspace.role === "admin";
|
||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{
|
{
|
||||||
key: "account",
|
key: "account",
|
||||||
@@ -37,13 +44,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "workspace",
|
key: "workspace",
|
||||||
icon: <HiOutlineRectangleGroup />,
|
icon: <HiOutlineRectangleGroup />,
|
||||||
label: t`Workspace`,
|
label: t`Workspace`,
|
||||||
condition: true,
|
condition: canViewWorkspace,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "permissions",
|
||||||
|
icon: <HiOutlineShieldCheck />,
|
||||||
|
label: t`Permissions`,
|
||||||
|
condition: isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
label: t`Billing`,
|
label: t`Billing`,
|
||||||
icon: <HiOutlineBanknotes />,
|
icon: <HiOutlineBanknotes />,
|
||||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "api",
|
key: "api",
|
||||||
@@ -55,7 +68,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "integrations",
|
key: "integrations",
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
label: t`Integrations`,
|
label: t`Integrations`,
|
||||||
condition: true,
|
condition: canEditWorkspace,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -97,7 +110,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,6 +39,7 @@ interface SideNavigationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
|
displayName?: string | null | undefined;
|
||||||
email?: string | null | undefined;
|
email?: string | null | undefined;
|
||||||
image?: string | null | undefined;
|
image?: string | null | undefined;
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,8 @@ export default function SideNavigation({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UserMenu
|
<UserMenu
|
||||||
email={user.email ?? ""}
|
displayName={user.displayName ?? undefined}
|
||||||
|
email={user.email ?? "Email not provided?"}
|
||||||
imageUrl={user.image ?? undefined}
|
imageUrl={user.image ?? undefined}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ const Toggle = ({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
|
showLabel = true,
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mr-4 flex items-center justify-end">
|
<div className="mr-4 flex items-center justify-end">
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
{showLabel && (
|
||||||
{label}
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
</span>
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function Tooltip({
|
|||||||
delay,
|
delay,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
theme: "tooltip",
|
theme: "tooltip",
|
||||||
|
touch: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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";
|
||||||
@@ -16,6 +17,7 @@ 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;
|
||||||
@@ -25,6 +27,7 @@ interface UserMenuProps {
|
|||||||
export default function UserMenu({
|
export default function UserMenu({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
email,
|
email,
|
||||||
|
displayName,
|
||||||
isLoading,
|
isLoading,
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
onCloseSideNav,
|
onCloseSideNav,
|
||||||
@@ -74,7 +77,7 @@ export default function UserMenu({
|
|||||||
) : (
|
) : (
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||||
title={isCollapsed ? email : undefined}
|
title={isCollapsed ? (displayName || email) : undefined}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -101,7 +104,7 @@ export default function UserMenu({
|
|||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{email}
|
{displayName || email}
|
||||||
</span>
|
</span>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
@@ -225,6 +228,25 @@ export default function UserMenu({
|
|||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
|
{env.NEXT_PUBLIC_APP_VERSION && (
|
||||||
|
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||||
|
<Menu.Item>
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
||||||
|
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
||||||
|
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
||||||
|
>
|
||||||
|
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
189
apps/web/src/components/YouTubeEmbed/EditYouTubeModal.tsx
Normal file
189
apps/web/src/components/YouTubeEmbed/EditYouTubeModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { HiXMark } from "react-icons/hi2";
|
||||||
|
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Input from "~/components/Input";
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||||
|
|
||||||
|
interface EditYouTubeFormInput {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditYouTubeModalState {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
onSave: (url: string, title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditYouTubeModal() {
|
||||||
|
const { closeModal, getModalState } = useModal();
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [urlError, setUrlError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Get initial values and callback from modal state
|
||||||
|
const modalState = getModalState("EDIT_YOUTUBE") as
|
||||||
|
| EditYouTubeModalState
|
||||||
|
| undefined;
|
||||||
|
const initialUrl = modalState?.url ?? "";
|
||||||
|
const initialTitle = modalState?.title ?? "";
|
||||||
|
const onSave = modalState?.onSave;
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch, reset } =
|
||||||
|
useForm<EditYouTubeFormInput>({
|
||||||
|
defaultValues: {
|
||||||
|
url: initialUrl,
|
||||||
|
title: initialTitle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form when modal state changes (when modal opens with new values)
|
||||||
|
useEffect(() => {
|
||||||
|
if (modalState) {
|
||||||
|
reset({
|
||||||
|
url: modalState.url,
|
||||||
|
title: modalState.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [modalState, reset]);
|
||||||
|
|
||||||
|
const currentUrl = watch("url");
|
||||||
|
|
||||||
|
const onSubmit = async (values: EditYouTubeFormInput) => {
|
||||||
|
// Validate URL
|
||||||
|
if (!isYouTubeUrl(values.url)) {
|
||||||
|
setUrlError(t`Please enter a valid YouTube URL`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrlError(null);
|
||||||
|
setIsValidating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If title is empty and URL changed, fetch new title
|
||||||
|
let finalTitle = values.title;
|
||||||
|
if (!finalTitle.trim() && values.url !== initialUrl) {
|
||||||
|
const metadata = await fetchYouTubeMetadata(values.url);
|
||||||
|
finalTitle = metadata?.title ?? "YouTube Video";
|
||||||
|
} else if (!finalTitle.trim()) {
|
||||||
|
// Keep existing title if no new title provided and URL unchanged
|
||||||
|
finalTitle = initialTitle || "YouTube Video";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSave) {
|
||||||
|
onSave(values.url, finalTitle);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setUrlError(t`Failed to fetch video information`);
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-focus on title input (more useful for editing)
|
||||||
|
useEffect(() => {
|
||||||
|
const titleElement: HTMLElement | null =
|
||||||
|
document.querySelector<HTMLElement>("#youtube-title");
|
||||||
|
if (titleElement) titleElement.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validate URL on change
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUrl && !isYouTubeUrl(currentUrl)) {
|
||||||
|
setUrlError(t`Please enter a valid YouTube URL`);
|
||||||
|
} else {
|
||||||
|
setUrlError(null);
|
||||||
|
}
|
||||||
|
}, [currentUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="px-5 pt-5">
|
||||||
|
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||||
|
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="youtube-title"
|
||||||
|
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||||
|
>
|
||||||
|
{t`Title`}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="youtube-title"
|
||||||
|
placeholder={t`Enter a custom title`}
|
||||||
|
{...register("title")}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
await handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="youtube-url"
|
||||||
|
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||||
|
>
|
||||||
|
{t`YouTube URL`}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="youtube-url"
|
||||||
|
placeholder={t`https://www.youtube.com/watch?v=...`}
|
||||||
|
{...register("url", { required: true })}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
await handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{urlError && (
|
||||||
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{urlError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => closeModal()}
|
||||||
|
>
|
||||||
|
{t`Cancel`}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={isValidating}
|
||||||
|
disabled={!watch("url") || !!urlError}
|
||||||
|
>
|
||||||
|
{t`Save`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx
Normal file
53
apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import YouTubeDropdown from "./YouTubeDropdown";
|
||||||
|
|
||||||
|
interface YouTubeCardProps {
|
||||||
|
videoId: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
showEmbed?: boolean;
|
||||||
|
onConvertToLink: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onUpdate: (url: string, title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YouTubeCard = ({
|
||||||
|
videoId,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
showEmbed = true,
|
||||||
|
onConvertToLink,
|
||||||
|
onDelete,
|
||||||
|
onUpdate,
|
||||||
|
}: YouTubeCardProps) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
|
||||||
|
<div className="flex items-center justify-between gap-6 p-0 px-6">
|
||||||
|
<h3 className="truncate text-sm font-medium">{title}</h3>
|
||||||
|
<div className="mt-3">
|
||||||
|
<YouTubeDropdown
|
||||||
|
url={url}
|
||||||
|
title={title}
|
||||||
|
onConvertToLink={onConvertToLink}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmbed && videoId && (
|
||||||
|
<div className="p-6 pt-1">
|
||||||
|
<iframe
|
||||||
|
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||||
|
title={title}
|
||||||
|
className="aspect-video w-full rounded-lg"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YouTubeCard;
|
||||||
63
apps/web/src/components/YouTubeEmbed/YouTubeDropdown.tsx
Normal file
63
apps/web/src/components/YouTubeEmbed/YouTubeDropdown.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import {
|
||||||
|
HiEllipsisHorizontal,
|
||||||
|
HiLink,
|
||||||
|
HiPencil,
|
||||||
|
HiTrash,
|
||||||
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { useModal } from "~/providers/modal";
|
||||||
|
import Dropdown from "../Dropdown";
|
||||||
|
|
||||||
|
interface YouTubeDropdownProps {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
onConvertToLink: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onUpdate: (url: string, title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YouTubeDropdown = ({
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
onConvertToLink,
|
||||||
|
onDelete,
|
||||||
|
onUpdate,
|
||||||
|
}: YouTubeDropdownProps) => {
|
||||||
|
const { openModal, setModalState } = useModal();
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setModalState("EDIT_YOUTUBE", {
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
onSave: onUpdate,
|
||||||
|
});
|
||||||
|
openModal("EDIT_YOUTUBE");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: t`Edit`,
|
||||||
|
action: handleEdit,
|
||||||
|
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Convert to link`,
|
||||||
|
action: onConvertToLink,
|
||||||
|
icon: <HiLink className="h-4 w-4 text-dark-900" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Delete`,
|
||||||
|
action: onDelete,
|
||||||
|
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default YouTubeDropdown;
|
||||||
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||||
|
import YouTubeNodeView from "./YouTubeNodeView";
|
||||||
|
|
||||||
|
export interface YouTubeOptions {
|
||||||
|
inline: boolean;
|
||||||
|
HTMLAttributes: Record<string, undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
youTube: {
|
||||||
|
setYouTubeEmbed: (options: {
|
||||||
|
videoId: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
showEmbed?: boolean;
|
||||||
|
}) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const YouTubeNode = Node.create<YouTubeOptions>({
|
||||||
|
name: "youtube",
|
||||||
|
group: "block",
|
||||||
|
atom: true,
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
inline: false,
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
videoId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-video-id"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.videoId) return {};
|
||||||
|
return { "data-video-id": attributes.videoId as string };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-url"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.url) return {};
|
||||||
|
return { "data-url": attributes.url as string };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: "YouTube Video",
|
||||||
|
parseHTML: (element) => element.getAttribute("data-title"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
return { "data-title": attributes.title as string };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thumbnailUrl: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-thumbnail"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.thumbnailUrl) return {};
|
||||||
|
return { "data-thumbnail": attributes.thumbnailUrl as string };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showEmbed: {
|
||||||
|
default: true,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-show-embed"),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
return { "data-show-embed": attributes.showEmbed as string };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: "div[data-youtube]",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-youtube": "" },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(YouTubeNodeView);
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const nodeType = this.type;
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey("youtubePaste"),
|
||||||
|
props: {
|
||||||
|
handlePaste: (view, event) => {
|
||||||
|
const text = event.clipboardData?.getData("text/plain");
|
||||||
|
if (!text) return false;
|
||||||
|
|
||||||
|
// Check if the pasted text is a YouTube URL
|
||||||
|
const youtubeRegex =
|
||||||
|
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
|
||||||
|
const match = youtubeRegex.exec(text);
|
||||||
|
|
||||||
|
if (!match || !isYouTubeUrl(text)) return false;
|
||||||
|
|
||||||
|
const videoId = extractVideoId(text);
|
||||||
|
if (!videoId) return false;
|
||||||
|
|
||||||
|
const { state, dispatch } = view;
|
||||||
|
const { tr } = state;
|
||||||
|
|
||||||
|
const node = nodeType.create({
|
||||||
|
videoId,
|
||||||
|
url: text,
|
||||||
|
title: "Loading...",
|
||||||
|
showEmbed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tr.replaceSelectionWith(node);
|
||||||
|
dispatch(tr);
|
||||||
|
|
||||||
|
// Fetch metadata asynchronously and update the node
|
||||||
|
void fetchYouTubeMetadata(text).then((metadata) => {
|
||||||
|
const { state: newState, dispatch: newDispatch } = view;
|
||||||
|
const { tr: newTr } = newState;
|
||||||
|
|
||||||
|
newState.doc.descendants((n, pos) => {
|
||||||
|
if (
|
||||||
|
n.type.name === "youtube" &&
|
||||||
|
n.attrs.videoId === videoId &&
|
||||||
|
n.attrs.title === "Loading..."
|
||||||
|
) {
|
||||||
|
newTr.setNodeMarkup(pos, undefined, {
|
||||||
|
...n.attrs,
|
||||||
|
title: metadata?.title ?? "YouTube Video",
|
||||||
|
thumbnailUrl: metadata?.thumbnail_url ?? null,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newTr.docChanged) {
|
||||||
|
newDispatch(newTr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
// commands for the YouTube embed node
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setYouTubeEmbed:
|
||||||
|
(options: {
|
||||||
|
videoId: string;
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
showEmbed?: boolean;
|
||||||
|
}) =>
|
||||||
|
({ editor }) => {
|
||||||
|
return editor.commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: {
|
||||||
|
videoId: options.videoId,
|
||||||
|
url: options.url,
|
||||||
|
title: options.title ?? "YouTube Video",
|
||||||
|
thumbnailUrl: options.thumbnailUrl,
|
||||||
|
showEmbed: options.showEmbed ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
82
apps/web/src/components/YouTubeEmbed/YouTubeNodeView.tsx
Normal file
82
apps/web/src/components/YouTubeEmbed/YouTubeNodeView.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { NodeViewProps } from "@tiptap/react";
|
||||||
|
import { NodeViewWrapper } from "@tiptap/react";
|
||||||
|
|
||||||
|
import { extractVideoId } from "./utils";
|
||||||
|
import YouTubeCard from "./YouTubeCard";
|
||||||
|
|
||||||
|
export default function YouTubeNodeView({
|
||||||
|
node,
|
||||||
|
editor,
|
||||||
|
getPos,
|
||||||
|
deleteNode,
|
||||||
|
updateAttributes,
|
||||||
|
}: NodeViewProps) {
|
||||||
|
const { videoId, title, url, showEmbed } = node.attrs as {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
showEmbed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConvertToLink = () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (typeof pos !== "number") return;
|
||||||
|
|
||||||
|
const linkContent = {
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: url,
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
attrs: {
|
||||||
|
href: url,
|
||||||
|
target: "_blank",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
||||||
|
.insertContentAt(pos, linkContent)
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteNode();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (newUrl: string, newTitle: string) => {
|
||||||
|
if (newUrl !== url) {
|
||||||
|
const newVideoId = extractVideoId(newUrl);
|
||||||
|
updateAttributes({
|
||||||
|
url: newUrl,
|
||||||
|
title: newTitle,
|
||||||
|
videoId: newVideoId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateAttributes({ title: newTitle });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper>
|
||||||
|
<YouTubeCard
|
||||||
|
videoId={videoId}
|
||||||
|
url={url}
|
||||||
|
title={title}
|
||||||
|
showEmbed={showEmbed}
|
||||||
|
onConvertToLink={handleConvertToLink}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
/>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/src/components/YouTubeEmbed/utils.ts
Normal file
61
apps/web/src/components/YouTubeEmbed/utils.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export function extractVideoId(url: string): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
url = url.trim();
|
||||||
|
|
||||||
|
// youtube.com/watch?v=VIDEO_ID
|
||||||
|
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||||
|
if (watchMatch) return watchMatch[1] ?? null;
|
||||||
|
|
||||||
|
// youtu.be/VIDEO_ID
|
||||||
|
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||||
|
if (shortMatch) return shortMatch[1] ?? null;
|
||||||
|
|
||||||
|
// youtube.com/embed/VIDEO_ID
|
||||||
|
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||||
|
if (embedMatch) return embedMatch[1] ?? null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a valid YouTube link
|
||||||
|
*/
|
||||||
|
export function isYouTubeUrl(url: string): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
const videoId = extractVideoId(url);
|
||||||
|
return videoId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch YouTube video metadata using oEmbed API
|
||||||
|
* Returns title, author, thumbnail URL, etc.
|
||||||
|
* No API key required
|
||||||
|
*/
|
||||||
|
export async function fetchYouTubeMetadata(url: string): Promise<{
|
||||||
|
title: string;
|
||||||
|
author_name: string;
|
||||||
|
thumbnail_url: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||||
|
const response = await fetch(oembedUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
title: string;
|
||||||
|
author_name: string;
|
||||||
|
thumbnail_url: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
title: data.title || "YouTube Video",
|
||||||
|
author_name: data.author_name || "",
|
||||||
|
thumbnail_url: data.thumbnail_url || "",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch YouTube metadata:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
positionFromTop?: "sm" | "md" | "lg";
|
positionFromTop?: "sm" | "md" | "lg";
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
|
centered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
isVisible,
|
||||||
closeOnClickOutside,
|
closeOnClickOutside,
|
||||||
|
centered = false,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@@ -71,7 +73,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const env = createEnv({
|
|||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
|
KAN_ADMIN_API_KEY: z.string().optional(),
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
@@ -77,6 +78,7 @@ export const env = createEnv({
|
|||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||||
EMAIL_FROM: z.string().optional(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
|
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,6 +96,14 @@ 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))
|
||||||
@@ -131,6 +141,9 @@ 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:
|
||||||
|
|||||||
101
apps/web/src/hooks/useDragToScroll.ts
Normal file
101
apps/web/src/hooks/useDragToScroll.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface UseDragToScrollOptions {
|
||||||
|
/**
|
||||||
|
* Whether drag-to-scroll is enabled
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The direction to scroll
|
||||||
|
*/
|
||||||
|
direction?: "horizontal" | "vertical" | "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to enable drag-to-scroll functionality on a scrollable element
|
||||||
|
* @param options Configuration options
|
||||||
|
* @returns Ref to attach to the scrollable element and mouse event handlers
|
||||||
|
*/
|
||||||
|
export function useDragToScroll({
|
||||||
|
enabled = true,
|
||||||
|
direction = "horizontal",
|
||||||
|
}: UseDragToScrollOptions = {}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const startPosRef = useRef({ x: 0, y: 0 });
|
||||||
|
const scrollStartRef = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !isDragging) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - startPosRef.current.x;
|
||||||
|
const deltaY = e.clientY - startPosRef.current.y;
|
||||||
|
|
||||||
|
if (direction === "horizontal" || direction === "both") {
|
||||||
|
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
|
||||||
|
}
|
||||||
|
if (direction === "vertical" || direction === "both") {
|
||||||
|
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
document.body.style.cursor = "grabbing";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
}, [enabled, isDragging, direction]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
|
||||||
|
// Check if the click is on an interactive or draggable element
|
||||||
|
// We need to be careful not to interfere with react-beautiful-dnd dragging
|
||||||
|
const isInteractiveElement =
|
||||||
|
target.closest("a") ||
|
||||||
|
target.closest("button") ||
|
||||||
|
target.closest("input") ||
|
||||||
|
target.closest("textarea") ||
|
||||||
|
target.closest("[role='button']") ||
|
||||||
|
target.closest("[draggable='true']") ||
|
||||||
|
target.closest(".react-beautiful-dnd-drag-handle") ||
|
||||||
|
target.closest("[data-rbd-drag-handle-draggable-id]") ||
|
||||||
|
target.closest("[data-rbd-draggable-id]");
|
||||||
|
|
||||||
|
// Don't start dragging if clicking on interactive elements
|
||||||
|
if (isInteractiveElement) return;
|
||||||
|
|
||||||
|
// Enable drag-to-scroll for any non-interactive element within the container
|
||||||
|
if (container.contains(target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
scrollStartRef.current = {
|
||||||
|
x: container.scrollLeft,
|
||||||
|
y: container.scrollTop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ref: scrollRef,
|
||||||
|
onMouseDown: handleMouseDown,
|
||||||
|
isDragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ export function useLocalisation() {
|
|||||||
nl,
|
nl,
|
||||||
ru,
|
ru,
|
||||||
pl,
|
pl,
|
||||||
ptbr: ptBR,
|
"pt-BR": ptBR,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||||
|
|||||||
109
apps/web/src/hooks/usePermissions.ts
Normal file
109
apps/web/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { Permission } from "@kan/shared";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
import { WorkspaceContext } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
interface UsePermissionsResult {
|
||||||
|
permissions: Permission[];
|
||||||
|
role: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasPermission: (permission: Permission) => boolean;
|
||||||
|
canViewCard: boolean;
|
||||||
|
canCreateCard: boolean;
|
||||||
|
canEditCard: boolean;
|
||||||
|
canDeleteCard: boolean;
|
||||||
|
canCreateList: boolean;
|
||||||
|
canEditList: boolean;
|
||||||
|
canDeleteList: boolean;
|
||||||
|
canCreateBoard: boolean;
|
||||||
|
canEditBoard: boolean;
|
||||||
|
canDeleteBoard: boolean;
|
||||||
|
canViewComment: boolean;
|
||||||
|
canCreateComment: boolean;
|
||||||
|
canEditComment: boolean;
|
||||||
|
canDeleteComment: boolean;
|
||||||
|
canInviteMember: boolean;
|
||||||
|
canEditMember: boolean;
|
||||||
|
canRemoveMember: boolean;
|
||||||
|
canViewWorkspace: boolean;
|
||||||
|
canEditWorkspace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions(): UsePermissionsResult {
|
||||||
|
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||||
|
const workspaceContext = useContext(WorkspaceContext);
|
||||||
|
|
||||||
|
// If WorkspaceProvider is not available, return safe defaults
|
||||||
|
if (!workspaceContext) {
|
||||||
|
const emptyPermissions: UsePermissionsResult = {
|
||||||
|
permissions: [],
|
||||||
|
role: null,
|
||||||
|
isLoading: false,
|
||||||
|
hasPermission: () => false,
|
||||||
|
canViewCard: false,
|
||||||
|
canCreateCard: false,
|
||||||
|
canEditCard: false,
|
||||||
|
canDeleteCard: false,
|
||||||
|
canCreateList: false,
|
||||||
|
canEditList: false,
|
||||||
|
canDeleteList: false,
|
||||||
|
canCreateBoard: false,
|
||||||
|
canEditBoard: false,
|
||||||
|
canDeleteBoard: false,
|
||||||
|
canViewComment: false,
|
||||||
|
canCreateComment: false,
|
||||||
|
canEditComment: false,
|
||||||
|
canDeleteComment: false,
|
||||||
|
canInviteMember: false,
|
||||||
|
canEditMember: false,
|
||||||
|
canRemoveMember: false,
|
||||||
|
canViewWorkspace: false,
|
||||||
|
canEditWorkspace: false,
|
||||||
|
};
|
||||||
|
return emptyPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace } = workspaceContext;
|
||||||
|
|
||||||
|
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||||
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
{
|
||||||
|
enabled: !!workspace.publicId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = (data?.permissions ?? []) as Permission[];
|
||||||
|
const role = data?.role ?? null;
|
||||||
|
|
||||||
|
const hasPermission = (permission: Permission): boolean => {
|
||||||
|
return permissions.includes(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
role,
|
||||||
|
isLoading,
|
||||||
|
hasPermission,
|
||||||
|
canViewCard: hasPermission("card:view"),
|
||||||
|
canCreateCard: hasPermission("card:create"),
|
||||||
|
canEditCard: hasPermission("card:edit"),
|
||||||
|
canDeleteCard: hasPermission("card:delete"),
|
||||||
|
canCreateList: hasPermission("list:create"),
|
||||||
|
canEditList: hasPermission("list:edit"),
|
||||||
|
canDeleteList: hasPermission("list:delete"),
|
||||||
|
canCreateBoard: hasPermission("board:create"),
|
||||||
|
canEditBoard: hasPermission("board:edit"),
|
||||||
|
canDeleteBoard: hasPermission("board:delete"),
|
||||||
|
canViewComment: hasPermission("comment:view"),
|
||||||
|
canCreateComment: hasPermission("comment:create"),
|
||||||
|
canEditComment: hasPermission("comment:edit"),
|
||||||
|
canDeleteComment: hasPermission("comment:delete"),
|
||||||
|
canInviteMember: hasPermission("member:invite"),
|
||||||
|
canEditMember: hasPermission("member:edit"),
|
||||||
|
canRemoveMember: hasPermission("member:remove"),
|
||||||
|
canViewWorkspace: hasPermission("workspace:view"),
|
||||||
|
canEditWorkspace: hasPermission("workspace:edit"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export const locales = [
|
|||||||
"nl",
|
"nl",
|
||||||
"ru",
|
"ru",
|
||||||
"pl",
|
"pl",
|
||||||
"ptbr"
|
"pt-BR"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
ptbr: "Português",
|
"pt-BR": "Português",
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/pt-BR/messages.ts
Normal file
1
apps/web/src/locales/pt-BR/messages.ts
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -2,9 +2,17 @@ import { toNodeHandler } from "better-auth/node";
|
|||||||
|
|
||||||
import { initAuth } from "@kan/auth/server";
|
import { initAuth } from "@kan/auth/server";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
export const auth = initAuth(createDrizzleClient());
|
export const auth = initAuth(createDrizzleClient());
|
||||||
|
|
||||||
export default toNodeHandler(auth.handler);
|
const authHandler = toNodeHandler(auth.handler);
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 100, duration: 60 },
|
||||||
|
async (req, res) => {
|
||||||
|
return await authHandler(req, res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
export default async function handler(
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse,
|
export default withRateLimit(
|
||||||
) {
|
{ 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" });
|
||||||
}
|
}
|
||||||
@@ -44,4 +45,5 @@ export default async function handler(
|
|||||||
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,9 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
export default async function handler(
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse,
|
export default withRateLimit(
|
||||||
) {
|
{ 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" });
|
||||||
}
|
}
|
||||||
@@ -20,4 +21,5 @@ export default async function handler(
|
|||||||
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 async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
) {
|
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -31,4 +31,5 @@ export default async function handler(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating portal session" });
|
return res.status(500).json({ error: "Error creating portal session" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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()
|
||||||
@@ -21,10 +22,9 @@ interface CheckoutSessionRequest {
|
|||||||
stripeCustomerId: string;
|
stripeCustomerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
) {
|
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -88,6 +88,7 @@ export default async function handler(
|
|||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
|
payment_method_collection: "always",
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||||
@@ -114,4 +115,5 @@ export default async function handler(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating checkout session" });
|
return res.status(500).json({ error: "Error creating checkout session" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { integrations } from "@kan/db/schema";
|
import { integrations } from "@kan/db/schema";
|
||||||
import { addYears } from "date-fns";
|
import { addYears } from "date-fns";
|
||||||
|
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||||
|
|
||||||
export default async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
) {
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -48,4 +48,5 @@ export default async function handler(
|
|||||||
console.error("Trello authentication error:", err);
|
console.error("Trello authentication error:", err);
|
||||||
return res.status(400).json({ message: "Trello authentication failed" });
|
return res.status(400).json({ message: "Trello authentication failed" });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
);
|
||||||
@@ -3,12 +3,14 @@ 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:
|
||||||
process.env.NODE_ENV === "development"
|
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}`,
|
||||||
@@ -17,11 +19,16 @@ const nextApiHandler = createNextApiHandler({
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default withRateLimit(
|
||||||
if (req.method === "OPTIONS") {
|
{ points: 100, duration: 60 },
|
||||||
res.writeHead(200);
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
return res.end();
|
if (req.method === "OPTIONS") {
|
||||||
}
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return nextApiHandler(req, res);
|
const result = await nextApiHandler(req, res);
|
||||||
}
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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),
|
||||||
@@ -19,10 +20,9 @@ type ResponseData =
|
|||||||
|
|
||||||
const textEncoder = new TextEncoder();
|
const textEncoder = new TextEncoder();
|
||||||
|
|
||||||
export default async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse<ResponseData>,
|
async (req: NextApiRequest, 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,4 +101,5 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
) {
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -71,4 +71,5 @@ export default async function handler(
|
|||||||
} 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,25 +6,26 @@ 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 async function handler(
|
export default withRateLimit(
|
||||||
req: NextApiRequest,
|
{ points: 100, duration: 60 },
|
||||||
res: NextApiResponse,
|
async (req: NextApiRequest, 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,9 +1,11 @@
|
|||||||
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";
|
||||||
|
|
||||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
export default withRateLimit(
|
||||||
res.status(200).send(openApiDocument);
|
{ points: 100, duration: 60 },
|
||||||
};
|
(req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
res.status(200).send(openApiDocument);
|
||||||
export default handler;
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import ApiSettings from "~/views/settings/ApiSettings";
|
import ApiSettings from "~/views/settings/ApiSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="api">
|
<SettingsLayout currentTab="api">
|
||||||
<ApiSettings />
|
<ApiSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import BillingSettings from "~/views/settings/BillingSettings";
|
import BillingSettings from "~/views/settings/BillingSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="billing">
|
<SettingsLayout currentTab="billing">
|
||||||
<BillingSettings />
|
<BillingSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="integrations">
|
<SettingsLayout currentTab="integrations">
|
||||||
<IntegrationsSettings />
|
<IntegrationsSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
23
apps/web/src/pages/settings/permissions.tsx
Normal file
23
apps/web/src/pages/settings/permissions.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
import PermissionsSettings from "~/views/settings/PermissionsSettings";
|
||||||
|
|
||||||
|
const PermissionsSettingsPage: NextPageWithLayout = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsLayout currentTab="permissions">
|
||||||
|
<PermissionsSettings />
|
||||||
|
<Popup />
|
||||||
|
</SettingsLayout>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||||
|
|
||||||
|
export default PermissionsSettingsPage;
|
||||||
|
|
||||||
|
|
||||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
||||||
|
import Popup from "~/components/Popup";
|
||||||
|
|
||||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="workspace">
|
<SettingsLayout currentTab="workspace">
|
||||||
<WorkspaceSettings />
|
<WorkspaceSettings />
|
||||||
|
<Popup />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
|
|||||||
|
|
||||||
const initialAvailableWorkspaces: Workspace[] = [];
|
const initialAvailableWorkspaces: Workspace[] = [];
|
||||||
|
|
||||||
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
16
apps/web/src/utils/cardInvalidation.ts
Normal file
16
apps/web/src/utils/cardInvalidation.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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 }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
101
apps/web/src/utils/helpers.test.ts
Normal file
101
apps/web/src/utils/helpers.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("next-runtime-env", () => ({
|
||||||
|
env: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { env } from "next-runtime-env";
|
||||||
|
import { getAvatarUrl } from "./helpers";
|
||||||
|
|
||||||
|
const mockEnv = env as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe("getAvatarUrl", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for null input", () => {
|
||||||
|
expect(getAvatarUrl(null)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty string input", () => {
|
||||||
|
expect(getAvatarUrl("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns URL unchanged if already absolute http", () => {
|
||||||
|
expect(getAvatarUrl("http://example.com/avatar.jpg")).toBe(
|
||||||
|
"http://example.com/avatar.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns URL unchanged if already absolute https", () => {
|
||||||
|
expect(getAvatarUrl("https://example.com/avatar.jpg")).toBe(
|
||||||
|
"https://example.com/avatar.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path-style URLs (MinIO/LocalStack)", () => {
|
||||||
|
it("constructs path-style URL when STORAGE_DOMAIN is not set", () => {
|
||||||
|
mockEnv.mockImplementation((key: string) => {
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
NEXT_PUBLIC_STORAGE_URL: "http://s3.localtest.me:9000",
|
||||||
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan",
|
||||||
|
};
|
||||||
|
return vars[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||||
|
"http://s3.localtest.me:9000/kan/user123/avatar.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("virtual-hosted URLs (Tigris/AWS S3)", () => {
|
||||||
|
it("constructs virtual-hosted URL when USE_VIRTUAL_HOSTED_URLS is true and STORAGE_DOMAIN is set", () => {
|
||||||
|
mockEnv.mockImplementation((key: string) => {
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "true",
|
||||||
|
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||||
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||||
|
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||||
|
};
|
||||||
|
return vars[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||||
|
"https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is false even if STORAGE_DOMAIN is set", () => {
|
||||||
|
mockEnv.mockImplementation((key: string) => {
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "false",
|
||||||
|
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||||
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||||
|
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||||
|
};
|
||||||
|
return vars[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||||
|
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is not set even if STORAGE_DOMAIN is set", () => {
|
||||||
|
mockEnv.mockImplementation((key: string) => {
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev",
|
||||||
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars",
|
||||||
|
NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev",
|
||||||
|
};
|
||||||
|
return vars[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAvatarUrl("user123/avatar.jpg")).toBe(
|
||||||
|
"https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,5 +52,14 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
|||||||
return imageOrKey;
|
return imageOrKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||||
|
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,8 @@ 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 "ptbr":
|
case "pt-BR":
|
||||||
return (await import("~/locales/ptbr/messages")).messages;
|
return (await import("~/locales/pt-BR/messages")).messages;
|
||||||
default:
|
default:
|
||||||
return enMessages;
|
return enMessages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ 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";
|
||||||
@@ -15,42 +18,104 @@ export default function BoardDropdown({
|
|||||||
isTemplate,
|
isTemplate,
|
||||||
isLoading,
|
isLoading,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
workspacePublicId,
|
isFavorite,
|
||||||
|
boardName,
|
||||||
}: {
|
}: {
|
||||||
isTemplate: boolean;
|
isTemplate: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
workspacePublicId: string;
|
isFavorite?: boolean;
|
||||||
|
boardName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
return (
|
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||||
<Dropdown
|
const { showPopup } = usePopup();
|
||||||
disabled={isLoading}
|
const utils = api.useUtils();
|
||||||
items={[
|
|
||||||
...(isTemplate
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: t`Make template`,
|
|
||||||
action: () => openModal("CREATE_TEMPLATE"),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Edit board URL`,
|
|
||||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
|
||||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
|
|
||||||
{
|
const handleToggleFavorite = () => {
|
||||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
updateBoard.mutate({
|
||||||
action: () => openModal("DELETE_BOARD"),
|
boardPublicId,
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
favorite: !isFavorite,
|
||||||
},
|
});
|
||||||
]}
|
};
|
||||||
>
|
|
||||||
|
const updateBoard = api.board.update.useMutation({
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
void utils.board.all.invalidate();
|
||||||
|
void utils.board.byId.invalidate();
|
||||||
|
|
||||||
|
// Show popup notification
|
||||||
|
if (variables.favorite !== undefined) {
|
||||||
|
showPopup({
|
||||||
|
header: variables.favorite
|
||||||
|
? t`Added to favorites`
|
||||||
|
: t`Removed from favorites`,
|
||||||
|
message: variables.favorite
|
||||||
|
? t`${boardName ?? "Board"} has been added to your favorites.`
|
||||||
|
: t`${boardName ?? "Board"} has been removed from your favorites.`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Unable to update board`,
|
||||||
|
message: t`Please try again later, or contact customer support.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(isTemplate && canCreateBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Make template`,
|
||||||
|
action: () => openModal("CREATE_TEMPLATE"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!isTemplate && canEditBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Edit board URL`,
|
||||||
|
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||||
|
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: isFavorite
|
||||||
|
? t`Remove from favorites`
|
||||||
|
: t`Add to favorites`,
|
||||||
|
action: handleToggleFavorite,
|
||||||
|
icon: isFavorite ? (
|
||||||
|
<HiStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineStar className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(canDeleteBoard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||||
|
action: () => openModal("DELETE_BOARD"),
|
||||||
|
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown disabled={isLoading} items={items}>
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
HiOutlineTrash,
|
HiOutlineTrash,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import Dropdown from "~/components/Dropdown";
|
import Dropdown from "~/components/Dropdown";
|
||||||
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
|
import { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ interface ListProps {
|
|||||||
interface List {
|
interface List {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
createdBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
@@ -39,8 +44,14 @@ export default function List({
|
|||||||
setSelectedPublicListId,
|
setSelectedPublicListId,
|
||||||
}: ListProps) {
|
}: ListProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
||||||
|
const canEdit = canEditList || isCreator;
|
||||||
|
const canDrag = canEditList || isCreator;
|
||||||
|
|
||||||
const openNewCardForm = (publicListId: PublicListId) => {
|
const openNewCardForm = (publicListId: PublicListId) => {
|
||||||
|
if (!canCreateCard) return;
|
||||||
openModal("NEW_CARD");
|
openModal("NEW_CARD");
|
||||||
setSelectedPublicListId(publicListId);
|
setSelectedPublicListId(publicListId);
|
||||||
};
|
};
|
||||||
@@ -59,6 +70,7 @@ export default function List({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
|
if (!canEdit) return;
|
||||||
updateList.mutate({
|
updateList.mutate({
|
||||||
listPublicId: values.listPublicId,
|
listPublicId: values.listPublicId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
@@ -71,7 +83,12 @@ export default function List({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
|
<Draggable
|
||||||
|
key={list.publicId}
|
||||||
|
draggableId={list.publicId}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={!canDrag}
|
||||||
|
>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
key={list.publicId}
|
key={list.publicId}
|
||||||
@@ -90,41 +107,65 @@ export default function List({
|
|||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={handleSubmit(onSubmit)}
|
||||||
|
readOnly={!canEdit}
|
||||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<Tooltip
|
||||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
content={
|
||||||
onClick={() => openNewCardForm(list.publicId)}
|
!canCreateCard ? t`You don't have permission` : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<HiOutlinePlusSmall
|
<button
|
||||||
className="h-5 w-5 text-dark-900"
|
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
|
||||||
aria-hidden="true"
|
onClick={() => openNewCardForm(list.publicId)}
|
||||||
/>
|
disabled={!canCreateCard}
|
||||||
</button>
|
|
||||||
<div className="relative mr-1 inline-block">
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Add a card`,
|
|
||||||
action: () => openNewCardForm(list.publicId),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete list`,
|
|
||||||
action: handleOpenDeleteListConfirmation,
|
|
||||||
icon: (
|
|
||||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiOutlinePlusSmall
|
||||||
</Dropdown>
|
className="h-5 w-5 text-dark-900"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{(() => {
|
||||||
|
const dropdownItems = [
|
||||||
|
...(canCreateCard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Add a card`,
|
||||||
|
action: () => openNewCardForm(list.publicId),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canDeleteList || isCreator
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Delete list`,
|
||||||
|
action: handleOpenDeleteListConfirmation,
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (dropdownItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mr-1 inline-block">
|
||||||
|
<Dropdown items={dropdownItems}>
|
||||||
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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,6 +349,7 @@ export function NewCardForm({
|
|||||||
}),
|
}),
|
||||||
) ?? []
|
) ?? []
|
||||||
}
|
}
|
||||||
|
enableYouTubeEmbed={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
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 <></>;
|
||||||
|
|
||||||
@@ -22,10 +27,14 @@ const UpdateBoardSlugButton = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip
|
||||||
onClick={handleOnClick}
|
content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
|
||||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
|
||||||
>
|
>
|
||||||
|
<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"
|
||||||
@@ -41,13 +50,20 @@ 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) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
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,6 +4,8 @@ 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";
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ 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,
|
||||||
@@ -60,38 +63,47 @@ const VisibilityButton = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canEdit = canEditBoard || isAdmin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CheckboxDropdown
|
<Tooltip
|
||||||
items={[
|
content={
|
||||||
{
|
!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"
|
|
||||||
>
|
>
|
||||||
<Button
|
<CheckboxDropdown
|
||||||
variant="secondary"
|
items={[
|
||||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
{
|
||||||
disabled={isLoading || !isAdmin}
|
key: "public",
|
||||||
|
value: t`Public`,
|
||||||
|
selected: isPublic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "private",
|
||||||
|
value: t`Private`,
|
||||||
|
selected: !isPublic,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
handleSelect={(_g, i) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
setStateVisibility(isPublic ? "private" : "public");
|
||||||
|
updateBoardVisibility.mutate({
|
||||||
|
visibility: i.key as "public" | "private",
|
||||||
|
boardPublicId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
menuSpacing="md"
|
||||||
>
|
>
|
||||||
{t`Visibility`}
|
<Button
|
||||||
</Button>
|
variant="secondary"
|
||||||
</CheckboxDropdown>
|
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||||
|
disabled={isLoading || !canEdit}
|
||||||
|
>
|
||||||
|
{t`Visibility`}
|
||||||
|
</Button>
|
||||||
|
</CheckboxDropdown>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ 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";
|
||||||
@@ -55,12 +58,19 @@ 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 && openNewListForm(boardId),
|
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||||
description: t`Create new list`,
|
description: t`Create new list`,
|
||||||
group: "ACTIONS",
|
group: "ACTIONS",
|
||||||
});
|
});
|
||||||
@@ -253,14 +263,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "LIST") {
|
if (type === "LIST" && canEditList) {
|
||||||
updateListMutation.mutate({
|
updateListMutation.mutate({
|
||||||
listPublicId: draggableId,
|
listPublicId: draggableId,
|
||||||
index: destination.index,
|
index: destination.index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "CARD") {
|
if (type === "CARD" && canEditCard) {
|
||||||
updateCardMutation.mutate({
|
updateCardMutation.mutate({
|
||||||
cardPublicId: draggableId,
|
cardPublicId: draggableId,
|
||||||
|
|
||||||
@@ -372,6 +382,13 @@ 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -398,10 +415,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onBlur={handleSubmit(onSubmit)}
|
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||||
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]"
|
readOnly={!canEditBoard}
|
||||||
|
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</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">
|
||||||
@@ -424,6 +443,7 @@ 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"}
|
||||||
@@ -446,7 +466,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={createListShortcutTooltipContent}>
|
<Tooltip
|
||||||
|
content={
|
||||||
|
!canCreateList
|
||||||
|
? t`You don't have permission`
|
||||||
|
: createListShortcutTooltipContent
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall
|
<HiOutlinePlusSmall
|
||||||
@@ -455,9 +481,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (boardId) openNewListForm(boardId);
|
if (boardId && canCreateList) openNewListForm(boardId);
|
||||||
}}
|
}}
|
||||||
disabled={!boardData}
|
disabled={!boardData || !canCreateList}
|
||||||
>
|
>
|
||||||
{t`New list`}
|
{t`New list`}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -467,11 +493,17 @@ 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 className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{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" />
|
||||||
@@ -488,16 +520,25 @@ 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">
|
||||||
{t`Get started by creating a new list`}
|
{canCreateList
|
||||||
|
? t`Get started by creating a new list`
|
||||||
|
: t`No lists have been created yet`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Tooltip
|
||||||
onClick={() => {
|
content={
|
||||||
if (boardId) openNewListForm(boardId);
|
!canCreateList ? t`You don't have permission` : undefined
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
{t`Create new list`}
|
<Button
|
||||||
</Button>
|
onClick={() => {
|
||||||
|
if (boardId && canCreateList) openNewListForm(boardId);
|
||||||
|
}}
|
||||||
|
disabled={!canCreateList}
|
||||||
|
>
|
||||||
|
{t`Create new list`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
@@ -537,6 +578,7 @@ 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
|
||||||
@@ -554,13 +596,12 @@ 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 ${
|
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||||
card.publicId.startsWith(
|
"PLACEHOLDER",
|
||||||
"PLACEHOLDER",
|
)
|
||||||
)
|
? "pointer-events-none"
|
||||||
? "pointer-events-none"
|
: ""
|
||||||
: ""
|
}`}
|
||||||
}`}
|
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import Button from "~/components/Button";
|
import 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";
|
||||||
@@ -11,6 +13,14 @@ 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(
|
||||||
{
|
{
|
||||||
@@ -20,6 +30,20 @@ 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">
|
||||||
@@ -41,27 +65,69 @@ 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>
|
||||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
<Tooltip
|
||||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
content={
|
||||||
</Button>
|
!canCreateBoard ? t`You don't have permission` : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (canCreateBoard) openModal("NEW_BOARD");
|
||||||
|
}}
|
||||||
|
disabled={!canCreateBoard}
|
||||||
|
>
|
||||||
|
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
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">
|
<motion.div
|
||||||
|
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
|
||||||
|
layout
|
||||||
|
>
|
||||||
{data?.map((board) => (
|
{data?.map((board) => (
|
||||||
<Link
|
<motion.div
|
||||||
key={board.publicId}
|
key={board.publicId}
|
||||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
layout: {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
mass: 1
|
||||||
|
},
|
||||||
|
opacity: { duration: 0.2 },
|
||||||
|
scale: { duration: 0.2 }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
<Link
|
||||||
<PatternedBackground />
|
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
>
|
||||||
{board.name}
|
<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">
|
||||||
</p>
|
<PatternedBackground />
|
||||||
</div>
|
<button
|
||||||
</Link>
|
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||||
|
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||||
|
>
|
||||||
|
{board.favorite ? (
|
||||||
|
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||||
|
{board.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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";
|
||||||
@@ -17,12 +18,13 @@ 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: () => openModal("NEW_BOARD"),
|
action: () => canCreateBoard && openModal("NEW_BOARD"),
|
||||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||||
group: "ACTIONS",
|
group: "ACTIONS",
|
||||||
});
|
});
|
||||||
@@ -39,22 +41,40 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!isTemplate && (
|
{!isTemplate && (
|
||||||
<Button
|
<Tooltip
|
||||||
type="button"
|
content={
|
||||||
variant="secondary"
|
!canCreateBoard ? t`You don't have permission` : undefined
|
||||||
onClick={() => openModal("IMPORT_BOARDS")}
|
|
||||||
iconLeft={
|
|
||||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t`Import`}
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (canCreateBoard) openModal("IMPORT_BOARDS");
|
||||||
|
}}
|
||||||
|
disabled={!canCreateBoard}
|
||||||
|
iconLeft={
|
||||||
|
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t`Import`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={createModalShortcutTooltipContent}>
|
<Tooltip
|
||||||
|
content={
|
||||||
|
!canCreateBoard
|
||||||
|
? t`You don't have permission`
|
||||||
|
: createModalShortcutTooltipContent
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => openModal("NEW_BOARD")}
|
onClick={() => {
|
||||||
|
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,6 +2,7 @@ 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,
|
||||||
@@ -15,11 +16,16 @@ import {
|
|||||||
HiOutlineUserPlus,
|
HiOutlineUserPlus,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
|
||||||
import type { GetCardByIdOutput } from "@kan/api/types";
|
import type {
|
||||||
|
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 =
|
||||||
@@ -41,6 +47,7 @@ const getActivityText = ({
|
|||||||
fromTitle,
|
fromTitle,
|
||||||
toDueDate,
|
toDueDate,
|
||||||
dateLocale,
|
dateLocale,
|
||||||
|
mergedLabels,
|
||||||
}: {
|
}: {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
toTitle: string | null;
|
toTitle: string | null;
|
||||||
@@ -53,7 +60,42 @@ 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`,
|
||||||
@@ -79,12 +121,6 @@ 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>
|
||||||
@@ -281,37 +317,151 @@ 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,
|
isLoading: cardIsLoading,
|
||||||
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 { data } = authClient.useSession();
|
const { dateLocale, locale } = useLocalisation();
|
||||||
const { dateLocale } = useLocalisation();
|
const { data: sessionData } = authClient.useSession();
|
||||||
|
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">
|
||||||
{activities.map((activity, index) => {
|
{allActivities.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 === data?.user.id,
|
isSelf: activity.member?.user?.id === sessionData?.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")
|
||||||
@@ -322,11 +472,12 @@ 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 === data?.user.id}
|
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
|
||||||
isAdmin={isAdmin ?? false}
|
isAdmin={isAdmin ?? false}
|
||||||
isViewOnly={!!isViewOnly}
|
isViewOnly={!!isViewOnly}
|
||||||
/>
|
/>
|
||||||
@@ -344,6 +495,7 @@ 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,
|
||||||
@@ -351,11 +503,9 @@ const ActivityList = ({
|
|||||||
)}
|
)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
{index !== activities.length - 1 &&
|
{index !== allActivities.length - 1 && (
|
||||||
activities[index + 1]?.type !==
|
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
|
||||||
"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>
|
||||||
@@ -373,6 +523,17 @@ 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,6 +13,7 @@ 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;
|
||||||
@@ -81,7 +82,7 @@ export function AttachmentThumbnails({
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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();
|
||||||
@@ -20,7 +21,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 utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, 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,11 +1,14 @@
|
|||||||
|
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: {
|
||||||
@@ -14,13 +17,19 @@ 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();
|
||||||
@@ -62,7 +71,7 @@ export default function ChecklistItemRow({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +99,7 @@ export default function ChecklistItemRow({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +147,23 @@ export default function ChecklistItemRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
|
<div
|
||||||
|
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`}
|
||||||
>
|
>
|
||||||
@@ -164,7 +189,10 @@ 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) => commitTitle(e.target.innerHTML as string)}
|
onBlur={(e: Event) => {
|
||||||
|
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",
|
||||||
@@ -174,7 +202,9 @@ export default function ChecklistItemRow({
|
|||||||
if (viewOnly) return;
|
if (viewOnly) return;
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
commitTitle(title);
|
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
|
||||||
|
commitTitle(innerHTML);
|
||||||
|
onCreateNewItem?.();
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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,
|
||||||
@@ -48,7 +49,7 @@ export default function ChecklistNameInput({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
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";
|
||||||
@@ -34,109 +40,206 @@ export default function Checklists({
|
|||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
}: ChecklistsProps) {
|
}: ChecklistsProps) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
if (!checklists || checklists.length === 0) return null;
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
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 (
|
||||||
<div className="border-light-300 pb-4 dark:border-dark-300">
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<div>
|
<div className="border-light-300 pb-4 dark:border-dark-300">
|
||||||
{checklists.map((checklist) => {
|
<div>
|
||||||
const completedItems = checklist.items.filter(
|
{checklists.map((checklist) => {
|
||||||
(item) => item.completed,
|
const completedItems = checklist.items.filter(
|
||||||
);
|
(item) => item.completed,
|
||||||
const progress =
|
);
|
||||||
checklist.items.length > 0 && completedItems.length > 0
|
const progress =
|
||||||
? (completedItems.length / checklist.items.length) * 100
|
checklist.items.length > 0 && completedItems.length > 0
|
||||||
: 2;
|
? (completedItems.length / checklist.items.length) * 100
|
||||||
|
: 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>
|
</div>
|
||||||
)}
|
{!viewOnly && (
|
||||||
{viewOnly && (
|
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||||
<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">
|
||||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
<CircularProgress
|
||||||
<CircularProgress
|
progress={progress}
|
||||||
progress={progress}
|
size="sm"
|
||||||
size="sm"
|
className="flex-shrink-0"
|
||||||
className="flex-shrink-0"
|
/>
|
||||||
/>
|
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
{completedItems.length}/{checklist.items.length}
|
||||||
{completedItems.length}/{checklist.items.length}
|
</span>
|
||||||
</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>
|
</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>
|
||||||
|
|
||||||
|
<Droppable
|
||||||
|
droppableId={checklist.publicId}
|
||||||
|
type="CHECKLIST_ITEM"
|
||||||
|
isDropDisabled={viewOnly}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className="ml-1"
|
||||||
|
>
|
||||||
|
{checklist.items.map((item, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={item.publicId}
|
||||||
|
draggableId={item.publicId}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={viewOnly}
|
||||||
|
>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
style={{
|
||||||
|
...provided.draggableProps.style,
|
||||||
|
opacity: snapshot.isDragging ? 0.8 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChecklistItemRow
|
||||||
|
item={{
|
||||||
|
publicId: item.publicId,
|
||||||
|
title: item.title,
|
||||||
|
completed: item.completed,
|
||||||
|
}}
|
||||||
|
cardPublicId={cardPublicId}
|
||||||
|
onCreateNewItem={() =>
|
||||||
|
setActiveChecklistForm?.(checklist.publicId)
|
||||||
|
}
|
||||||
|
viewOnly={viewOnly}
|
||||||
|
dragHandleProps={provided.dragHandleProps}
|
||||||
|
isDragging={snapshot.isDragging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</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">
|
})}
|
||||||
{checklist.items.map((item) => (
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</DragDropContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ 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;
|
||||||
@@ -21,6 +24,7 @@ const Comment = ({
|
|||||||
cardPublicId,
|
cardPublicId,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
|
image,
|
||||||
isLoading,
|
isLoading,
|
||||||
createdAt,
|
createdAt,
|
||||||
comment,
|
comment,
|
||||||
@@ -33,6 +37,7 @@ 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;
|
||||||
@@ -45,6 +50,7 @@ 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,
|
||||||
@@ -55,7 +61,7 @@ const Comment = ({
|
|||||||
|
|
||||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.card.byId.refetch();
|
await invalidateCard(utils, cardPublicId);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -76,7 +82,7 @@ const Comment = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dropdownItems = [
|
const dropdownItems = [
|
||||||
...(isAuthor
|
...(isAuthor && canEditComment
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Edit comment`,
|
label: t`Edit comment`,
|
||||||
@@ -85,7 +91,7 @@ const Comment = ({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(isAuthor || isAdmin
|
...((isAuthor || canDeleteComment)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t`Delete comment`,
|
label: t`Delete comment`,
|
||||||
@@ -107,6 +113,7 @@ const Comment = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
name={name ?? ""}
|
name={name ?? ""}
|
||||||
email={email ?? ""}
|
email={email ?? ""}
|
||||||
|
imageUrl={getAvatarUrl(image) || undefined}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 DeleteChecklistConfirmation({
|
export function DeleteChecklistConfirmation({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
@@ -40,7 +41,7 @@ export function DeleteChecklistConfirmation({
|
|||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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";
|
||||||
|
|
||||||
interface DeleteCommentConfirmationProps {
|
interface DeleteCommentConfirmationProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -47,7 +48,7 @@ export function DeleteCommentConfirmation({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate(queryParams);
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,29 +5,53 @@ 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 { usePermissions } from "~/hooks/usePermissions";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
|
||||||
export default function BoardDropdown() {
|
export default function CardDropdown({
|
||||||
|
cardCreatedBy,
|
||||||
|
}: {
|
||||||
|
cardCreatedBy?: string | null;
|
||||||
|
}) {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
const { canEditCard, canDeleteCard } = usePermissions();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(canEditCard
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Add checklist`,
|
||||||
|
action: () => openModal("ADD_CHECKLIST"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canDeleteCard || isCreator
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t`Delete card`,
|
||||||
|
action: () => openModal("DELETE_CARD"),
|
||||||
|
icon: (
|
||||||
|
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown items={items}>
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Add checklist`,
|
|
||||||
action: () => openModal("ADD_CHECKLIST"),
|
|
||||||
icon: (
|
|
||||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete card`,
|
|
||||||
action: () => openModal("DELETE_CARD"),
|
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ import { HiMiniPlus } from "react-icons/hi2";
|
|||||||
import DateSelector from "~/components/DateSelector";
|
import DateSelector from "~/components/DateSelector";
|
||||||
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 DueDateSelectorProps {
|
interface DueDateSelectorProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
dueDate: Date | null | undefined;
|
dueDate: Date | null | undefined;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DueDateSelector({
|
export function DueDateSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
dueDate,
|
dueDate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
disabled = false,
|
||||||
}: DueDateSelectorProps) {
|
}: DueDateSelectorProps) {
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -61,7 +64,7 @@ export function DueDateSelector({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
await utils.board.byId.invalidate();
|
await utils.board.byId.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -104,9 +107,9 @@ export function DueDateSelector({
|
|||||||
<div className="relative flex w-full items-center text-left">
|
<div className="relative flex w-full items-center text-left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
disabled={isLoading}
|
disabled={isLoading || disabled}
|
||||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
|
className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}
|
||||||
>
|
>
|
||||||
{dueDate ? (
|
{dueDate ? (
|
||||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||||
@@ -117,7 +120,7 @@ export function DueDateSelector({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && !disabled && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
|
|||||||
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";
|
||||||
|
|
||||||
interface LabelSelectorProps {
|
interface LabelSelectorProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -16,12 +17,14 @@ interface LabelSelectorProps {
|
|||||||
leftIcon: React.ReactNode;
|
leftIcon: React.ReactNode;
|
||||||
}[];
|
}[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LabelSelector({
|
export default function LabelSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
labels,
|
labels,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
}: LabelSelectorProps) {
|
}: LabelSelectorProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
@@ -74,7 +77,7 @@ export default function LabelSelector({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,9 +95,10 @@ export default function LabelSelector({
|
|||||||
handleSelect={(_, label) => {
|
handleSelect={(_, label) => {
|
||||||
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
|
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
|
||||||
}}
|
}}
|
||||||
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||||
handleCreate={() => openModal("NEW_LABEL")}
|
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
|
||||||
createNewItemLabel={t`Create new label`}
|
createNewItemLabel={t`Create new label`}
|
||||||
|
disabled={disabled}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
{selectedLabels.length ? (
|
{selectedLabels.length ? (
|
||||||
@@ -109,7 +113,7 @@ export default function LabelSelector({
|
|||||||
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||||
<HiMiniPlus size={22} className="pr-2" />
|
<HiMiniPlus size={22} className="pr-2" />
|
||||||
{t`Add label`}
|
{t`Add label`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
|||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
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 ListSelectorProps {
|
interface ListSelectorProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -12,12 +13,14 @@ interface ListSelectorProps {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}[];
|
}[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ListSelector({
|
export default function ListSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
lists,
|
lists,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
}: ListSelectorProps) {
|
}: ListSelectorProps) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ export default function ListSelector({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,9 +79,10 @@ export default function ListSelector({
|
|||||||
index: 0,
|
index: 0,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
disabled={disabled}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||||
{selectedList?.value}
|
{selectedList?.value}
|
||||||
</div>
|
</div>
|
||||||
</CheckboxDropdown>
|
</CheckboxDropdown>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
|
|||||||
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";
|
||||||
|
|
||||||
interface MemberSelectorProps {
|
interface MemberSelectorProps {
|
||||||
cardPublicId: string;
|
cardPublicId: string;
|
||||||
@@ -18,12 +19,14 @@ interface MemberSelectorProps {
|
|||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
}[];
|
}[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberSelector({
|
export default function MemberSelector({
|
||||||
cardPublicId,
|
cardPublicId,
|
||||||
members,
|
members,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
}: MemberSelectorProps) {
|
}: MemberSelectorProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -81,7 +84,7 @@ export default function MemberSelector({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,11 +110,12 @@ export default function MemberSelector({
|
|||||||
workspaceMemberPublicId: member.key,
|
workspaceMemberPublicId: member.key,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
handleCreate={handleInviteMember}
|
handleCreate={disabled ? undefined : handleInviteMember}
|
||||||
createNewItemLabel={t`Invite member`}
|
createNewItemLabel={t`Invite member`}
|
||||||
|
disabled={disabled}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||||
{selectedMembers.length ? (
|
{selectedMembers.length ? (
|
||||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||||
{selectedMembers.map(({ value, imageUrl }) => (
|
{selectedMembers.map(({ value, imageUrl }) => (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Input from "~/components/Input";
|
|||||||
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";
|
||||||
|
|
||||||
interface NewChecklistFormInput {
|
interface NewChecklistFormInput {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -72,7 +73,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async (_data, _error, vars) => {
|
onSettled: async (_data, _error, vars) => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
|
await invalidateCard(utils, vars.cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
|
|
||||||
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 FormValues {
|
interface FormValues {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -91,7 +92,7 @@ const NewChecklistItemForm = ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await utils.card.byId.invalidate({ cardPublicId });
|
await invalidateCard(utils, cardPublicId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user