Compare commits
42 Commits
v0.5.2
...
fix/multip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
175999c88c | ||
|
|
24e283e22c | ||
|
|
07db7409cb | ||
|
|
346ddc16be | ||
|
|
f9eee53ed0 | ||
|
|
1ab4e24c96 | ||
|
|
34165b390f | ||
|
|
ef0d53db8f | ||
|
|
78b9de869f | ||
|
|
81c03b51e2 | ||
|
|
0d3d9a358f | ||
|
|
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 |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
# .git
|
||||
@@ -32,15 +32,23 @@ NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||
|
||||
# Auth config (optional)
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
|
||||
# API configuration (optional)
|
||||
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
|
||||
|
||||
# Integration providers (optional)
|
||||
TRELLO_APP_API_KEY=
|
||||
TRELLO_APP_SECRET=
|
||||
|
||||
# Redis (optional - for rate limiting)
|
||||
# If not provided, rate limiting will use in-memory storage
|
||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||
|
||||
40
.github/workflows/docker-publish.yml
vendored
40
.github/workflows/docker-publish.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
@@ -74,6 +76,42 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
# Remove 'v' prefix if present
|
||||
VERSION="${VERSION#v}"
|
||||
else
|
||||
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
|
||||
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
|
||||
if [[ -n "$GIT_DESCRIBE" ]]; then
|
||||
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
|
||||
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
|
||||
# Format as tag+commit (SemVer build metadata)
|
||||
TAG_VERSION="${BASH_REMATCH[1]}"
|
||||
COMMIT_HASH="${BASH_REMATCH[2]}"
|
||||
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
|
||||
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||
# Exactly on a tag
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# Fallback: just commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
else
|
||||
# No tags exist, use commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
@@ -86,6 +124,8 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
315
AGENTS.md
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
|
||||
15
README.md
15
README.md
@@ -47,7 +47,17 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Self Hosting 🐳
|
||||
|
||||
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||
### One-click Deployments
|
||||
|
||||
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
|
||||
|
||||
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
||||
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
||||
</a>
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
|
||||
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
|
||||
@@ -141,6 +151,7 @@ pnpm dev
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
@@ -150,6 +161,7 @@ pnpm dev
|
||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
@@ -171,6 +183,7 @@ pnpm dev
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
|
||||
@@ -19,43 +19,45 @@ RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
||||
# RUN export VERSION=$(npm run version)
|
||||
|
||||
ARG PROJECT
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# It might be the path to <ROOT> turborepo
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
|
||||
# Generate version from git (fallback if APP_VERSION not provided)
|
||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
||||
sed 's/^v//' || \
|
||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
|
||||
# Environment to skip .env validation on build
|
||||
ENV CI=true
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile and package.json's of isolated subworkspace
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
ENV CI=true
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
|
||||
RUN pnpm build --filter=${PROJECT}
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
|
||||
@@ -5,6 +5,8 @@ checksums:
|
||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20has%20been%20added%20to%20your%20favorites./singular": 0f752180183c80703b3f9f39dc97195f
|
||||
"%7B0%7D%20has%20been%20removed%20from%20your%20favorites./singular": 26e4b4997ad8a711d0b3e46337799454
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
@@ -27,6 +29,7 @@ checksums:
|
||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||
Add%20to%20favorites/singular: 532155acb0a46f2b6c37e15afb3ad21e
|
||||
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
|
||||
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
||||
@@ -35,10 +38,14 @@ checksums:
|
||||
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
|
||||
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
|
||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||
Added%20to%20favorites/singular: 848046ec65d15913112b466c544722eb
|
||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
||||
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
||||
Admin/singular: 90eb20f1400db82ab874744e47836dc6
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20member%20permission%20overrides%20have%20been%20reset%20to%20their%20role%20defaults./singular: e5c38724a283373506d53afd22b6d096
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
||||
@@ -87,6 +94,30 @@ checksums:
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
Can%20add%20comments/singular: d7d70b75780156312701f4c5eed1b285
|
||||
Can%20create%20boards/singular: 8538236f8caafd4eaa751b4ec45f1699
|
||||
Can%20create%20cards/singular: dd1cce2d52e0fb261676750bfc46da22
|
||||
Can%20create%20lists/singular: 53c3c1343efff494640b1227e4444de2
|
||||
Can%20delete%20boards/singular: 818ff50f9f21d8ed3741a7933d66b794
|
||||
Can%20delete%20cards/singular: c62309a7e52958aa4bdb477c07d1855d
|
||||
Can%20delete%20comments/singular: 99884fcaf89c47aa33c6ddfd9f94ea5f
|
||||
Can%20delete%20lists/singular: 8d6ff6cd43c6b9fcbc9fc7954d19409e
|
||||
Can%20delete%20workspace/singular: 41bd5a6636500b1a6e04184e6f566198
|
||||
Can%20edit%20boards/singular: 8b77fabfd955d5507b1826dc4a5c1108
|
||||
Can%20edit%20cards/singular: 47e6c92c1056fd6e8b517ba456b3f607
|
||||
Can%20edit%20comments/singular: a0caeec2b2b64e9f371f07dabed5733d
|
||||
Can%20edit%20lists/singular: 1ce5feb15139c881b615edab065b3e12
|
||||
Can%20edit%20member%20roles%20and%20permissions/singular: c364e8e8866111a471f4081db0730049
|
||||
Can%20edit%20workspace/singular: 9768f990844e215c06910fed250da23a
|
||||
Can%20invite%20members/singular: e02e562bcb7f46008d9595e485951413
|
||||
Can%20manage%20workspace%20settings/singular: 27eb18d3be5b3d813996b7d5071e0317
|
||||
Can%20remove%20members/singular: 97a452b0fb4b661eaca7e5b3d5b49dcd
|
||||
Can%20view%20boards/singular: 14977bbbb566f72fc74e17d99bad09bb
|
||||
Can%20view%20cards/singular: 5e728083853948c9d618f2276732d5cd
|
||||
Can%20view%20comments/singular: 76dbbc9ae4bff589d391c67c84ac6470
|
||||
Can%20view%20lists/singular: e36331c4a49f376befc9f0aa42492b01
|
||||
Can%20view%20members/singular: 5b75a467257a1db29d466c0d9ccea3df
|
||||
Can%20view%20workspace/singular: 79a12c55fcd04ea69cbb85b906794e9b
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
@@ -98,6 +129,9 @@ checksums:
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
Clear%20all%20custom%20permissions%3F/singular: 31d0962985c83a29558c98a765bb1b16
|
||||
Clear%20any%20custom%20member%20permissions%20so%20that%20all%20members%20only%20inherit%20permissions%20from%20their%20role%20defaults./singular: e493291818059bfd51f2c87b938616c4
|
||||
Clear%20custom%20permissions/singular: 288ce8688fd09c5a01eda5a6ba4bc98a
|
||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||
@@ -112,6 +146,7 @@ checksums:
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
||||
Configure%20which%20actions%20are%20allowed%20for%20each%20workspace%20role.%20These%20permissions%20apply%20to%20all%20members%20with%20that%20role./singular: 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
|
||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||
@@ -123,6 +158,7 @@ checksums:
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
|
||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
@@ -144,6 +180,7 @@ checksums:
|
||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20permissions/singular: 6f1748601979e2e4548877b292b43a20
|
||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||
@@ -183,14 +220,19 @@ checksums:
|
||||
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
|
||||
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
|
||||
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
Edit%20permissions/singular: 244558dd716491b7ed72ba8ab73aa28f
|
||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
|
||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
||||
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
@@ -220,6 +262,7 @@ checksums:
|
||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
||||
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
||||
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
||||
@@ -255,6 +298,7 @@ checksums:
|
||||
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
||||
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
||||
Guest/singular: 2aec6d6ebe0d9a1db0a5c8cd5a98b8c3
|
||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
@@ -316,6 +360,7 @@ checksums:
|
||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
||||
Loading%20permissions.../singular: a5665279d4e439186825057c32d4d976
|
||||
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||
@@ -327,6 +372,7 @@ checksums:
|
||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
Member/singular: 1606dc30b369856b9dba1fe9aec425d2
|
||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||
@@ -359,7 +405,9 @@ checksums:
|
||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
No%20lists%20have%20been%20created%20yet/singular: f18ee3d7230cc33b68bd17b429d1d442
|
||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||
No%20roles%20found%20for%20this%20workspace%20yet./singular: 2eb502333aaf6c58e8ab23d881e2e357
|
||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
@@ -372,6 +420,7 @@ checksums:
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
||||
Overrides%20cleared/singular: f2e380efbae31a6113cc0cbf0eadf7b0
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
@@ -384,6 +433,10 @@ checksums:
|
||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Permission/singular: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
Permissions/singular: 2160be68b1d6b6577e64634e9feba2ed
|
||||
Permissions%20reset/singular: dd4776f04aca858deb95887e807570fc
|
||||
Permissions%20updated/singular: 0df44570b783b8b284610da8627d333d
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||
@@ -391,6 +444,7 @@ checksums:
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||
@@ -411,22 +465,26 @@ checksums:
|
||||
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
|
||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
Remove%20from%20favorites/singular: 74d9cc2d2f3c7a7dc42a7e62c8ee06d5
|
||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
|
||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||
Removed%20from%20favorites/singular: 540609ab686ed67b1cfd7649467b8bc1
|
||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
||||
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
|
||||
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
||||
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Reset%20to%20role%20defaults/singular: fc9ff8ea0503e50da3e9b3e5d8bb75dd
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
Role%20updated/singular: 73606ae9c35101f1bb518961f68559d0
|
||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
@@ -448,6 +506,7 @@ checksums:
|
||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||
Settings%20%7C%20Permissions/singular: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
@@ -482,6 +541,8 @@ checksums:
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
||||
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
||||
The%20member's%20permissions%20have%20been%20updated./singular: 6ae91be2c5c0504bf521335094c50fa4
|
||||
The%20member's%20role%20has%20been%20updated./singular: e30c30a2beaac7d046c35f628102f83b
|
||||
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
|
||||
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||
@@ -491,11 +552,14 @@ checksums:
|
||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
||||
This%20member's%20permissions%20have%20been%20reset%20to%20their%20role%20defaults./singular: 730f33b8f1fc002c4f393ccd262b6664
|
||||
This%20will%20remove%20all%20custom%20member%20permissions%20in%20this%20workspace.%20Members%20will%20inherit%20permissions%20only%20from%20their%20roles./singular: 1c989752736fa41ecdd68ea890f16a15
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||
Title/singular: 344e64395eaff6822a57d18623853e1a
|
||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
||||
@@ -507,6 +571,7 @@ checksums:
|
||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
|
||||
Unable%20to%20clear%20overrides/singular: dfeac280858332082ad34d2623a59863
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
@@ -519,7 +584,9 @@ checksums:
|
||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
|
||||
Unable%20to%20reset%20permissions/singular: fb09d88ff4eb32afb852d733bafd515b
|
||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Unable%20to%20update%20board/singular: 62899897d7bffcb1df3af77d460f0881
|
||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
||||
@@ -530,6 +597,8 @@ checksums:
|
||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||
Unable%20to%20update%20permissions/singular: 134f20c5f2463167509562b284ef1c61
|
||||
Unable%20to%20update%20role/singular: 4f2240aeff6f7275feb33ca3f608e48c
|
||||
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||
@@ -588,6 +657,7 @@ checksums:
|
||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||
Workspace%20permissions/singular: 72c0202f30e543eb81bf930d85647096
|
||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
||||
@@ -598,10 +668,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%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
|
||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
You%20don't%20have%20permission/singular: 11d928b1993d95d54a95f85f8ae5016d
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
|
||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
||||
You%20need%20to%20be%20an%20admin%20to%20manage%20workspace%20permissions./singular: e2e816f2b7a13b1056d79e7f25088664
|
||||
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
@@ -617,3 +689,4 @@ checksums:
|
||||
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
||||
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
||||
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
||||
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
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",
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -50,6 +50,10 @@ const config = {
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --",
|
||||
"lingui:extract": "lingui extract",
|
||||
@@ -18,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/lib-storage": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
@@ -36,6 +39,7 @@
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-mention": "^3.0.9",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/extension-typography": "^3.18.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
"@tiptap/starter-kit": "^2.14.0",
|
||||
@@ -45,6 +49,7 @@
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.26.2",
|
||||
"geist": "^1.3.1",
|
||||
"jose": "^6.1.2",
|
||||
"next": "15.5.9",
|
||||
@@ -86,7 +91,8 @@
|
||||
"jiti": "^1.21.6",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
||||
socialProviders?.length === 0 && (
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||
@@ -371,65 +371,62 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{isSignUp && isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
{...register("name", { required: true })}
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.email && (
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
@@ -441,8 +438,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
||||
icon?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const initials = name
|
||||
const initials = name?.trim()
|
||||
? getInitialsFromName(name)
|
||||
: inferInitialsFromEmail(email);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
|
||||
handleEdit?: (key: string) => void;
|
||||
handleCreate?: () => void;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CheckboxDropdown({
|
||||
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
asChild = true,
|
||||
disabled = false,
|
||||
}: CheckboxDropdownProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
|
||||
@@ -58,13 +60,13 @@ export default function CheckboxDropdown({
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
@@ -132,7 +134,8 @@ export default function CheckboxDropdown({
|
||||
<>
|
||||
<Menu.Button
|
||||
as={asChild ? "div" : undefined}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||
disabled={disabled}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
||||
>
|
||||
{children}
|
||||
</Menu.Button>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -44,6 +45,12 @@ export default function Dashboard({
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!session?.user,
|
||||
},
|
||||
);
|
||||
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
@@ -155,8 +162,12 @@ export default function Dashboard({
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{ email: session?.user.email, image: session?.user.image }}
|
||||
isLoading={sessionLoading}
|
||||
user={{
|
||||
displayName: user?.name ?? session?.user.name,
|
||||
email: user?.email ?? session?.user.email ?? "",
|
||||
image: user?.image ?? undefined,
|
||||
}}
|
||||
isLoading={sessionLoading || userLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -30,13 +30,14 @@ export default function Dropdown({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.label}>
|
||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||
<button
|
||||
onClick={item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
disabled={item.disabled ?? !item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Range as TiptapRange } from "@tiptap/core";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
ReactRenderer,
|
||||
useEditor,
|
||||
} from "@tiptap/react";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
@@ -44,6 +46,7 @@ import { Markdown } from "tiptap-markdown";
|
||||
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Avatar from "./Avatar";
|
||||
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -56,7 +59,7 @@ declare module "@tiptap/core" {
|
||||
export interface SlashCommandItem {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -431,12 +434,14 @@ export default function Editor({
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -484,10 +489,17 @@ export default function Editor({
|
||||
}),
|
||||
);
|
||||
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) => {
|
||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||
command: ({ editor, range, props }) => {
|
||||
const id = props.id ?? "";
|
||||
const label = props.label ?? "";
|
||||
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span> `;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
@@ -503,6 +515,18 @@ export default function Editor({
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
Typography.configure({
|
||||
openDoubleQuote: false,
|
||||
closeDoubleQuote: false,
|
||||
openSingleQuote: false,
|
||||
closeSingleQuote: false,
|
||||
oneHalf: false,
|
||||
oneQuarter: false,
|
||||
threeQuarters: false,
|
||||
superscriptTwo: false,
|
||||
superscriptThree: false,
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||
@@ -559,6 +583,9 @@ export default function Editor({
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tiptap [data-youtube] {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
`}</style>
|
||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
|
||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
||||
>
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<Transition
|
||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4">
|
||||
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4 relative">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{popupIcon === "success" && (
|
||||
<HiOutlineCheckCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-green-400"
|
||||
className="h-5 w-5 text-green-400"
|
||||
/>
|
||||
)}
|
||||
{popupIcon === "error" && (
|
||||
<HiOutlineExclamationCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-red-400"
|
||||
className="h-5 w-5 text-red-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
||||
{popupHeader}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
||||
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
||||
{popupMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-shrink-0">
|
||||
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
||||
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<HiXMark
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-dark-900"
|
||||
className="h-4 w-4 text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,8 @@ const Button: React.FC<{
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onClick={handleClick}
|
||||
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
|
||||
? "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",
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
HiOutlineBanknotes,
|
||||
HiOutlineCodeBracketSquare,
|
||||
HiOutlineRectangleGroup,
|
||||
HiOutlineShieldCheck,
|
||||
HiOutlineUser,
|
||||
} from "react-icons/hi2";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -24,8 +27,12 @@ interface SettingsLayoutProps {
|
||||
|
||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
const router = useRouter();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
|
||||
const isAdmin = workspace.role === "admin";
|
||||
|
||||
const settingsTabs = [
|
||||
{
|
||||
key: "account",
|
||||
@@ -37,13 +44,19 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
key: "workspace",
|
||||
icon: <HiOutlineRectangleGroup />,
|
||||
label: t`Workspace`,
|
||||
condition: true,
|
||||
condition: canViewWorkspace,
|
||||
},
|
||||
{
|
||||
key: "permissions",
|
||||
icon: <HiOutlineShieldCheck />,
|
||||
label: t`Permissions`,
|
||||
condition: isAdmin,
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: t`Billing`,
|
||||
icon: <HiOutlineBanknotes />,
|
||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
@@ -55,7 +68,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
key: "integrations",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
label: t`Integrations`,
|
||||
condition: true,
|
||||
condition: canEditWorkspace,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -97,7 +110,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
||||
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
|
||||
<HiChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||
|
||||
@@ -39,6 +39,7 @@ interface SideNavigationProps {
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
displayName?: string | null | undefined;
|
||||
email?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
}
|
||||
@@ -54,9 +55,10 @@ export default function SideNavigation({
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
@@ -206,7 +208,8 @@ export default function SideNavigation({
|
||||
|
||||
<div className="space-y-2">
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
displayName={user.displayName ?? undefined}
|
||||
email={user.email ?? "Email not provided?"}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
@@ -6,16 +6,20 @@ const Toggle = ({
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
showLabel = true,
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
|
||||
|
||||
interface TooltipProps {
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
content?: ReactNode;
|
||||
placement?: Placement;
|
||||
delay?: number | [number, number];
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export function Tooltip({
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
if (!content) return;
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
rootRef.current = root;
|
||||
@@ -35,6 +37,7 @@ export function Tooltip({
|
||||
delay,
|
||||
interactive: false,
|
||||
theme: "tooltip",
|
||||
touch: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -16,6 +17,7 @@ import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
displayName: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
isCollapsed?: boolean;
|
||||
@@ -25,6 +27,7 @@ interface UserMenuProps {
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
displayName,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
@@ -74,7 +77,7 @@ export default function UserMenu({
|
||||
) : (
|
||||
<Menu.Button
|
||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||
title={isCollapsed ? email : undefined}
|
||||
title={isCollapsed ? (displayName || email) : undefined}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
@@ -101,7 +104,7 @@ export default function UserMenu({
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{email}
|
||||
{displayName || email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
@@ -225,6 +228,25 @@ export default function UserMenu({
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_APP_VERSION && (
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href={
|
||||
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
||||
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
||||
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
||||
>
|
||||
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
|
||||
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";
|
||||
isVisible?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const Modal: React.FC<Props> = ({
|
||||
@@ -17,6 +18,7 @@ const Modal: React.FC<Props> = ({
|
||||
positionFromTop = "md",
|
||||
isVisible,
|
||||
closeOnClickOutside,
|
||||
centered = false,
|
||||
}) => {
|
||||
const {
|
||||
isOpen,
|
||||
@@ -60,7 +62,7 @@ const Modal: React.FC<Props> = ({
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -71,7 +73,7 @@ const Modal: React.FC<Props> = ({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -78,6 +78,7 @@ export const env = createEnv({
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -95,6 +96,14 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
@@ -132,6 +141,9 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
|
||||
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||
|
||||
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,
|
||||
ru,
|
||||
pl,
|
||||
ptbr: ptBR,
|
||||
"pt-BR": ptBR,
|
||||
};
|
||||
|
||||
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",
|
||||
"ru",
|
||||
"pl",
|
||||
"ptbr"
|
||||
"pt-BR"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
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 { createDrizzleClient } from "@kan/db/client";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
export const auth = initAuth(createDrizzleClient());
|
||||
|
||||
export default toNodeHandler(auth.handler);
|
||||
const authHandler = toNodeHandler(auth.handler);
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req, res) => {
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -44,4 +45,5 @@ export default async function handler(
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -20,4 +21,5 @@ export default async function handler(
|
||||
console.error("Error fetching OSS friends:", error);
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
@@ -31,4 +31,5 @@ export default async function handler(
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
@@ -21,10 +22,9 @@ interface CheckoutSessionRequest {
|
||||
stripeCustomerId: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
@@ -115,4 +115,5 @@ export default async function handler(
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating checkout session" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -48,4 +48,5 @@ export default async function handler(
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -3,12 +3,14 @@ import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
|
||||
import { appRouter } from "@kan/api/root";
|
||||
import { createTRPCContext } from "@kan/api/trpc";
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const nextApiHandler = createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === "development"
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
@@ -17,11 +19,16 @@ const nextApiHandler = createNextApiHandler({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(200);
|
||||
return res.end();
|
||||
}
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
return nextApiHandler(req, res);
|
||||
}
|
||||
const result = await nextApiHandler(req, res);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const requestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -19,10 +20,9 @@ type ResponseData =
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>,
|
||||
) {
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -101,4 +101,5 @@ export default async function handler(
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
131
apps/web/src/pages/api/upload/attachment.ts
Normal file
131
apps/web/src/pages/api/upload/attachment.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
|
||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
||||
}
|
||||
|
||||
const cardPublicId = req.query.cardPublicId;
|
||||
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
|
||||
return res.status(400).json({ error: "Invalid cardPublicId" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
// Get card and check permissions
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
db,
|
||||
cardPublicId,
|
||||
);
|
||||
|
||||
if (!card) {
|
||||
return res.status(404).json({ error: "Card not found" });
|
||||
}
|
||||
|
||||
// Check if user has permission to edit the card
|
||||
try {
|
||||
await assertPermission(db, user.id, card.workspaceId, "card:edit");
|
||||
} catch {
|
||||
return res.status(403).json({ error: "Permission denied" });
|
||||
}
|
||||
|
||||
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
},
|
||||
leavePartsOnError: false,
|
||||
});
|
||||
|
||||
await upload.done();
|
||||
|
||||
// Create attachment record and log activity
|
||||
const attachment = await cardAttachmentRepo.create(db, {
|
||||
cardId: card.id,
|
||||
filename: sanitizedFilename,
|
||||
originalFilename: originalFilenameHeader,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
s3Key,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
return res.status(200).json({ attachment });
|
||||
} catch (error) {
|
||||
console.error("Attachment upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
101
apps/web/src/pages/api/upload/avatar.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Avatar bucket not configured" });
|
||||
}
|
||||
|
||||
const contentType = req.headers["content-type"];
|
||||
const contentLengthHeader = req.headers["content-length"];
|
||||
const contentLength = contentLengthHeader
|
||||
? Number.parseInt(contentLengthHeader, 10)
|
||||
: NaN;
|
||||
|
||||
if (typeof contentType !== "string") {
|
||||
return res.status(400).json({ error: "Missing content type" });
|
||||
}
|
||||
|
||||
if (!allowedContentTypes.includes(contentType)) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.substring(0, 200);
|
||||
|
||||
const s3Key = `${user.id}/${sanitizedFilename}`;
|
||||
|
||||
const client = createS3Client();
|
||||
|
||||
// Upload the file to S3
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: s3Key,
|
||||
Body: req,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
);
|
||||
|
||||
// Update user image in database
|
||||
const updatedUser = await userRepo.update(db, user.id, {
|
||||
image: s3Key,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
key: s3Key,
|
||||
filename: sanitizedFilename,
|
||||
contentType,
|
||||
size: contentLength,
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Avatar upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env as nextRuntimeEnv } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const allowedContentTypes = ["image/jpeg", "image/png"];
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const { filename, contentType } = req.body as {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
// Specific to avatar uploads for now
|
||||
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
|
||||
|
||||
if (!filenameRegex.test(filename)) {
|
||||
return res.status(400).json({ error: "Invalid filename" });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof contentType !== "string" ||
|
||||
!allowedContentTypes.includes(contentType)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid content type" });
|
||||
}
|
||||
|
||||
const credentials =
|
||||
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const client = new S3Client({
|
||||
region: env.S3_REGION ?? "",
|
||||
endpoint: env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials,
|
||||
});
|
||||
|
||||
const signedUrl = await getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
ACL: "public-read",
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({ url: signedUrl, key: filename });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,26 @@ import { appRouter } from "@kan/api";
|
||||
import { createRESTContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await cors(req, res);
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
await cors(req, res);
|
||||
|
||||
const openApiHandler = createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createRESTContext,
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const openApiHandler = createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createRESTContext,
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return await openApiHandler(req, res);
|
||||
}
|
||||
return await openApiHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { openApiDocument } from "@kan/api/openapi";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(200).send(openApiDocument);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
(req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(200).send(openApiDocument);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import ApiSettings from "~/views/settings/ApiSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="api">
|
||||
<ApiSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import BillingSettings from "~/views/settings/BillingSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="billing">
|
||||
<BillingSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="integrations">
|
||||
<IntegrationsSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
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 { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
||||
import Popup from "~/components/Popup";
|
||||
|
||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="workspace">
|
||||
<WorkspaceSettings />
|
||||
<Popup />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
@@ -464,19 +465,37 @@ function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
||||
? stroke.modifiers.map(stringifyModifier)
|
||||
: [];
|
||||
|
||||
modifierStrings.forEach((mod) => {
|
||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
||||
modifierStrings.forEach((mod, index) => {
|
||||
parts.push(
|
||||
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
|
||||
{mod}
|
||||
</kbd>,
|
||||
);
|
||||
});
|
||||
|
||||
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
||||
parts.push(
|
||||
<kbd key={`key-${stroke.key}`} className={kbdClassName}>
|
||||
{stroke.key.toUpperCase()}
|
||||
</kbd>,
|
||||
);
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
if (shortcut.type === "SEQUENCE") {
|
||||
const parts: ReactNode[] = [];
|
||||
shortcut.strokes.forEach((stroke) => {
|
||||
parts.push(...formatStroke(stroke));
|
||||
shortcut.strokes.forEach((stroke, strokeIndex) => {
|
||||
const strokeParts = formatStroke(stroke);
|
||||
// Add stroke index to keys to ensure uniqueness across multiple strokes
|
||||
const keyedParts = strokeParts.map((part, partIndex) => {
|
||||
if (React.isValidElement(part)) {
|
||||
return React.cloneElement(part, {
|
||||
key: `stroke-${strokeIndex}-${part.key || partIndex}`,
|
||||
});
|
||||
}
|
||||
return part;
|
||||
});
|
||||
parts.push(...keyedParts);
|
||||
});
|
||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const initialWorkspace: Workspace = {
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
|
||||
const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export async function invalidateCard(
|
||||
utils: ReturnType<typeof api.useUtils>,
|
||||
cardPublicId: string,
|
||||
) {
|
||||
if (!cardPublicId || cardPublicId.length < 12) return;
|
||||
|
||||
await Promise.all([
|
||||
utils.card.byId.invalidate({ cardPublicId }),
|
||||
utils.card.getActivities.invalidate({ cardPublicId }),
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const formatToArray = (
|
||||
value: string | string[] | undefined,
|
||||
): string[] => {
|
||||
@@ -52,5 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
|
||||
return (await import("~/locales/ru/messages")).messages;
|
||||
case "pl":
|
||||
return (await import("~/locales/pl/messages")).messages;
|
||||
case "ptbr":
|
||||
return (await import("~/locales/ptbr/messages")).messages;
|
||||
case "pt-BR":
|
||||
return (await import("~/locales/pt-BR/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
HiLink,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
HiOutlineStar,
|
||||
HiStar,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -15,42 +18,104 @@ export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
boardPublicId,
|
||||
workspacePublicId,
|
||||
isFavorite,
|
||||
boardName,
|
||||
}: {
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
workspacePublicId: string;
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
...(isTemplate
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]),
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
void utils.board.all.invalidate();
|
||||
void utils.board.byId.invalidate();
|
||||
|
||||
// Show popup notification
|
||||
if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
? t`Added to favorites`
|
||||
: t`Removed from favorites`,
|
||||
message: variables.favorite
|
||||
? t`${boardName ?? "Board"} has been added to your favorites.`
|
||||
: t`${boardName ?? "Board"} has been removed from your favorites.`,
|
||||
icon: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update board`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const items = [
|
||||
...(isTemplate && canCreateBoard
|
||||
? [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
? t`Remove from favorites`
|
||||
: t`Add to favorites`,
|
||||
action: handleToggleFavorite,
|
||||
icon: isFavorite ? (
|
||||
<HiStar className="h-[16px] w-[16px] text-dark-900" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
...(canDeleteBoard
|
||||
? [
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown disabled={isLoading} items={items}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -23,6 +27,7 @@ interface ListProps {
|
||||
interface List {
|
||||
publicId: string;
|
||||
name: string;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
@@ -39,8 +44,14 @@ export default function List({
|
||||
setSelectedPublicListId,
|
||||
}: ListProps) {
|
||||
const { openModal } = useModal();
|
||||
const { canCreateCard, canEditList, canDeleteList } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = list.createdBy && session?.user.id === list.createdBy;
|
||||
const canEdit = canEditList || isCreator;
|
||||
const canDrag = canEditList || isCreator;
|
||||
|
||||
const openNewCardForm = (publicListId: PublicListId) => {
|
||||
if (!canCreateCard) return;
|
||||
openModal("NEW_CARD");
|
||||
setSelectedPublicListId(publicListId);
|
||||
};
|
||||
@@ -59,6 +70,7 @@ export default function List({
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (!canEdit) return;
|
||||
updateList.mutate({
|
||||
listPublicId: values.listPublicId,
|
||||
name: values.name,
|
||||
@@ -71,7 +83,12 @@ export default function List({
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable key={list.publicId} draggableId={list.publicId} index={index}>
|
||||
<Draggable
|
||||
key={list.publicId}
|
||||
draggableId={list.publicId}
|
||||
index={index}
|
||||
isDragDisabled={!canDrag}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
key={list.publicId}
|
||||
@@ -90,41 +107,65 @@ export default function List({
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
readOnly={!canEdit}
|
||||
className="w-full border-0 bg-transparent px-4 pt-1 text-sm font-medium text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateCard ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
<HiOutlinePlusSmall
|
||||
className="h-5 w-5 text-dark-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div className="relative mr-1 inline-block">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add a card`,
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete list`,
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]}
|
||||
<button
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-400 disabled:opacity-60 disabled:cursor-not-allowed dark:hover:bg-dark-200"
|
||||
onClick={() => openNewCardForm(list.publicId)}
|
||||
disabled={!canCreateCard}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
<HiOutlinePlusSmall
|
||||
className="h-5 w-5 text-dark-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(() => {
|
||||
const dropdownItems = [
|
||||
...(canCreateCard
|
||||
? [
|
||||
{
|
||||
label: t`Add a card`,
|
||||
action: () => openNewCardForm(list.publicId),
|
||||
icon: (
|
||||
<HiOutlineSquaresPlus className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteList || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete list`,
|
||||
action: handleOpenDeleteListConfirmation,
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[18px] w-[18px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (dropdownItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mr-1 inline-block">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -335,7 +335,7 @@ export function NewCardForm({
|
||||
saveFormState({ ...formState, description: value });
|
||||
}}
|
||||
workspaceMembers={
|
||||
boardData?.workspace.members?.map(
|
||||
boardData?.workspace.members.map(
|
||||
(member): WorkspaceMember => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
@@ -349,6 +349,7 @@ export function NewCardForm({
|
||||
}),
|
||||
) ?? []
|
||||
}
|
||||
enableYouTubeEmbed={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiLink } from "react-icons/hi";
|
||||
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
const displayBaseUrl =
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn"
|
||||
: env("NEXT_PUBLIC_BASE_URL");
|
||||
|
||||
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||
|
||||
const pathSeparator = (
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
|
||||
);
|
||||
|
||||
const UpdateBoardSlugButton = ({
|
||||
handleOnClick,
|
||||
workspaceSlug,
|
||||
boardSlug,
|
||||
boardPublicId,
|
||||
visibility,
|
||||
isLoading,
|
||||
canEdit,
|
||||
}: {
|
||||
handleOnClick: () => void;
|
||||
workspaceSlug: string;
|
||||
boardSlug: string;
|
||||
boardPublicId: string;
|
||||
visibility: "public" | "private";
|
||||
isLoading: boolean;
|
||||
canEdit: boolean;
|
||||
}) => {
|
||||
if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -21,32 +42,54 @@ const UpdateBoardSlugButton = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !boardSlug || !boardPublicId) return <></>;
|
||||
|
||||
const isPublic = visibility === "public";
|
||||
const boardUrl = isPublic
|
||||
? `${linkBaseUrl}/${workspaceSlug}/${boardSlug}`
|
||||
: `${linkBaseUrl}/boards/${boardPublicId}`;
|
||||
|
||||
const pathSegments = isPublic
|
||||
? [displayBaseUrl, workspaceSlug, boardSlug]
|
||||
: [displayBaseUrl, "boards", boardPublicId];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleOnClick}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
<Tooltip
|
||||
content={!canEdit ? t`You don't have permission` : undefined}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn"
|
||||
: env("NEXT_PUBLIC_BASE_URL")}
|
||||
</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{workspaceSlug}</span>
|
||||
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
|
||||
<span>{boardSlug}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
<button
|
||||
onClick={canEdit ? handleOnClick : undefined}
|
||||
disabled={!canEdit || isLoading}
|
||||
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</Link>
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
{pathSegments.map((segment, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && pathSeparator}
|
||||
<span>{segment}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(boardUrl).then(
|
||||
() =>
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Board URL copied to clipboard`,
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
aria-label={t`Copy board link`}
|
||||
>
|
||||
<HiLink className="h-[13px] w-[13px]" />
|
||||
</button>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="/settings?tab=workspace"
|
||||
href="/settings/workspace"
|
||||
onClick={closeModal}
|
||||
>
|
||||
{t`Edit workspace URL`}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -29,6 +31,7 @@ const VisibilityButton = ({
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard } = usePermissions();
|
||||
const utils = api.useUtils();
|
||||
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
||||
visibility,
|
||||
@@ -60,38 +63,47 @@ const VisibilityButton = ({
|
||||
},
|
||||
});
|
||||
|
||||
const canEdit = canEditBoard || isAdmin;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
<Tooltip
|
||||
content={
|
||||
!canEdit && !isLoading ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !isAdmin}
|
||||
<CheckboxDropdown
|
||||
items={[
|
||||
{
|
||||
key: "public",
|
||||
value: t`Public`,
|
||||
selected: isPublic,
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
value: t`Private`,
|
||||
selected: !isPublic,
|
||||
},
|
||||
]}
|
||||
handleSelect={(_g, i) => {
|
||||
if (!canEdit) return;
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: i.key as "public" | "private",
|
||||
boardPublicId,
|
||||
});
|
||||
}}
|
||||
menuSpacing="md"
|
||||
>
|
||||
{t`Visibility`}
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
<Button
|
||||
variant="secondary"
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
disabled={isLoading || !canEdit}
|
||||
>
|
||||
{t`Visibility`}
|
||||
</Button>
|
||||
</CheckboxDropdown>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,9 @@ import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -55,12 +58,19 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => boardId && openNewListForm(boardId),
|
||||
action: () => boardId && canCreateList && openNewListForm(boardId),
|
||||
description: t`Create new list`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
@@ -253,14 +263,14 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "LIST") {
|
||||
if (type === "LIST" && canEditList) {
|
||||
updateListMutation.mutate({
|
||||
listPublicId: draggableId,
|
||||
index: destination.index,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "CARD") {
|
||||
if (type === "CARD" && canEditCard) {
|
||||
updateCardMutation.mutate({
|
||||
cardPublicId: draggableId,
|
||||
|
||||
@@ -372,6 +382,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
sourceBoardName={boardData?.name ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -398,10 +415,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
id="name"
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||
readOnly={!canEditBoard}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||
/>
|
||||
</form>
|
||||
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
@@ -424,6 +443,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
boardPublicId={boardId ?? ""}
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
canEdit={canEditBoard}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
@@ -446,7 +468,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Tooltip content={createListShortcutTooltipContent}>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateList
|
||||
? t`You don't have permission`
|
||||
: createListShortcutTooltipContent
|
||||
}
|
||||
>
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
@@ -455,9 +483,9 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
if (boardId && canCreateList) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!boardData}
|
||||
disabled={!boardData || !canCreateList}
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
@@ -467,11 +495,17 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
isFavorite={boardData?.favorite}
|
||||
boardName={boardData?.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onMouseDown={onMouseDown}
|
||||
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
@@ -488,16 +522,25 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{t`No lists`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`Get started by creating a new list`}
|
||||
{canCreateList
|
||||
? t`Get started by creating a new list`
|
||||
: t`No lists have been created yet`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateList ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
{t`Create new list`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (boardId && canCreateList) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={!canCreateList}
|
||||
>
|
||||
{t`Create new list`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
@@ -537,6 +580,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
key={card.publicId}
|
||||
draggableId={card.publicId}
|
||||
index={index}
|
||||
isDragDisabled={!canEditCard}
|
||||
>
|
||||
{(provided) => (
|
||||
<Link
|
||||
@@ -554,13 +598,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiOutlineRectangleStack } from "react-icons/hi2";
|
||||
|
||||
import { HiOutlineRectangleStack, HiOutlineStar, HiStar } from "react-icons/hi2";
|
||||
import { motion } from "framer-motion";
|
||||
import Button from "~/components/Button";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -11,6 +13,14 @@ import { api } from "~/utils/api";
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.board.all.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading } = api.board.all.useQuery(
|
||||
{
|
||||
@@ -20,6 +30,20 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
|
||||
const handleToggleFavorite = (
|
||||
e: React.MouseEvent,
|
||||
boardPublicId: string,
|
||||
currentFavorite: boolean | undefined
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !currentFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||
@@ -41,27 +65,69 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("NEW_BOARD");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
>
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||
<motion.div
|
||||
className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3"
|
||||
layout
|
||||
>
|
||||
{data?.map((board) => (
|
||||
<Link
|
||||
<motion.div
|
||||
key={board.publicId}
|
||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
layout: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 1
|
||||
},
|
||||
opacity: { duration: 0.2 },
|
||||
scale: { duration: 0.2 }
|
||||
}}
|
||||
>
|
||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||
>
|
||||
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -17,12 +18,13 @@ import { NewBoardForm } from "./components/NewBoardForm";
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
type: "PRESS",
|
||||
stroke: { key: "C" },
|
||||
action: () => openModal("NEW_BOARD"),
|
||||
action: () => canCreateBoard && openModal("NEW_BOARD"),
|
||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
||||
group: "ACTIONS",
|
||||
});
|
||||
@@ -39,22 +41,40 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
{!isTemplate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard ? t`You don't have permission` : undefined
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("IMPORT_BOARDS");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={createModalShortcutTooltipContent}>
|
||||
<Tooltip
|
||||
content={
|
||||
!canCreateBoard
|
||||
? t`You don't have permission`
|
||||
: createModalShortcutTooltipContent
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => openModal("NEW_BOARD")}
|
||||
onClick={() => {
|
||||
if (canCreateBoard) openModal("NEW_BOARD");
|
||||
}}
|
||||
disabled={!canCreateBoard}
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { authClient } from "@kan/auth/client";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Comment from "./Comment";
|
||||
|
||||
type ActivityType =
|
||||
@@ -35,12 +36,21 @@ const truncate = (value: string | null, maxLength = 50) => {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
};
|
||||
|
||||
const getUserDisplayName = (
|
||||
user: { name?: string | null; email?: string | null } | null | undefined,
|
||||
): string => {
|
||||
if (user?.name?.trim()) return user.name;
|
||||
if (user?.email) return user.email;
|
||||
return t`Member`;
|
||||
};
|
||||
|
||||
const getActivityText = ({
|
||||
type,
|
||||
toTitle,
|
||||
fromList,
|
||||
toList,
|
||||
memberName,
|
||||
memberEmail,
|
||||
isSelf,
|
||||
label,
|
||||
fromTitle,
|
||||
@@ -53,6 +63,7 @@ const getActivityText = ({
|
||||
fromList: string | null;
|
||||
toList: string | null;
|
||||
memberName: string | null;
|
||||
memberEmail: string | null;
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
fromTitle?: string | null;
|
||||
@@ -61,6 +72,7 @@ const getActivityText = ({
|
||||
dateLocale: DateFnsLocale;
|
||||
mergedLabels?: string[];
|
||||
}) => {
|
||||
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||
<span className="font-medium text-light-1000 dark:text-dark-1000">
|
||||
{children}
|
||||
@@ -138,23 +150,23 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.added" && memberName) {
|
||||
if (type === "card.updated.member.added" && displayName) {
|
||||
if (isSelf) return <Trans>self-assigned the card</Trans>;
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
|
||||
assigned <TextHighlight>{truncate(displayName)}</TextHighlight> to the
|
||||
card
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.member.removed" && memberName) {
|
||||
if (type === "card.updated.member.removed" && displayName) {
|
||||
if (isSelf) return <Trans>unassigned themselves from the card</Trans>;
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
|
||||
unassigned <TextHighlight>{truncate(displayName)}</TextHighlight> from
|
||||
the card
|
||||
</Trans>
|
||||
);
|
||||
@@ -351,7 +363,7 @@ const ActivityList = ({
|
||||
limit: ACTIVITIES_PAGE_SIZE,
|
||||
},
|
||||
{
|
||||
enabled: !!cardPublicId,
|
||||
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -454,6 +466,7 @@ const ActivityList = ({
|
||||
fromList: activity.fromList?.name ?? null,
|
||||
toList: activity.toList?.name ?? null,
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
memberEmail: activity.member?.user?.email ?? null,
|
||||
isSelf: activity.member?.user?.id === sessionData?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
fromTitle: activity.fromTitle ?? null,
|
||||
@@ -471,6 +484,7 @@ const ActivityList = ({
|
||||
cardPublicId={cardPublicId}
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
image={activity.user?.image ?? null}
|
||||
isLoading={isLoading}
|
||||
createdAt={activity.createdAt.toISOString()}
|
||||
comment={activity.comment?.comment}
|
||||
@@ -493,6 +507,7 @@ const ActivityList = ({
|
||||
size="sm"
|
||||
name={activity.user?.name ?? ""}
|
||||
email={activity.user?.email ?? ""}
|
||||
imageUrl={getAvatarUrl(activity.user?.image ?? null) || undefined}
|
||||
icon={getActivityIcon(
|
||||
activity.type,
|
||||
activity.fromList?.index,
|
||||
@@ -505,7 +520,7 @@ const ActivityList = ({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
|
||||
<span className="font-medium dark:text-dark-1000">{`${getUserDisplayName(activity.user)} `}</span>
|
||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||
{activityText}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { env } from "next-runtime-env";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
||||
onSuccess: async () => {
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"x-original-filename": file.name,
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
showPopup({
|
||||
header: t`Attachment uploaded`,
|
||||
message: t`Your file has been uploaded successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
message: t`Failed to upload attachment. Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// Generate presigned URL
|
||||
const { url, key } = await generateUploadUrl.mutateAsync({
|
||||
cardPublicId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// Upload file to S3
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
// Confirm attachment in database
|
||||
await confirmAttachment.mutateAsync({
|
||||
cardPublicId,
|
||||
s3Key: key,
|
||||
filename: file.name,
|
||||
originalFilename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
} catch {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
|
||||
@@ -8,10 +8,12 @@ import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface FormValues {
|
||||
comment: string;
|
||||
@@ -22,6 +24,7 @@ const Comment = ({
|
||||
cardPublicId,
|
||||
name,
|
||||
email,
|
||||
image,
|
||||
isLoading,
|
||||
createdAt,
|
||||
comment,
|
||||
@@ -34,6 +37,7 @@ const Comment = ({
|
||||
cardPublicId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
isLoading: boolean;
|
||||
createdAt: string;
|
||||
comment: string | undefined;
|
||||
@@ -46,6 +50,7 @@ const Comment = ({
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { openModal } = useModal();
|
||||
const { canEditComment, canDeleteComment } = usePermissions();
|
||||
const { handleSubmit, setValue, watch } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
comment,
|
||||
@@ -77,7 +82,7 @@ const Comment = ({
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
...(isAuthor
|
||||
...(isAuthor && canEditComment
|
||||
? [
|
||||
{
|
||||
label: t`Edit comment`,
|
||||
@@ -86,7 +91,7 @@ const Comment = ({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAuthor || isAdmin
|
||||
...((isAuthor || canDeleteComment)
|
||||
? [
|
||||
{
|
||||
label: t`Delete comment`,
|
||||
@@ -108,6 +113,7 @@ const Comment = ({
|
||||
size="sm"
|
||||
name={name ?? ""}
|
||||
email={email ?? ""}
|
||||
imageUrl={getAvatarUrl(image) || undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,33 +1,94 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function BoardDropdown() {
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
export default function CardDropdown({
|
||||
cardPublicId,
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
cardCreatedBy,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
isTemplate?: boolean;
|
||||
boardPublicId?: string;
|
||||
cardCreatedBy?: string | null;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
const path =
|
||||
isTemplate && boardPublicId
|
||||
? `/templates/${boardPublicId}/cards/${cardPublicId}`
|
||||
: `/cards/${cardPublicId}`;
|
||||
const url = `${window.location.origin}${path}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Card URL copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: t`Copy card link`,
|
||||
action: handleCopyCardLink,
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteCard || isCreator
|
||||
? [
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Dropdown items={items}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -12,12 +12,14 @@ interface DueDateSelectorProps {
|
||||
cardPublicId: string;
|
||||
dueDate: Date | null | undefined;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DueDateSelector({
|
||||
cardPublicId,
|
||||
dueDate,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
@@ -105,9 +107,9 @@ export function DueDateSelector({
|
||||
<div className="relative flex w-full items-center text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={isLoading || disabled}
|
||||
className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}
|
||||
>
|
||||
{dueDate ? (
|
||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||
@@ -118,7 +120,7 @@ export function DueDateSelector({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
{isOpen && !disabled && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
||||
<div
|
||||
|
||||
@@ -17,12 +17,14 @@ interface LabelSelectorProps {
|
||||
leftIcon: React.ReactNode;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function LabelSelector({
|
||||
cardPublicId,
|
||||
labels,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: LabelSelectorProps) {
|
||||
const utils = api.useUtils();
|
||||
const { openModal } = useModal();
|
||||
@@ -93,9 +95,10 @@ export default function LabelSelector({
|
||||
handleSelect={(_, label) => {
|
||||
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
|
||||
}}
|
||||
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={() => openModal("NEW_LABEL")}
|
||||
handleEdit={disabled ? undefined : (labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
|
||||
handleCreate={disabled ? undefined : () => openModal("NEW_LABEL")}
|
||||
createNewItemLabel={t`Create new label`}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
{selectedLabels.length ? (
|
||||
@@ -110,7 +113,7 @@ export default function LabelSelector({
|
||||
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className={`flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 dark:border-dark-50 dark:text-dark-1000 ${disabled ? "cursor-not-allowed opacity-60" : "hover:border-light-300 hover:bg-light-200 dark:hover:border-dark-200 dark:hover:bg-dark-100"}`}>
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Add label`}
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,14 @@ interface ListSelectorProps {
|
||||
selected: boolean;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ListSelector({
|
||||
cardPublicId,
|
||||
lists,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: ListSelectorProps) {
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -77,9 +79,10 @@ export default function ListSelector({
|
||||
index: 0,
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 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}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -19,12 +19,14 @@ interface MemberSelectorProps {
|
||||
imageUrl: string | undefined;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MemberSelector({
|
||||
cardPublicId,
|
||||
members,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
}: MemberSelectorProps) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
@@ -108,11 +110,12 @@ export default function MemberSelector({
|
||||
workspaceMemberPublicId: member.key,
|
||||
});
|
||||
}}
|
||||
handleCreate={handleInviteMember}
|
||||
handleCreate={disabled ? undefined : handleInviteMember}
|
||||
createNewItemLabel={t`Invite member`}
|
||||
disabled={disabled}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 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 ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
@@ -15,6 +16,7 @@ interface FormValues {
|
||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { canCreateComment } = usePermissions();
|
||||
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
|
||||
values: {
|
||||
comment: "",
|
||||
@@ -46,6 +48,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!canCreateComment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Editor from "~/components/Editor";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -13,6 +15,8 @@ import LabelIcon from "~/components/LabelIcon";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
@@ -43,13 +47,19 @@ interface FormValues {
|
||||
|
||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card } = api.card.byId.useQuery({
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
const { data: card } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const board = card?.list.board;
|
||||
const labels = board?.labels;
|
||||
@@ -115,6 +125,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
lists={formattedLists}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
@@ -123,6 +134,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
labels={formattedLabels}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
@@ -132,6 +144,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -141,6 +154,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId ?? ""}
|
||||
dueDate={card?.dueDate}
|
||||
isLoading={!card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,6 +175,8 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -169,9 +185,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery({
|
||||
cardPublicId: cardId ?? "",
|
||||
});
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
const refetchCard = async () => {
|
||||
if (cardId) await utils.card.byId.refetch({ cardPublicId: cardId });
|
||||
@@ -297,7 +317,12 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown />
|
||||
<Dropdown
|
||||
cardPublicId={cardId}
|
||||
isTemplate={isTemplate}
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -325,9 +350,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<textarea
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
onBlur={canEdit ? handleSubmit(onSubmit) : undefined}
|
||||
rows={1}
|
||||
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
disabled={!canEdit}
|
||||
className={`block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem] ${!canEdit ? "cursor-default" : ""}`}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
@@ -353,9 +379,16 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
onChange={
|
||||
canEdit
|
||||
? (e) => setValue("description", e)
|
||||
: undefined
|
||||
}
|
||||
onBlur={
|
||||
canEdit ? () => handleSubmit(onSubmit)() : undefined
|
||||
}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@@ -365,6 +398,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
cardPublicId={cardId}
|
||||
activeChecklistForm={activeChecklistForm}
|
||||
setActiveChecklistForm={setActiveChecklistForm}
|
||||
viewOnly={!canEdit}
|
||||
/>
|
||||
{!isTemplate && (
|
||||
<>
|
||||
@@ -373,12 +407,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<AttachmentThumbnails
|
||||
attachments={card.attachments}
|
||||
cardPublicId={cardId ?? ""}
|
||||
isReadOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
@@ -483,6 +520,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
checklistPublicId={entityId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_YOUTUBE"}
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import type { Permission } from "@kan/shared";
|
||||
import { permissionCategories } from "@kan/shared";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function EditMemberPermissionsModal() {
|
||||
const { workspace } = useWorkspace();
|
||||
const { modalContentType, entityId, entityLabel, closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data, isLoading } = api.permission.getMemberPermissions.useQuery(
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
modalContentType === "EDIT_MEMBER_PERMISSIONS" && !!entityId,
|
||||
},
|
||||
);
|
||||
|
||||
const grantMutation = api.permission.grantPermission.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Permissions updated`,
|
||||
message: t`The member's permissions have been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update permissions`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.permission.getMemberPermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = api.permission.revokePermission.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Permissions updated`,
|
||||
message: t`The member's permissions have been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update permissions`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.permission.getMemberPermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = api.permission.resetMemberPermissions.useMutation({
|
||||
onSuccess: async () => {
|
||||
showPopup({
|
||||
header: t`Permissions reset`,
|
||||
message: t`This member's permissions have been reset to their role defaults.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
await utils.permission.getMemberPermissions.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to reset permissions`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const effectivePermissions = (data?.permissions ?? []) as Permission[];
|
||||
const hasOverrides = (data?.overrides?.length ?? 0) > 0;
|
||||
const isBusy =
|
||||
grantMutation.isPending ||
|
||||
revokeMutation.isPending ||
|
||||
resetMutation.isPending;
|
||||
|
||||
const handleToggle = (permission: Permission, nextState: boolean) => {
|
||||
if (!workspace.publicId || !entityId) return;
|
||||
|
||||
if (nextState) {
|
||||
grantMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
permission,
|
||||
});
|
||||
} else {
|
||||
revokeMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const permissionLabels: Record<Permission, string> = {
|
||||
"workspace:view": t`Can view workspace`,
|
||||
"workspace:edit": t`Can edit workspace`,
|
||||
"workspace:delete": t`Can delete workspace`,
|
||||
"workspace:manage": t`Can manage workspace settings`,
|
||||
|
||||
"board:view": t`Can view boards`,
|
||||
"board:create": t`Can create boards`,
|
||||
"board:edit": t`Can edit boards`,
|
||||
"board:delete": t`Can delete boards`,
|
||||
|
||||
"list:view": t`Can view lists`,
|
||||
"list:create": t`Can create lists`,
|
||||
"list:edit": t`Can edit lists`,
|
||||
"list:delete": t`Can delete lists`,
|
||||
|
||||
"card:view": t`Can view cards`,
|
||||
"card:create": t`Can create cards`,
|
||||
"card:edit": t`Can edit cards`,
|
||||
"card:delete": t`Can delete cards`,
|
||||
|
||||
"comment:view": t`Can view comments`,
|
||||
"comment:create": t`Can add comments`,
|
||||
"comment:edit": t`Can edit comments`,
|
||||
"comment:delete": t`Can delete comments`,
|
||||
|
||||
"member:view": t`Can view members`,
|
||||
"member:invite": t`Can invite members`,
|
||||
"member:edit": t`Can edit member roles and permissions`,
|
||||
"member:remove": t`Can remove members`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md bg-light-50 text-light-1000 dark:bg-dark-100 dark:text-dark-1000">
|
||||
<div className="px-5 pt-5">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="mb-1 text-sm font-semibold">
|
||||
{t`Edit permissions`}
|
||||
</h2>
|
||||
<p className="min-h-[16px] text-xs text-light-900 dark:text-dark-900">
|
||||
{entityLabel}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="ml-2 inline-flex h-6 w-6 items-center justify-center rounded-md text-light-900 hover:bg-light-200 focus:outline-none dark:text-dark-900 dark:hover:bg-dark-200"
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
<HiXMark className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-light-900 dark:text-dark-900">
|
||||
{t`Loading permissions...`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-80 pb-4 space-y-3 overflow-y-auto pr-1">
|
||||
{Object.values(permissionCategories).map((category, index) => (
|
||||
<div
|
||||
key={category.label}
|
||||
className={`py-2 ${
|
||||
index > 0
|
||||
? "border-t border-light-300 dark:border-dark-300"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="my-2 text-[12px] font-semibold text-light-900 dark:text-dark-950">
|
||||
{category.label}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{category.permissions.map((permission) => {
|
||||
const label =
|
||||
permissionLabels[permission] ?? (permission as string);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={permission}
|
||||
className="flex items-center justify-between gap-3 py-0.5"
|
||||
>
|
||||
<span className="text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
<Toggle
|
||||
label={label}
|
||||
showLabel={false}
|
||||
isChecked={effectivePermissions.includes(permission)}
|
||||
disabled={isBusy}
|
||||
onChange={() =>
|
||||
handleToggle(
|
||||
permission,
|
||||
!effectivePermissions.includes(permission),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!workspace.publicId || !entityId || isBusy) return;
|
||||
resetMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId: entityId,
|
||||
});
|
||||
}}
|
||||
disabled={isBusy || !hasOverrides}
|
||||
isLoading={resetMutation.isPending}
|
||||
>
|
||||
{t`Reset to role defaults`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import {
|
||||
HiBolt,
|
||||
HiChevronDown,
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlinePlusSmall,
|
||||
} from "react-icons/hi2";
|
||||
@@ -19,24 +20,55 @@ import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
|
||||
export default function MembersPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
// { enabled: workspace?.publicId ? true : false },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { canEditMember } = usePermissions();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const updateRoleMutation = api.member.updateRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
if (workspace.publicId && workspace.publicId.length >= 12) {
|
||||
await utils.workspace.byId.invalidate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
}
|
||||
|
||||
showPopup({
|
||||
header: t`Role updated`,
|
||||
message: t`The member's role has been updated.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to update role`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
||||
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
@@ -54,6 +86,7 @@ export default function MembersPage() {
|
||||
memberStatus,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
showPendingIcon,
|
||||
}: {
|
||||
memberPublicId?: string;
|
||||
memberId?: string | null | undefined;
|
||||
@@ -64,7 +97,18 @@ export default function MembersPage() {
|
||||
memberStatus?: string;
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
showPendingIcon?: boolean;
|
||||
}) => {
|
||||
const handleRoleChange = (newRole: "admin" | "member" | "guest") => {
|
||||
if (!memberPublicId) return;
|
||||
|
||||
updateRoleMutation.mutate({
|
||||
workspacePublicId: workspace.publicId,
|
||||
memberPublicId,
|
||||
role: newRole,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td
|
||||
@@ -93,20 +137,26 @@ export default function MembersPage() {
|
||||
"mr-2 truncate text-xs font-medium text-neutral-900 dark:text-dark-1000 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
showPendingIcon &&
|
||||
"italic text-neutral-500 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-xs text-dark-900 sm:text-sm",
|
||||
showSkeleton &&
|
||||
"h-3 w-[175px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberEmail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,32 +168,67 @@ export default function MembersPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-2 sm:px-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
showSkeleton &&
|
||||
<div className="flex items-center gap-2">
|
||||
{showSkeleton ? (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]",
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative inline-flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() +
|
||||
memberRole.slice(1)}
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<HiChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<select
|
||||
value={memberRole}
|
||||
onChange={(e) =>
|
||||
handleRoleChange(
|
||||
e.target.value as "admin" | "member" | "guest",
|
||||
)
|
||||
}
|
||||
disabled={updateRoleMutation.isPending}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer appearance-none border-none bg-transparent p-0 text-[10px] leading-none opacity-0 focus:outline-none focus-visible:outline-none sm:text-[11px]"
|
||||
>
|
||||
<option value="admin">{t`Admin`}</option>
|
||||
<option value="member">{t`Member`}</option>
|
||||
<option value="guest">{t`Guest`}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(memberStatus === "invited" || memberStatus === "paused") && (
|
||||
<span className="mt-1 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:ml-2 sm:mt-0 sm:text-[11px]">
|
||||
<span className="inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20 sm:text-[11px]">
|
||||
{memberStatus === "invited" ? t`Pending` : t`Paused`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative z-50",
|
||||
"relative",
|
||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||
)}
|
||||
>
|
||||
{session?.user.id !== memberId && (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit permissions`,
|
||||
action: () =>
|
||||
openModal(
|
||||
"EDIT_MEMBER_PERMISSIONS",
|
||||
memberPublicId,
|
||||
memberEmail ?? "",
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Remove member`,
|
||||
action: () =>
|
||||
@@ -157,7 +242,7 @@ export default function MembersPage() {
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900 sm:size-[25px]"
|
||||
className="text-light-900 dark:text-dark-900 sm:size-[20px]"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
@@ -246,19 +331,24 @@ export default function MembersPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
data?.members.map((member, index) => (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
/>
|
||||
))}
|
||||
data?.members.map((member, index) => {
|
||||
const isPendingInvite = member.status === "invited";
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={member.publicId}
|
||||
memberPublicId={member.publicId}
|
||||
memberId={member.user?.id}
|
||||
memberName={member.user?.name}
|
||||
memberEmail={member.user?.email ?? member.email}
|
||||
memberImage={member.user?.image}
|
||||
memberRole={member.role}
|
||||
memberStatus={member.status}
|
||||
isLastRow={index === data.members.length - 1}
|
||||
showPendingIcon={isPendingInvite}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
@@ -307,6 +397,14 @@ export default function MembersPage() {
|
||||
>
|
||||
<DeleteMemberConfirmation />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_MEMBER_PERMISSIONS"}
|
||||
centered
|
||||
>
|
||||
<EditMemberPermissionsModal />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user