Compare commits
1 Commits
docs/updat
...
fix/openap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eafe4d0cd8 |
@@ -1,62 +1,18 @@
|
|||||||
# Environment
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Docker
|
docker-compose.override.yml
|
||||||
docker-compose*.yml
|
|
||||||
|
Dockerfile
|
||||||
|
./**/*/Dockerfile
|
||||||
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
# Dependencies (rebuilt in Docker)
|
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
./**/*/node_modules
|
||||||
|
|
||||||
# Build outputs (rebuilt in Docker)
|
|
||||||
**/.next
|
|
||||||
**/dist
|
|
||||||
**/out
|
|
||||||
**/.turbo
|
|
||||||
**/.cache
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.gitattributes
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# CI/CD
|
|
||||||
.github
|
|
||||||
.husky
|
|
||||||
.changeset
|
|
||||||
|
|
||||||
# Documentation (not needed in build)
|
|
||||||
*.md
|
|
||||||
!packages/db/migrations/**
|
|
||||||
LICENSE
|
|
||||||
CONTRIBUTING.md
|
|
||||||
AGENTS.md
|
|
||||||
CHANGELOG.md
|
|
||||||
|
|
||||||
# Test files
|
|
||||||
**/*.test.ts
|
|
||||||
**/*.test.tsx
|
|
||||||
**/*.spec.ts
|
|
||||||
**/*.spec.tsx
|
|
||||||
**/__tests__
|
|
||||||
**/coverage
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
pnpm-debug.log
|
pnpm-debug.log
|
||||||
**/pnpm-debug.log
|
./**/*/pnpm-debug.log
|
||||||
|
|
||||||
# Cloud compose (separate deployment)
|
README.md
|
||||||
cloud/
|
.next
|
||||||
|
.git
|
||||||
12
.env.example
@@ -17,7 +17,6 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||||
SMTP_SECURE= # set to "false" to use port 587
|
SMTP_SECURE= # set to "false" to use port 587
|
||||||
SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
|
||||||
|
|
||||||
# Switch email features off entirely (optional)
|
# Switch email features off entirely (optional)
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||||
@@ -32,27 +31,17 @@ NEXT_PUBLIC_STORAGE_URL=
|
|||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
|
||||||
|
|
||||||
# Auth config (optional)
|
# Auth config (optional)
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
|
|
||||||
# API configuration (optional)
|
|
||||||
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
|
|
||||||
|
|
||||||
# Integration providers (optional)
|
# Integration providers (optional)
|
||||||
TRELLO_APP_API_KEY=
|
TRELLO_APP_API_KEY=
|
||||||
TRELLO_APP_SECRET=
|
TRELLO_APP_SECRET=
|
||||||
|
|
||||||
# Redis (optional - for rate limiting)
|
|
||||||
# If not provided, rate limiting will use in-memory storage
|
|
||||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
|
||||||
|
|
||||||
# OAuth providers (optional)
|
# OAuth providers (optional)
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
|
||||||
BETTER_AUTH_ALLOWED_DOMAINS=
|
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
@@ -90,3 +79,4 @@ TWITCH_CLIENT_SECRET=
|
|||||||
APPLE_CLIENT_ID=
|
APPLE_CLIENT_ID=
|
||||||
APPLE_CLIENT_SECRET=
|
APPLE_CLIENT_SECRET=
|
||||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||||
|
|
||||||
|
|||||||
90
.github/workflows/docker-publish.yml
vendored
@@ -9,30 +9,20 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "21 21 * * *"
|
- cron: "21 21 * * *"
|
||||||
push:
|
push:
|
||||||
# Only trigger on tags (releases), not main branch pushes
|
branches: ["main"]
|
||||||
# Main branch builds are handled by workflow_run after Translate completes
|
# Publish semver tags as releases.
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
workflow_run:
|
|
||||||
workflows: ["Translate"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
|
||||||
# Docker builds after Translate completes (success or failure)
|
|
||||||
# This ensures Docker always has the latest translations
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Use docker.io for Docker Hub if empty
|
# Use docker.io for Docker Hub if empty
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
# github.repository as <account>/<repo>
|
# github.repository as <account>/<repo>
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
concurrency:
|
|
||||||
group: docker-build-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -44,8 +34,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
# Install the cosign tool except on PR
|
||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
@@ -86,55 +74,6 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Extract Docker metadata (migrate)
|
|
||||||
id: meta-migrate
|
|
||||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
# Extract version from git tag or ref
|
|
||||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
|
||||||
- name: Extract version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
|
||||||
VERSION="${{ github.ref_name }}"
|
|
||||||
# Remove 'v' prefix if present
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
else
|
|
||||||
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
|
|
||||||
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
|
|
||||||
if [[ -n "$GIT_DESCRIBE" ]]; then
|
|
||||||
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
|
|
||||||
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
|
|
||||||
# Format as tag+commit (SemVer build metadata)
|
|
||||||
TAG_VERSION="${BASH_REMATCH[1]}"
|
|
||||||
COMMIT_HASH="${BASH_REMATCH[2]}"
|
|
||||||
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
|
|
||||||
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
|
||||||
# Exactly on a tag
|
|
||||||
VERSION="${BASH_REMATCH[1]}"
|
|
||||||
else
|
|
||||||
# Fallback: just commit hash
|
|
||||||
COMMIT_SHA="${{ github.sha }}"
|
|
||||||
VERSION="${COMMIT_SHA:0:7}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# No tags exist, use commit hash
|
|
||||||
COMMIT_SHA="${{ github.sha }}"
|
|
||||||
VERSION="${COMMIT_SHA:0:7}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Version: $VERSION"
|
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -147,24 +86,6 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Build and push migrate Docker image
|
|
||||||
id: build-and-push-migrate
|
|
||||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: apps/web/Dockerfile
|
|
||||||
target: migrate
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: ${{ steps.meta-migrate.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta-migrate.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
@@ -182,10 +103,3 @@ jobs:
|
|||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|
||||||
- name: Sign the published migrate Docker image
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
env:
|
|
||||||
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
|
||||||
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
|
||||||
|
|||||||
56
.github/workflows/translate.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Translate
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
translate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --filter @kan/web...
|
|
||||||
|
|
||||||
- name: Extract translations
|
|
||||||
working-directory: apps/web
|
|
||||||
run: pnpm lingui:extract
|
|
||||||
|
|
||||||
- name: Lingo.dev
|
|
||||||
uses: lingodotdev/lingo.dev@main
|
|
||||||
with:
|
|
||||||
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
|
|
||||||
commit-message: "chore: update translations"
|
|
||||||
working-directory: apps/web
|
|
||||||
commit-author-name: "${{ github.actor }}"
|
|
||||||
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
|
|
||||||
parallel: true
|
|
||||||
|
|
||||||
- name: Compile translations
|
|
||||||
working-directory: apps/web
|
|
||||||
run: pnpm lingui:compile
|
|
||||||
|
|
||||||
- name: Commit compiled translations
|
|
||||||
run: |
|
|
||||||
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
|
|
||||||
git config --local user.name "${{ github.actor }}"
|
|
||||||
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
|
|
||||||
git add apps/web/src/locales/*/messages.ts
|
|
||||||
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
|
|
||||||
fi
|
|
||||||
1
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
315
AGENTS.md
@@ -1,315 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Kan is an open-source project management tool (Trello alternative) built with:
|
|
||||||
|
|
||||||
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
|
|
||||||
- **Backend**: tRPC, Node.js
|
|
||||||
- **Database**: PostgreSQL with Drizzle ORM
|
|
||||||
- **Monorepo**: pnpm workspaces with Turbo
|
|
||||||
- **Auth**: Better Auth
|
|
||||||
- **Internationalization**: Lingui
|
|
||||||
|
|
||||||
## Setup Commands
|
|
||||||
|
|
||||||
- Install deps: `pnpm install`
|
|
||||||
- Start dev server: `pnpm dev`
|
|
||||||
- Create migrations: `cd packages/db && pnpm drizzle-kit generate --name "AddFieldToTable"`
|
|
||||||
- Run database migrations: `pnpm db:migrate`
|
|
||||||
- Run linter: `pnpm lint`
|
|
||||||
- Run type check: `pnpm typecheck`
|
|
||||||
- Format code: `pnpm format:fix`
|
|
||||||
- Extract i18n strings: `pnpm lingui:extract`
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `apps/web/` - Next.js web application
|
|
||||||
- `packages/api/` - tRPC API routers
|
|
||||||
- `packages/db/` - Database schema, migrations, and repositories
|
|
||||||
- `packages/auth/` - Authentication package
|
|
||||||
- `packages/shared/` - Shared utilities
|
|
||||||
- `packages/email/` - Email templates and sending
|
|
||||||
- `packages/stripe/` - Stripe integration
|
|
||||||
- `tooling/` - Shared tooling configs (ESLint, Prettier, TypeScript)
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- Use TypeScript strictly - avoid `any` types
|
|
||||||
- Prefer explicit types over inference when it improves clarity
|
|
||||||
- Use `as const` for literal types when appropriate
|
|
||||||
- Follow existing patterns for type definitions
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- **Files**: kebab-case for files (e.g., `card-repo.ts`)
|
|
||||||
- **Components**: PascalCase for React components
|
|
||||||
- **Functions**: camelCase for functions
|
|
||||||
- **Constants**: UPPER_SNAKE_CASE for constants
|
|
||||||
- **Types/Interfaces**: PascalCase
|
|
||||||
|
|
||||||
### Database Layer (`packages/db/`)
|
|
||||||
|
|
||||||
- **Schema**: Define schemas in `src/schema/` using Drizzle ORM
|
|
||||||
- **Migrations**: Create migrations with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
|
||||||
- **Repositories**: Put database queries in `src/repository/` files
|
|
||||||
- **Soft Deletes**: Use `deletedAt` timestamp for soft deletion (not hard deletes)
|
|
||||||
- **Index Management**: Cards have `index` fields that must be maintained sequentially per list
|
|
||||||
- **Activity Logging**: Use `card_activity` table to track all card changes
|
|
||||||
|
|
||||||
### API Layer (`packages/api/`)
|
|
||||||
|
|
||||||
- **Routers**: Create tRPC routers in `src/routers/`
|
|
||||||
- **Procedures**: Use `protectedProcedure` for authenticated endpoints, `publicProcedure` for public
|
|
||||||
- **Validation**: Use Zod schemas for input validation
|
|
||||||
- **Error Handling**: Use `TRPCError` with appropriate error codes
|
|
||||||
- **OpenAPI**: Add OpenAPI metadata for all endpoints
|
|
||||||
- **Authorization**: Always check workspace membership with `assertUserInWorkspace`
|
|
||||||
|
|
||||||
### Frontend (`apps/web/`)
|
|
||||||
|
|
||||||
- **Components**: React components in `src/components/`
|
|
||||||
- **Views**: Page-level components in `src/views/`
|
|
||||||
- **Hooks**: Custom hooks in `src/hooks/`
|
|
||||||
- **i18n**: Use `t` template literal for translations (Lingui)
|
|
||||||
- **Styling**: Use Tailwind CSS classes
|
|
||||||
- **State Management**: Use tRPC React Query hooks for server state
|
|
||||||
- **Modals**: Use `useModal` hook for modal management
|
|
||||||
- **Popups**: Use `usePopup` hook for toast notifications
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
|
|
||||||
### Cards
|
|
||||||
|
|
||||||
- Cards are the main entity in Kan
|
|
||||||
- Cards belong to Lists, which belong to Boards
|
|
||||||
- Cards have: title, description, labels, members, checklists, comments, attachments, due dates
|
|
||||||
- Cards use soft deletion (`deletedAt` field)
|
|
||||||
- Cards have an `index` field that must be maintained sequentially per list
|
|
||||||
- All card changes are tracked in `card_activity` table
|
|
||||||
|
|
||||||
### Activity Tracking
|
|
||||||
|
|
||||||
- Every significant card change creates an activity record
|
|
||||||
- Activity types include: created, updated (various fields), etc.
|
|
||||||
- Activities are displayed in card activity feeds
|
|
||||||
|
|
||||||
### Workspaces & Boards
|
|
||||||
|
|
||||||
- Users belong to Workspaces
|
|
||||||
- Boards belong to Workspaces
|
|
||||||
- Workspace members have different permission levels
|
|
||||||
- Boards can be public or private
|
|
||||||
|
|
||||||
### Soft Deletion Pattern
|
|
||||||
|
|
||||||
- Entities use `deletedAt` timestamp for soft deletion
|
|
||||||
- Queries filter with `isNull(table.deletedAt)` to exclude deleted items
|
|
||||||
|
|
||||||
## File Locations Reference
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
- Schema: `packages/db/src/schema/*.ts`
|
|
||||||
- Repositories: `packages/db/src/repository/*.repo.ts`
|
|
||||||
- Migrations: `packages/db/migrations/`
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
- Routers: `packages/api/src/routers/*.ts`
|
|
||||||
- Utils: `packages/api/src/utils/`
|
|
||||||
- Types: `packages/api/src/types/`
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- Components: `apps/web/src/components/`
|
|
||||||
- Views: `apps/web/src/views/`
|
|
||||||
- Pages: `apps/web/src/pages/`
|
|
||||||
- Hooks: `apps/web/src/hooks/`
|
|
||||||
- Utils: `apps/web/src/utils/`
|
|
||||||
- Locales: `apps/web/src/locales/`
|
|
||||||
|
|
||||||
## Database Patterns
|
|
||||||
|
|
||||||
- **Soft Deletes**: Always filter with `isNull(table.deletedAt)` in queries
|
|
||||||
- **Public IDs**: Use 12-character public IDs (`publicId`) for all user-facing entities
|
|
||||||
- **Internal IDs**: Never expose internal database IDs (e.g., `id`, `cardId`, `listId`) in API responses or URLs - always use `publicId` externally
|
|
||||||
- **Transactions**: Use database transactions for multi-step operations
|
|
||||||
- **Index Management**: When deleting/moving cards, maintain sequential indices
|
|
||||||
- **Activity Tracking**: Create activity records for all significant changes
|
|
||||||
|
|
||||||
## API Patterns
|
|
||||||
|
|
||||||
- **Input Validation**: Always validate inputs with Zod
|
|
||||||
- **Error Messages**: Provide clear, user-friendly error messages
|
|
||||||
- **Optimistic Updates**: Use tRPC's `onMutate` for optimistic UI updates
|
|
||||||
- **Cache Invalidation**: Properly invalidate queries after mutations
|
|
||||||
- **ID Exposure**: Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` for external communication
|
|
||||||
|
|
||||||
## Important Patterns
|
|
||||||
|
|
||||||
### Card Index Management
|
|
||||||
|
|
||||||
When cards are created, moved, or deleted, their indices must be maintained:
|
|
||||||
|
|
||||||
- New cards: Append to end (max index + 1) or insert at position
|
|
||||||
- Moving cards: Adjust indices of affected cards
|
|
||||||
- Deleting cards: Decrement indices of cards after deleted one
|
|
||||||
- Always use transactions for index updates
|
|
||||||
|
|
||||||
### Activity Logging
|
|
||||||
|
|
||||||
Create activity records for:
|
|
||||||
|
|
||||||
- Card creation
|
|
||||||
- Card updates (title, description, list, etc.)
|
|
||||||
- Label/member additions/removals
|
|
||||||
- Comments
|
|
||||||
- Checklists and items
|
|
||||||
- Attachments
|
|
||||||
- Due dates
|
|
||||||
|
|
||||||
### Authorization
|
|
||||||
|
|
||||||
Always check:
|
|
||||||
|
|
||||||
1. User is authenticated
|
|
||||||
2. User has access to workspace
|
|
||||||
3. User has permission for the operation
|
|
||||||
|
|
||||||
Use `assertUserInWorkspace` helper for workspace checks.
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
|
||||||
- Provide user-friendly error messages
|
|
||||||
- Log errors appropriately
|
|
||||||
- Show popup notifications for user-facing errors
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Creating a Card
|
|
||||||
|
|
||||||
1. Create card in repository with proper index management
|
|
||||||
2. Create `card.created` activity
|
|
||||||
3. Handle label/member relationships if provided
|
|
||||||
4. Return the created card
|
|
||||||
|
|
||||||
### Updating a Card
|
|
||||||
|
|
||||||
1. Validate user has workspace access
|
|
||||||
2. Update card fields
|
|
||||||
3. Create appropriate activity records
|
|
||||||
4. Invalidate relevant queries
|
|
||||||
|
|
||||||
### Querying Cards
|
|
||||||
|
|
||||||
- Always filter by `isNull(cards.deletedAt)` in queries
|
|
||||||
- Include related data (labels, members, checklists) via Drizzle relations
|
|
||||||
- Order by `index` for proper card ordering
|
|
||||||
|
|
||||||
## Adding a New Feature
|
|
||||||
|
|
||||||
1. **Database**: Update schema in `packages/db/src/schema/`
|
|
||||||
2. **Migration**: Create migration with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
|
||||||
3. **Repository**: Add repository functions in `packages/db/src/repository/`
|
|
||||||
4. **API**: Add tRPC router procedures in `packages/api/src/routers/`
|
|
||||||
5. **Frontend**: Add UI components in `apps/web/src/`
|
|
||||||
6. **i18n**: Add translations for new strings
|
|
||||||
|
|
||||||
## Database Changes
|
|
||||||
|
|
||||||
- Always create migrations (never modify existing migrations)
|
|
||||||
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
|
|
||||||
- Run migrations with: `pnpm db:migrate`
|
|
||||||
- Update schema files in `packages/db/src/schema/`
|
|
||||||
- Test migrations on development database first
|
|
||||||
- Update TypeScript types after schema changes
|
|
||||||
- Consider index management for card operations
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
- Use tRPC procedures (not REST)
|
|
||||||
- Add OpenAPI metadata for documentation
|
|
||||||
- Validate inputs with Zod
|
|
||||||
- Check workspace permissions
|
|
||||||
- Create activity records for significant changes
|
|
||||||
|
|
||||||
## Frontend Components
|
|
||||||
|
|
||||||
- Use Tailwind for styling
|
|
||||||
- Follow existing component patterns
|
|
||||||
- Use tRPC hooks for data fetching
|
|
||||||
- Implement optimistic updates where appropriate
|
|
||||||
- Add proper loading and error states
|
|
||||||
|
|
||||||
## Testing Instructions
|
|
||||||
|
|
||||||
- Test database operations in transactions that rollback
|
|
||||||
- Test authorization checks
|
|
||||||
- Test index management when moving/deleting cards
|
|
||||||
- Test activity logging
|
|
||||||
- Test UI interactions
|
|
||||||
- Run `pnpm lint` and `pnpm typecheck` before committing
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- Use database indexes appropriately
|
|
||||||
- Batch operations when possible
|
|
||||||
- Avoid N+1 queries
|
|
||||||
- Use transactions for related operations
|
|
||||||
- Implement optimistic updates in UI
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- Always check workspace membership before operations
|
|
||||||
- Validate all inputs
|
|
||||||
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
|
|
||||||
- Sanitize user input
|
|
||||||
|
|
||||||
## Internationalization
|
|
||||||
|
|
||||||
- All user-facing strings must use `t` template literal
|
|
||||||
- Add translations to locale files in `apps/web/src/locales/`
|
|
||||||
- Run `pnpm lingui:extract` to update translation files
|
|
||||||
- Update locale files for all languages
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Use workspace dependencies (`workspace:*`) for internal packages
|
|
||||||
- Keep dependencies up to date
|
|
||||||
- Use catalog for shared dependency versions
|
|
||||||
|
|
||||||
## When Implementing Features
|
|
||||||
|
|
||||||
### Adding New Card Fields
|
|
||||||
|
|
||||||
1. Update schema in `packages/db/src/schema/cards.ts`
|
|
||||||
2. Create migration
|
|
||||||
3. Update repository functions
|
|
||||||
4. Add API endpoints
|
|
||||||
5. Update frontend components
|
|
||||||
6. Add activity tracking if needed
|
|
||||||
|
|
||||||
### Adding New Activity Types
|
|
||||||
|
|
||||||
1. Add to `activityTypes` array in schema
|
|
||||||
2. Create migration to update enum
|
|
||||||
3. Use in activity creation code
|
|
||||||
4. Update activity display components if needed
|
|
||||||
|
|
||||||
## Git & Commits
|
|
||||||
|
|
||||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
|
|
||||||
- Keep commits focused on single changes
|
|
||||||
- Reference issue numbers when applicable
|
|
||||||
|
|
||||||
## PR Instructions
|
|
||||||
|
|
||||||
- Title format: `feat: description` or `fix: description`
|
|
||||||
- Always run `pnpm lint` and `pnpm typecheck` before committing
|
|
||||||
- Provide clear description of changes
|
|
||||||
- Include screenshots for UI changes
|
|
||||||
- Keep PRs focused on a single feature/fix
|
|
||||||
146
README.md
@@ -34,7 +34,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
|
|
||||||
## Screenshot 👁️
|
## Screenshot 👁️
|
||||||
|
|
||||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/8490104a-cd5d-49de-afc2-152fd8a93119" />
|
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
|
||||||
|
|
||||||
## Made With 🛠️
|
## Made With 🛠️
|
||||||
|
|
||||||
@@ -47,71 +47,39 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
|
|
||||||
## Self Hosting 🐳
|
## Self Hosting 🐳
|
||||||
|
|
||||||
### One-click Deployments
|
The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
|
||||||
|
|
||||||
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.
|
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||||
|
|
||||||
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
|
||||||
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
|
||||||
|
|
||||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
|
||||||
|
|
||||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
migrate:
|
|
||||||
image: ghcr.io/kanbn/kan-migrate:latest
|
|
||||||
container_name: kan-migrate
|
|
||||||
networks:
|
|
||||||
- kan-network
|
|
||||||
environment:
|
|
||||||
- POSTGRES_URL=${POSTGRES_URL}
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: "no"
|
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ghcr.io/kanbn/kan:latest
|
image: ghcr.io/kanbn/kan:latest
|
||||||
container_name: kan-web
|
container_name: kan-web
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-3000}:3000"
|
- "3000:3000"
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
||||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: your_auth_secret
|
||||||
- POSTGRES_URL=${POSTGRES_URL}
|
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
||||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
- postgres
|
||||||
condition: service_completed_successfully
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
container_name: kan-db
|
container_name: kan-db
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=kan_db
|
POSTGRES_DB: kan_db
|
||||||
- POSTGRES_USER=kan
|
POSTGRES_USER: kan
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: your_postgres_password
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
volumes:
|
volumes:
|
||||||
- kan_postgres_data:/var/lib/postgresql/data
|
- kan_postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- kan-network
|
- kan-network
|
||||||
@@ -123,23 +91,23 @@ volumes:
|
|||||||
kan_postgres_data:
|
kan_postgres_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the containers in detached mode:
|
2. Start the containers in detached mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
|
3. Access Kan at http://localhost:3000
|
||||||
|
|
||||||
**Managing containers:**
|
The application will be running in the background. You can manage the containers using these commands:
|
||||||
|
|
||||||
- To stop the containers: `docker compose down`
|
- To stop the containers: `docker compose down`
|
||||||
- To view logs: `docker compose logs -f`
|
- To view logs: `docker compose logs -f`
|
||||||
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
|
||||||
- To restart the containers: `docker compose restart`
|
- To restart the containers: `docker compose restart`
|
||||||
- To rebuild after code changes: `docker compose up -d --build`
|
|
||||||
|
|
||||||
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||||
|
|
||||||
|
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
||||||
|
|
||||||
## Local Development 🧑💻
|
## Local Development 🧑💻
|
||||||
|
|
||||||
@@ -170,48 +138,42 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
| `NEXT_PUBLIC_ATTATCHMENTS_BUCKET_NAME` | S3 bucket name for attatchments | For file uploads | `attatchments` |
|
||||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `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` |
|
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
|
||||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
|
||||||
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
|
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -1,121 +1,84 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
|
||||||
|
|
||||||
ARG NODE_VERSION=20
|
ARG NODE_VERSION=20
|
||||||
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
ARG APP_DIRNAME=web
|
||||||
|
ARG PROJECT=@kan/web
|
||||||
|
|
||||||
# ============================================
|
# 1. Alpine image
|
||||||
# Stage 1: Alpine base with pnpm and turbo
|
|
||||||
# ============================================
|
|
||||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update && \
|
RUN apk update
|
||||||
apk add --no-cache libc6-compat && \
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
|
||||||
corepack enable && \
|
|
||||||
npm install turbo@2.3.1 --global && \
|
|
||||||
pnpm config set store-dir ~/.pnpm-store
|
|
||||||
|
|
||||||
# ============================================
|
# Setup pnpm and turbo on the alpine base
|
||||||
# Stage 2: Prune the monorepo
|
FROM alpine AS base
|
||||||
# ============================================
|
RUN corepack enable
|
||||||
FROM alpine AS pruner
|
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||||
|
# RUN npm install turbo@2.1.3 --global
|
||||||
|
RUN npm install turbo@2.3.1 --global
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
# It might be the path to <ROOT> turborepo
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||||
|
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||||
|
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||||
|
|
||||||
|
# 3. Build the project
|
||||||
|
FROM base AS builder
|
||||||
|
ARG PROJECT
|
||||||
|
|
||||||
# Generate version from git (fallback if APP_VERSION not provided)
|
# Environment to skip .env validation on build
|
||||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
ENV CI=true
|
||||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
|
||||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
|
||||||
sed 's/^v//' || \
|
|
||||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
|
||||||
echo "unknown") && \
|
|
||||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
|
||||||
|
|
||||||
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 3: Install dependencies
|
|
||||||
# ============================================
|
|
||||||
FROM alpine AS deps
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy lockfile and package.json's of isolated subworkspace
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
|
||||||
ENV CI=true
|
# First install the dependencies (as they change less often)
|
||||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# ============================================
|
# Copy source code of isolated subworkspace
|
||||||
# Stage 4: Build the web application
|
|
||||||
# ============================================
|
|
||||||
FROM alpine AS builder
|
|
||||||
ARG APP_VERSION
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=deps /app/ ./
|
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
|
||||||
|
|
||||||
# Force standalone output for production Docker image
|
|
||||||
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
|
||||||
ENV CI=true
|
|
||||||
|
|
||||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
RUN pnpm build --filter=${PROJECT}
|
||||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
|
|
||||||
|
|
||||||
# ============================================
|
# # Copy static files to standalone directory
|
||||||
# Stage 5: Migration image (run-once container)
|
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||||
# ============================================
|
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||||
FROM node:${NODE_VERSION}-alpine AS migrate
|
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||||
WORKDIR /db
|
|
||||||
|
|
||||||
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
# 4. Final image - runner stage to run the application
|
||||||
COPY packages/db/migrations/ ./migrations/
|
FROM base AS runner
|
||||||
|
ARG APP_DIRNAME
|
||||||
|
|
||||||
|
# Don't run production as root
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
RUN npm init -y && \
|
|
||||||
npm install drizzle-kit drizzle-orm pg --save-exact && \
|
|
||||||
# Strip unnecessary files from node_modules
|
|
||||||
find node_modules -type f \( \
|
|
||||||
-name '*.d.ts' -o \
|
|
||||||
-name '*.d.mts' -o \
|
|
||||||
-name '*.d.cts' -o \
|
|
||||||
-name '*.map' -o \
|
|
||||||
-name '*.md' -o \
|
|
||||||
-name '*.txt' -o \
|
|
||||||
-name 'LICENSE*' -o \
|
|
||||||
-name 'CHANGELOG*' -o \
|
|
||||||
-name 'README*' -o \
|
|
||||||
-name '.eslint*' -o \
|
|
||||||
-name '.prettier*' -o \
|
|
||||||
-name 'tsconfig*.json' \
|
|
||||||
\) -delete && \
|
|
||||||
find node_modules -type d -empty -delete && \
|
|
||||||
rm -rf /root/.npm /tmp/*
|
|
||||||
|
|
||||||
CMD ["npx", "drizzle-kit", "migrate"]
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 6: Production web image (distroless)
|
|
||||||
# ============================================
|
|
||||||
FROM ${DISTROLESS_NODE_IMAGE} AS web
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
|
|
||||||
# Copy the standalone Next.js server
|
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
WORKDIR /app/apps/${APP_DIRNAME}
|
||||||
# Copy static assets and public files
|
|
||||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
|
||||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
|
||||||
|
|
||||||
# Copy bootstrap script for runtime env var injection
|
ARG PORT=3000
|
||||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
ENV PORT=${PORT}
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
EXPOSE 3000
|
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||||
CMD ["bootstrap.cjs"]
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bootstrap script for the distroless production image.
|
|
||||||
*
|
|
||||||
* Distroless images have no shell, so this Node.js script handles two tasks
|
|
||||||
* that would normally be done in an entrypoint.sh:
|
|
||||||
*
|
|
||||||
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
|
||||||
* environment variables. The file was originally created at build time by
|
|
||||||
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
|
||||||
* env vars are provided at *run* time via docker-compose / docker run.
|
|
||||||
*
|
|
||||||
* 2. Start the Next.js standalone server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const publicDir = path.join(__dirname, "apps", "web", "public");
|
|
||||||
|
|
||||||
if (!existsSync(publicDir)) {
|
|
||||||
mkdirSync(publicDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const envVars = {};
|
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
|
||||||
if (key.startsWith("NEXT_PUBLIC_")) {
|
|
||||||
envVars[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
path.join(publicDir, "__ENV.js"),
|
|
||||||
`self.__ENV = ${JSON.stringify(envVars)};`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 2. Start the Next.js standalone server
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
require("./apps/web/server.js");
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": "1.10",
|
"version": 0,
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"json": {
|
"po": {
|
||||||
"include": ["src/locales/[locale]/messages.json"]
|
"include": ["src/locales/[locale]/messages.po"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||||
|
|||||||
3181
apps/web/i18n.lock
@@ -1,9 +1,7 @@
|
|||||||
import { defineConfig } from "@lingui/cli";
|
import type { LinguiConfig } from "@lingui/conf";
|
||||||
import { formatter } from "@lingui/format-json";
|
|
||||||
|
|
||||||
|
const config: LinguiConfig = {
|
||||||
export default defineConfig({
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
@@ -12,5 +10,6 @@ export default defineConfig({
|
|||||||
exclude: ["**/node_modules/**"],
|
exclude: ["**/node_modules/**"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
format: formatter({ style: "lingui" }),
|
};
|
||||||
});
|
|
||||||
|
export default config;
|
||||||
|
|||||||
@@ -16,18 +16,6 @@ const config = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
/** Exclude build tools and dev-only packages from the standalone output */
|
|
||||||
outputFileTracingExcludes: {
|
|
||||||
"**/*": [
|
|
||||||
"@esbuild/**",
|
|
||||||
"esbuild/**",
|
|
||||||
"typescript/**",
|
|
||||||
"webpack/**",
|
|
||||||
"uglify-js/**",
|
|
||||||
"terser/**",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@kan/api",
|
"@kan/api",
|
||||||
@@ -38,32 +26,61 @@ const config = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
typescript: { ignoreBuildErrors: true },
|
|
||||||
|
|
||||||
// temporarily ignore eslint errors during build until we fix all the errors sigh
|
|
||||||
eslint: { ignoreDuringBuilds: true },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: (() => {
|
remotePatterns: (() => {
|
||||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||||
const patterns = [
|
const patterns = [
|
||||||
{ protocol: "https", hostname: "**" },
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname:
|
||||||
|
env("S3_FORCE_PATH_STYLE") === "true"
|
||||||
|
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||||
|
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "*.googleusercontent.com",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||||
|
const s3Endpoint = env("S3_ENDPOINT");
|
||||||
|
if (s3Endpoint) {
|
||||||
|
try {
|
||||||
|
const url = new URL(s3Endpoint);
|
||||||
|
const hostname = url.hostname;
|
||||||
|
const protocol = url.protocol.replace(":", "");
|
||||||
|
|
||||||
|
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
||||||
|
const parts = hostname.split(".");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const rootDomain = parts.slice(-2).join(".");
|
||||||
|
patterns.push({
|
||||||
|
protocol: protocol === "http" ? "http" : "https",
|
||||||
|
hostname: `*.${rootDomain}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If S3_ENDPOINT is not a valid URL, ignore it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return patterns;
|
return patterns;
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
turbopack: {
|
webpack(config) {
|
||||||
rules: {
|
config.module.rules.push({
|
||||||
"*.svg": {
|
test: /\.svg$/,
|
||||||
loaders: ["@svgr/webpack"],
|
use: ["@svgr/webpack"],
|
||||||
as: "*.js",
|
});
|
||||||
},
|
return config;
|
||||||
},
|
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
// instrumentationHook: true,
|
// instrumentationHook: true,
|
||||||
|
|||||||
@@ -4,14 +4,12 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build --turbo",
|
"build": "pnpm with-env next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev --turbo",
|
"dev": "pnpm with-env next dev",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --",
|
"with-env": "dotenv -e ../../.env --",
|
||||||
"lingui:extract": "lingui extract",
|
"lingui:extract": "lingui extract",
|
||||||
@@ -20,7 +18,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.802.0",
|
"@aws-sdk/client-s3": "^3.802.0",
|
||||||
"@aws-sdk/lib-storage": "^3.802.0",
|
|
||||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
@@ -32,14 +29,12 @@
|
|||||||
"@lingui/conf": "^5.3.2",
|
"@lingui/conf": "^5.3.2",
|
||||||
"@lingui/macro": "^5.3.2",
|
"@lingui/macro": "^5.3.2",
|
||||||
"@lingui/react": "^5.3.2",
|
"@lingui/react": "^5.3.2",
|
||||||
"@novu/api": "^3.11.0",
|
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tiptap/extension-link": "^2.22.2",
|
"@tiptap/extension-link": "^2.22.2",
|
||||||
"@tiptap/extension-mention": "^3.0.9",
|
"@tiptap/extension-mention": "^3.0.9",
|
||||||
"@tiptap/extension-placeholder": "^2.14.0",
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
"@tiptap/extension-typography": "^3.18.0",
|
|
||||||
"@tiptap/pm": "^2.14.0",
|
"@tiptap/pm": "^2.14.0",
|
||||||
"@tiptap/react": "^2.14.0",
|
"@tiptap/react": "^2.14.0",
|
||||||
"@tiptap/starter-kit": "^2.14.0",
|
"@tiptap/starter-kit": "^2.14.0",
|
||||||
@@ -48,14 +43,15 @@
|
|||||||
"@trpc/next": "^11.0.0-rc.660",
|
"@trpc/next": "^11.0.0-rc.660",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
|
"aws-sdk": "^2.1692.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.26.2",
|
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"next": "^15.3.4",
|
||||||
"next": "15.5.9",
|
"next-logger": "^5.0.1",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-cors": "^2.2.0",
|
"nextjs-cors": "^2.2.0",
|
||||||
|
"pino": "^9.6.0",
|
||||||
"posthog-js": "^1.254.0",
|
"posthog-js": "^1.254.0",
|
||||||
"react": "catalog:react18",
|
"react": "catalog:react18",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
@@ -79,7 +75,6 @@
|
|||||||
"@kan/tailwind-config": "workspace:*",
|
"@kan/tailwind-config": "workspace:*",
|
||||||
"@kan/tsconfig": "workspace:*",
|
"@kan/tsconfig": "workspace:*",
|
||||||
"@lingui/cli": "^5.3.2",
|
"@lingui/cli": "^5.3.2",
|
||||||
"@lingui/format-json": "^5.9.1",
|
|
||||||
"@lingui/loader": "^5.3.2",
|
"@lingui/loader": "^5.3.2",
|
||||||
"@lingui/swc-plugin": "^5.5.2",
|
"@lingui/swc-plugin": "^5.5.2",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
@@ -90,10 +85,10 @@
|
|||||||
"dotenv-cli": "^7.4.4",
|
"dotenv-cli": "^7.4.4",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^1.21.6",
|
"jiti": "^1.21.6",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:"
|
||||||
"vitest": "^3.0.0"
|
|
||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config"
|
"prettier": "@kan/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
1
apps/web/public/logos/bitwarden.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
socialProviders?.length === 0 && (
|
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
||||||
<div className="flex w-full items-center gap-4">
|
<div className="flex w-full items-center gap-4">
|
||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
<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">
|
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||||
@@ -371,62 +371,65 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
{socialProviders?.length !== 0 && (
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
{t`or`}
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
</span>
|
||||||
{t`or`}
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
</span>
|
</div>
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
)}
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
)}
|
{isSignUp && isCredentialsEnabled && (
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
{...register("email", { required: true })}
|
{...register("name", { required: true })}
|
||||||
placeholder={t`Enter your email address`}
|
placeholder={t`Enter your name`}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.name && (
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{t`Please enter a valid email address`}
|
{t`Please enter a valid name`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{isCredentialsEnabled && (
|
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
{...register("email", { required: true })}
|
||||||
{...register("password", { required: true })}
|
placeholder={t`Enter your email address`}
|
||||||
placeholder={t`Enter your password`}
|
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.email && (
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{errors.password.message ??
|
{t`Please enter a valid email address`}
|
||||||
t`Please enter a valid password`}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{loginError && (
|
{isCredentialsEnabled && (
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
<div>
|
||||||
)}
|
<Input
|
||||||
</div>
|
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>
|
||||||
|
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoginWithEmailPending}
|
isLoading={isLoginWithEmailPending}
|
||||||
@@ -438,11 +441,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
)}
|
</form>
|
||||||
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const initials = name?.trim()
|
const initials = name
|
||||||
? getInitialsFromName(name)
|
? getInitialsFromName(name)
|
||||||
: inferInitialsFromEmail(email);
|
: inferInitialsFromEmail(email);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ interface CheckboxDropdownProps {
|
|||||||
handleEdit?: (key: string) => void;
|
handleEdit?: (key: string) => void;
|
||||||
handleCreate?: () => void;
|
handleCreate?: () => void;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckboxDropdown({
|
export default function CheckboxDropdown({
|
||||||
@@ -45,7 +44,6 @@ export default function CheckboxDropdown({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
asChild = true,
|
asChild = true,
|
||||||
disabled = false,
|
|
||||||
}: CheckboxDropdownProps) {
|
}: CheckboxDropdownProps) {
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -60,13 +58,13 @@ export default function CheckboxDropdown({
|
|||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<Menu.Item key={item.key}>
|
<Menu.Item key={item.key}>
|
||||||
<div
|
<div
|
||||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id={item.key}
|
id={item.key}
|
||||||
name={item.key}
|
name={item.key}
|
||||||
@@ -134,8 +132,7 @@ export default function CheckboxDropdown({
|
|||||||
<>
|
<>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
as={asChild ? "div" : undefined}
|
as={asChild ? "div" : undefined}
|
||||||
disabled={disabled}
|
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||||
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { authClient } from "@kan/auth/client";
|
|||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
@@ -45,12 +44,6 @@ export default function Dashboard({
|
|||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||||
|
|
||||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
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 [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||||
@@ -162,12 +155,8 @@ export default function Dashboard({
|
|||||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||||
>
|
>
|
||||||
<SideNavigation
|
<SideNavigation
|
||||||
user={{
|
user={{ email: session?.user.email, image: session?.user.image }}
|
||||||
displayName: user?.name ?? session?.user.name,
|
isLoading={sessionLoading}
|
||||||
email: user?.email ?? session?.user.email ?? "",
|
|
||||||
image: user?.image ?? undefined,
|
|
||||||
}}
|
|
||||||
isLoading={sessionLoading || userLoading}
|
|
||||||
onCloseSideNav={closeSideNav}
|
onCloseSideNav={closeSideNav}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import {
|
|
||||||
addMonths,
|
|
||||||
eachDayOfInterval,
|
|
||||||
endOfMonth,
|
|
||||||
endOfWeek,
|
|
||||||
format,
|
|
||||||
isSameDay,
|
|
||||||
isToday,
|
|
||||||
startOfMonth,
|
|
||||||
startOfWeek,
|
|
||||||
subMonths,
|
|
||||||
} from "date-fns";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
interface DateSelectorProps {
|
|
||||||
selectedDate?: Date | null;
|
|
||||||
onDateSelect?: (date: Date | undefined) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
|
||||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
|
||||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
|
||||||
});
|
|
||||||
|
|
||||||
const monthName = format(currentMonth, "MMMM");
|
|
||||||
const year = format(currentMonth, "yyyy");
|
|
||||||
|
|
||||||
const dayHeaders = useMemo(() => {
|
|
||||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
|
||||||
return eachDayOfInterval({
|
|
||||||
start: weekStart,
|
|
||||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
|
||||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const days = useMemo(() => {
|
|
||||||
const monthStart = startOfMonth(currentMonth);
|
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
|
||||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
|
||||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
|
||||||
|
|
||||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
|
||||||
(date) => {
|
|
||||||
const dateString = format(date, "yyyy-MM-dd");
|
|
||||||
return {
|
|
||||||
date: dateString,
|
|
||||||
isToday: isToday(date),
|
|
||||||
isSelected: selectedDate ? isSameDay(date, selectedDate) : false,
|
|
||||||
isCurrentMonth: date >= monthStart && date <= monthEnd,
|
|
||||||
dateObj: date,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [currentMonth, selectedDate]);
|
|
||||||
|
|
||||||
const handlePreviousMonth = () => {
|
|
||||||
setCurrentMonth(subMonths(currentMonth, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextMonth = () => {
|
|
||||||
setCurrentMonth(addMonths(currentMonth, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDateClick = (date: Date, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// If clicking the same date that's already selected, unselect it
|
|
||||||
if (selectedDate && isSameDay(date, selectedDate)) {
|
|
||||||
onDateSelect?.(undefined);
|
|
||||||
} else {
|
|
||||||
onDateSelect?.(date);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[250px] p-4">
|
|
||||||
<div className="flex items-center text-light-1000 dark:text-dark-1000">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handlePreviousMonth}
|
|
||||||
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Previous month</span>
|
|
||||||
<HiChevronLeft aria-hidden="true" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<div className="flex-1 text-center text-sm font-semibold">
|
|
||||||
{monthName} {year}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNextMonth}
|
|
||||||
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Next month</span>
|
|
||||||
<HiChevronRight aria-hidden="true" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 grid grid-cols-7 text-center text-xs/6 text-light-950 dark:text-dark-950">
|
|
||||||
{dayHeaders.map((day, index) => (
|
|
||||||
<div key={index}>{day}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="isolate mt-2 grid grid-cols-7 text-sm">
|
|
||||||
{days.map((day) => (
|
|
||||||
<button
|
|
||||||
key={day.date}
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => handleDateClick(day.dateObj, e)}
|
|
||||||
className={twMerge(
|
|
||||||
"flex aspect-square items-center justify-center rounded-lg focus:z-10",
|
|
||||||
day.isSelected
|
|
||||||
? "bg-light-1000 hover:bg-light-1000 dark:bg-dark-1000 dark:hover:bg-dark-1000"
|
|
||||||
: "bg-transparent hover:bg-light-200 dark:bg-transparent dark:hover:bg-dark-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<time
|
|
||||||
dateTime={day.date}
|
|
||||||
className={twMerge(
|
|
||||||
"mx-auto flex size-7 items-center justify-center rounded-full text-light-900 dark:text-dark-900",
|
|
||||||
day.isCurrentMonth
|
|
||||||
? "text-light-900 dark:text-dark-900"
|
|
||||||
: "text-light-700 dark:text-dark-600",
|
|
||||||
day.isSelected && "text-light-50 dark:text-dark-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{day.date.split("-").pop()?.replace(/^0/, "")}
|
|
||||||
</time>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DateSelector;
|
|
||||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
|||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -30,14 +30,13 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
<Menu.Item key={item.label}>
|
||||||
<button
|
<button
|
||||||
onClick={item.action}
|
onClick={item.action}
|
||||||
disabled={item.disabled ?? !item.action}
|
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Range as TiptapRange } from "@tiptap/core";
|
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import type {
|
import type {
|
||||||
SuggestionKeyDownProps,
|
SuggestionKeyDownProps,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
ReactRenderer,
|
ReactRenderer,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import Typography from "@tiptap/extension-typography";
|
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +44,6 @@ import { Markdown } from "tiptap-markdown";
|
|||||||
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -59,7 +56,7 @@ declare module "@tiptap/core" {
|
|||||||
export interface SlashCommandItem {
|
export interface SlashCommandItem {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,54 +384,46 @@ export interface SlashNodeAttrs {
|
|||||||
label?: string | null;
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
const CommandItems: SlashCommandItem[] = [
|
||||||
const headingCommands: SlashCommandItem[] = disableHeadings
|
{
|
||||||
? []
|
title: "Heading 1",
|
||||||
: [
|
icon: <HiH1 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 1",
|
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||||
icon: <HiH1 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
title: "Heading 2",
|
||||||
},
|
icon: <HiH2 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 2",
|
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||||
icon: <HiH2 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
title: "Heading 3",
|
||||||
},
|
icon: <HiH3 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 3",
|
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||||
icon: <HiH3 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
title: "Bullet List",
|
||||||
},
|
icon: <HiOutlineListBullet />,
|
||||||
];
|
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||||
|
},
|
||||||
return [
|
{
|
||||||
...headingCommands,
|
title: "Ordered List",
|
||||||
{
|
icon: <HiOutlineNumberedList />,
|
||||||
title: "Bullet List",
|
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||||
icon: <HiOutlineListBullet />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
{
|
||||||
},
|
title: "Blockquote",
|
||||||
{
|
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||||
title: "Ordered List",
|
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||||
icon: <HiOutlineNumberedList />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
{
|
||||||
},
|
title: "Code Block",
|
||||||
{
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
title: "Blockquote",
|
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
];
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Code Block",
|
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
|
||||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
content,
|
content,
|
||||||
@@ -442,26 +431,24 @@ export default function Editor({
|
|||||||
onBlur,
|
onBlur,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
workspaceMembers,
|
||||||
enableYouTubeEmbed = true,
|
|
||||||
placeholder,
|
|
||||||
disableHeadings = false,
|
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
workspaceMembers: WorkspaceMember[];
|
||||||
enableYouTubeEmbed?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
disableHeadings?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit,
|
||||||
heading: disableHeadings ? false : undefined,
|
Markdown,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: readOnly
|
||||||
|
? ""
|
||||||
|
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
@@ -472,20 +459,12 @@ export default function Editor({
|
|||||||
},
|
},
|
||||||
validate: (href) => /^https?:\/\//.test(href),
|
validate: (href) => /^https?:\/\//.test(href),
|
||||||
autolink: true,
|
autolink: true,
|
||||||
linkOnPaste: true,
|
|
||||||
}),
|
|
||||||
Markdown,
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: readOnly
|
|
||||||
? ""
|
|
||||||
: placeholder ??
|
|
||||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
|
||||||
}),
|
}),
|
||||||
SlashCommands.configure({
|
SlashCommands.configure({
|
||||||
commandItems: getCommandItems(disableHeadings),
|
commandItems: CommandItems,
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: ({ query }: { query: string }) =>
|
items: ({ query }: { query: string }) =>
|
||||||
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
filterSlashCommandItems(CommandItems, query),
|
||||||
startOfLine: true,
|
startOfLine: true,
|
||||||
char: "/",
|
char: "/",
|
||||||
},
|
},
|
||||||
@@ -497,33 +476,18 @@ export default function Editor({
|
|||||||
suggestion: {
|
suggestion: {
|
||||||
char: "@",
|
char: "@",
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
const withEmail = workspaceMembers.filter((member) => member.email);
|
const all: MentionItem[] = workspaceMembers.map(
|
||||||
|
(member: WorkspaceMember) => ({
|
||||||
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
id: member.publicId,
|
||||||
id: member.publicId,
|
label: member?.user?.name ?? member.email,
|
||||||
label: member?.user?.name?.trim() || member.email || "",
|
image: member?.user?.image ?? null,
|
||||||
image: member?.user?.image ?? null,
|
}),
|
||||||
}));
|
|
||||||
|
|
||||||
const all: MentionItem[] = mapped.filter(
|
|
||||||
(item) => item.label && item.label.length > 0,
|
|
||||||
);
|
);
|
||||||
|
const q = query.toLowerCase();
|
||||||
const q = query.toLowerCase().trim();
|
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||||
|
|
||||||
if (q === "") {
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = all.filter((u) =>
|
|
||||||
u.label.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
return filtered;
|
|
||||||
},
|
},
|
||||||
command: ({ editor, range, props }) => {
|
command: ({ editor, range, props }: any) => {
|
||||||
const id = props.id ?? "";
|
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||||
const label = props.label ?? "";
|
|
||||||
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span> `;
|
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
@@ -539,18 +503,6 @@ export default function Editor({
|
|||||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Typography.configure({
|
|
||||||
openDoubleQuote: false,
|
|
||||||
closeDoubleQuote: false,
|
|
||||||
openSingleQuote: false,
|
|
||||||
closeSingleQuote: false,
|
|
||||||
oneHalf: false,
|
|
||||||
oneQuarter: false,
|
|
||||||
threeQuarters: false,
|
|
||||||
superscriptTwo: false,
|
|
||||||
superscriptThree: false,
|
|
||||||
}),
|
|
||||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||||
@@ -607,9 +559,6 @@ export default function Editor({
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.tiptap [data-youtube] {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
`}</style>
|
`}</style>
|
||||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const schema = z.object({
|
|||||||
.min(3, {
|
.min(3, {
|
||||||
message: t`URL must be at least 3 characters long`,
|
message: t`URL must be at least 3 characters long`,
|
||||||
})
|
})
|
||||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||||
<div className="p-4 relative">
|
<div className="p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 mt-1">
|
<div className="flex-shrink-0">
|
||||||
{popupIcon === "success" && (
|
{popupIcon === "success" && (
|
||||||
<HiOutlineCheckCircle
|
<HiOutlineCheckCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-green-400"
|
className="h-6 w-6 text-green-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{popupIcon === "error" && (
|
{popupIcon === "error" && (
|
||||||
<HiOutlineExclamationCircle
|
<HiOutlineExclamationCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-red-400"
|
className="h-6 w-6 text-red-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||||
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||||
{popupHeader}
|
{popupHeader}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
{popupMessage}
|
{popupMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
<div className="ml-4 flex flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
<HiXMark
|
<HiXMark
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-4 w-4 text-dark-900"
|
className="h-5 w-5 text-dark-900"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import LottieIcon from "~/components/LottieIcon";
|
import LottieIcon from "~/components/LottieIcon";
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
|
|
||||||
const Button: React.FC<{
|
const Button: React.FC<{
|
||||||
href: string;
|
href: string;
|
||||||
@@ -14,20 +12,10 @@ const Button: React.FC<{
|
|||||||
json: object;
|
json: object;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCloseSideNav?: () => void;
|
onCloseSideNav?: () => void;
|
||||||
keyboardShortcut: KeyboardShortcut;
|
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
|
||||||
}> = ({
|
|
||||||
href,
|
|
||||||
current,
|
|
||||||
name,
|
|
||||||
json,
|
|
||||||
isCollapsed = false,
|
|
||||||
keyboardShortcut,
|
|
||||||
onCloseSideNav,
|
|
||||||
}) => {
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
@@ -47,27 +35,17 @@ const Button: React.FC<{
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||||
isCollapsed ? "md:justify-center" : "justify-between",
|
|
||||||
current
|
current
|
||||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||||
|
isCollapsed
|
||||||
|
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
||||||
|
: "gap-x-3",
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? name : undefined}
|
title={isCollapsed ? name : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||||
className={twMerge(
|
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||||
"flex items-center",
|
|
||||||
isCollapsed
|
|
||||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
|
||||||
: "gap-x-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
|
||||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
|
||||||
</div>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ import {
|
|||||||
HiOutlineBanknotes,
|
HiOutlineBanknotes,
|
||||||
HiOutlineCodeBracketSquare,
|
HiOutlineCodeBracketSquare,
|
||||||
HiOutlineRectangleGroup,
|
HiOutlineRectangleGroup,
|
||||||
HiOutlineShieldCheck,
|
|
||||||
HiOutlineUser,
|
HiOutlineUser,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { usePermissions } from "~/hooks/usePermissions";
|
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -27,12 +24,8 @@ interface SettingsLayoutProps {
|
|||||||
|
|
||||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
const { canViewWorkspace, canEditWorkspace } = usePermissions();
|
|
||||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||||
|
|
||||||
const isAdmin = workspace.role === "admin";
|
|
||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{
|
{
|
||||||
key: "account",
|
key: "account",
|
||||||
@@ -44,19 +37,13 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "workspace",
|
key: "workspace",
|
||||||
icon: <HiOutlineRectangleGroup />,
|
icon: <HiOutlineRectangleGroup />,
|
||||||
label: t`Workspace`,
|
label: t`Workspace`,
|
||||||
condition: canViewWorkspace,
|
condition: true,
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "permissions",
|
|
||||||
icon: <HiOutlineShieldCheck />,
|
|
||||||
label: t`Permissions`,
|
|
||||||
condition: isAdmin,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
label: t`Billing`,
|
label: t`Billing`,
|
||||||
icon: <HiOutlineBanknotes />,
|
icon: <HiOutlineBanknotes />,
|
||||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "api",
|
key: "api",
|
||||||
@@ -68,7 +55,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
key: "integrations",
|
key: "integrations",
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
label: t`Integrations`,
|
label: t`Integrations`,
|
||||||
condition: canEditWorkspace,
|
condition: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -110,7 +97,7 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
>
|
>
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||||
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
|
{availableTabs[selectedTabIndex]?.label || "Select a tab"}
|
||||||
<HiChevronDown
|
<HiChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import type { Subscription } from "@kan/shared/utils";
|
import type { Subscription } from "@kan/shared/utils";
|
||||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||||
|
|
||||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import boardsIconDark from "~/assets/boards-dark.json";
|
import boardsIconDark from "~/assets/boards-dark.json";
|
||||||
import boardsIconLight from "~/assets/boards-light.json";
|
import boardsIconLight from "~/assets/boards-light.json";
|
||||||
import membersIconDark from "~/assets/members-dark.json";
|
import membersIconDark from "~/assets/members-dark.json";
|
||||||
@@ -39,7 +38,6 @@ interface SideNavigationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
displayName?: string | null | undefined;
|
|
||||||
email?: string | null | undefined;
|
email?: string | null | undefined;
|
||||||
image?: string | null | undefined;
|
image?: string | null | undefined;
|
||||||
}
|
}
|
||||||
@@ -55,10 +53,9 @@ export default function SideNavigation({
|
|||||||
const [isInitialised, setIsInitialised] = useState(false);
|
const [isInitialised, setIsInitialised] = useState(false);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||||
{ workspacePublicId: workspace.publicId },
|
workspacePublicId: workspace.publicId,
|
||||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const subscriptions = workspaceData?.subscriptions as
|
const subscriptions = workspaceData?.subscriptions as
|
||||||
| Subscription[]
|
| Subscription[]
|
||||||
@@ -89,59 +86,26 @@ export default function SideNavigation({
|
|||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
const navigation: {
|
const navigation = [
|
||||||
name: string;
|
|
||||||
href: string;
|
|
||||||
icon: object;
|
|
||||||
keyboardShortcut: KeyboardShortcut;
|
|
||||||
}[] = [
|
|
||||||
{
|
{
|
||||||
name: t`Boards`,
|
name: t`Boards`,
|
||||||
href: "/boards",
|
href: "/boards",
|
||||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "B" }],
|
|
||||||
action: () => router.push("/boards"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to boards`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Templates`,
|
name: t`Templates`,
|
||||||
href: "/templates",
|
href: "/templates",
|
||||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "T" }],
|
|
||||||
action: () => router.push("/templates"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to templates`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Members`,
|
name: t`Members`,
|
||||||
href: "/members",
|
href: "/members",
|
||||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "M" }],
|
|
||||||
action: () => router.push("/members"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to members`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Settings`,
|
name: t`Settings`,
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "S" }],
|
|
||||||
action: () => router.push("/settings"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to settings`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -199,7 +163,6 @@ export default function SideNavigation({
|
|||||||
json={item.icon}
|
json={item.icon}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onCloseSideNav={onCloseSideNav}
|
onCloseSideNav={onCloseSideNav}
|
||||||
keyboardShortcut={item.keyboardShortcut}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -208,8 +171,7 @@ export default function SideNavigation({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<UserMenu
|
<UserMenu
|
||||||
displayName={user.displayName ?? undefined}
|
email={user.email ?? ""}
|
||||||
email={user.email ?? "Email not provided?"}
|
|
||||||
imageUrl={user.image ?? undefined}
|
imageUrl={user.image ?? undefined}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|||||||
@@ -6,20 +6,16 @@ const Toggle = ({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
showLabel = true,
|
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showLabel?: boolean;
|
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mr-4 flex items-center justify-end">
|
<div className="mr-4 flex items-center justify-end">
|
||||||
{showLabel && (
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
{label}
|
||||||
{label}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import type { Root } from "react-dom/client";
|
|
||||||
import type { Placement } from "tippy.js";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import tippy from "tippy.js";
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
children: ReactNode;
|
|
||||||
content?: ReactNode;
|
|
||||||
placement?: Placement;
|
|
||||||
delay?: number | [number, number];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tooltip({
|
|
||||||
children,
|
|
||||||
content,
|
|
||||||
placement = "bottom",
|
|
||||||
delay = [500, 0],
|
|
||||||
}: TooltipProps) {
|
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rootRef = useRef<Root | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!triggerRef.current) return;
|
|
||||||
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
const root = createRoot(container);
|
|
||||||
rootRef.current = root;
|
|
||||||
root.render(content);
|
|
||||||
|
|
||||||
const instance = tippy(triggerRef.current, {
|
|
||||||
content: container,
|
|
||||||
placement,
|
|
||||||
delay,
|
|
||||||
interactive: false,
|
|
||||||
theme: "tooltip",
|
|
||||||
touch: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
instance.destroy();
|
|
||||||
rootRef.current?.unmount();
|
|
||||||
};
|
|
||||||
}, [content, placement, delay]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={triggerRef} className="inline-flex">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,15 +9,12 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
displayName: string | undefined;
|
|
||||||
email: string;
|
email: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
@@ -27,7 +24,6 @@ interface UserMenuProps {
|
|||||||
export default function UserMenu({
|
export default function UserMenu({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
email,
|
email,
|
||||||
displayName,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
onCloseSideNav,
|
onCloseSideNav,
|
||||||
@@ -35,7 +31,6 @@ export default function UserMenu({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { openLegend } = useKeyboardShortcuts();
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -77,7 +72,7 @@ export default function UserMenu({
|
|||||||
) : (
|
) : (
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||||
title={isCollapsed ? (displayName || email) : undefined}
|
title={isCollapsed ? email : undefined}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -104,7 +99,7 @@ export default function UserMenu({
|
|||||||
isCollapsed && "md:hidden",
|
isCollapsed && "md:hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayName || email}
|
{email}
|
||||||
</span>
|
</span>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
@@ -174,19 +169,6 @@ export default function UserMenu({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||||
<Menu.Item>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
openLegend();
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
{t`Shortcuts`}
|
|
||||||
</button>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<Link
|
<Link
|
||||||
href="mailto:support@kan.bn"
|
href="mailto:support@kan.bn"
|
||||||
@@ -228,25 +210,6 @@ export default function UserMenu({
|
|||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
{env.NEXT_PUBLIC_APP_VERSION && (
|
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
|
||||||
<Menu.Item>
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
|
||||||
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
|
||||||
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
onClick={handleLinkClick}
|
|
||||||
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
|
||||||
</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Button, Menu, Transition } from "@headlessui/react";
|
import { Button, Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import CommandPallette from "./CommandPallette";
|
import CommandPallette from "./CommandPallette";
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
export default function WorkspaceMenu({
|
export default function WorkspaceMenu({
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
@@ -20,17 +18,17 @@ export default function WorkspaceMenu({
|
|||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
useEffect(() => {
|
||||||
useKeyboardShortcut({
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
type: "PRESS",
|
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||||
stroke: {
|
event.preventDefault();
|
||||||
key: "k",
|
setIsOpen(true);
|
||||||
modifiers: ["META"],
|
}
|
||||||
},
|
};
|
||||||
action: () => setIsOpen(true),
|
|
||||||
description: t`Open command menu`,
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
group: "GENERAL",
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -86,17 +84,15 @@ export default function WorkspaceMenu({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Tooltip content={commandPaletteShortcutTooltipContent}>
|
<Button
|
||||||
<Button
|
className={twMerge(
|
||||||
className={twMerge(
|
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
isCollapsed && "md:mb-2 md:h-9 md:w-9",
|
||||||
isCollapsed && "md:mb-2 md:h-9 md:w-9",
|
)}
|
||||||
)}
|
onClick={() => setIsOpen(true)}
|
||||||
onClick={() => setIsOpen(true)}
|
>
|
||||||
>
|
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { HiXMark } from "react-icons/hi2";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Input from "~/components/Input";
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
|
||||||
|
|
||||||
interface EditYouTubeFormInput {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditYouTubeModalState {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
onSave: (url: string, title: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditYouTubeModal() {
|
|
||||||
const { closeModal, getModalState } = useModal();
|
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
|
||||||
const [urlError, setUrlError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Get initial values and callback from modal state
|
|
||||||
const modalState = getModalState("EDIT_YOUTUBE") as
|
|
||||||
| EditYouTubeModalState
|
|
||||||
| undefined;
|
|
||||||
const initialUrl = modalState?.url ?? "";
|
|
||||||
const initialTitle = modalState?.title ?? "";
|
|
||||||
const onSave = modalState?.onSave;
|
|
||||||
|
|
||||||
const { register, handleSubmit, watch, reset } =
|
|
||||||
useForm<EditYouTubeFormInput>({
|
|
||||||
defaultValues: {
|
|
||||||
url: initialUrl,
|
|
||||||
title: initialTitle,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form when modal state changes (when modal opens with new values)
|
|
||||||
useEffect(() => {
|
|
||||||
if (modalState) {
|
|
||||||
reset({
|
|
||||||
url: modalState.url,
|
|
||||||
title: modalState.title,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [modalState, reset]);
|
|
||||||
|
|
||||||
const currentUrl = watch("url");
|
|
||||||
|
|
||||||
const onSubmit = async (values: EditYouTubeFormInput) => {
|
|
||||||
// Validate URL
|
|
||||||
if (!isYouTubeUrl(values.url)) {
|
|
||||||
setUrlError(t`Please enter a valid YouTube URL`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUrlError(null);
|
|
||||||
setIsValidating(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If title is empty and URL changed, fetch new title
|
|
||||||
let finalTitle = values.title;
|
|
||||||
if (!finalTitle.trim() && values.url !== initialUrl) {
|
|
||||||
const metadata = await fetchYouTubeMetadata(values.url);
|
|
||||||
finalTitle = metadata?.title ?? "YouTube Video";
|
|
||||||
} else if (!finalTitle.trim()) {
|
|
||||||
// Keep existing title if no new title provided and URL unchanged
|
|
||||||
finalTitle = initialTitle || "YouTube Video";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSave) {
|
|
||||||
onSave(values.url, finalTitle);
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setUrlError(t`Failed to fetch video information`);
|
|
||||||
} finally {
|
|
||||||
setIsValidating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-focus on title input (more useful for editing)
|
|
||||||
useEffect(() => {
|
|
||||||
const titleElement: HTMLElement | null =
|
|
||||||
document.querySelector<HTMLElement>("#youtube-title");
|
|
||||||
if (titleElement) titleElement.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Validate URL on change
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUrl && !isYouTubeUrl(currentUrl)) {
|
|
||||||
setUrlError(t`Please enter a valid YouTube URL`);
|
|
||||||
} else {
|
|
||||||
setUrlError(null);
|
|
||||||
}
|
|
||||||
}, [currentUrl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div className="px-5 pt-5">
|
|
||||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
|
||||||
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
closeModal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="youtube-title"
|
|
||||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
|
||||||
>
|
|
||||||
{t`Title`}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="youtube-title"
|
|
||||||
placeholder={t`Enter a custom title`}
|
|
||||||
{...register("title")}
|
|
||||||
onKeyDown={async (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="youtube-url"
|
|
||||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
|
||||||
>
|
|
||||||
{t`YouTube URL`}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="youtube-url"
|
|
||||||
placeholder={t`https://www.youtube.com/watch?v=...`}
|
|
||||||
{...register("url", { required: true })}
|
|
||||||
onKeyDown={async (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{urlError && (
|
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
|
||||||
{urlError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
|
||||||
<div className="space-x-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => closeModal()}
|
|
||||||
>
|
|
||||||
{t`Cancel`}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
isLoading={isValidating}
|
|
||||||
disabled={!watch("url") || !!urlError}
|
|
||||||
>
|
|
||||||
{t`Save`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import YouTubeDropdown from "./YouTubeDropdown";
|
|
||||||
|
|
||||||
interface YouTubeCardProps {
|
|
||||||
videoId: string;
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
showEmbed?: boolean;
|
|
||||||
onConvertToLink: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onUpdate: (url: string, title: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const YouTubeCard = ({
|
|
||||||
videoId,
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
showEmbed = true,
|
|
||||||
onConvertToLink,
|
|
||||||
onDelete,
|
|
||||||
onUpdate,
|
|
||||||
}: YouTubeCardProps) => {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
|
|
||||||
<div className="flex items-center justify-between gap-6 p-0 px-6">
|
|
||||||
<h3 className="truncate text-sm font-medium">{title}</h3>
|
|
||||||
<div className="mt-3">
|
|
||||||
<YouTubeDropdown
|
|
||||||
url={url}
|
|
||||||
title={title}
|
|
||||||
onConvertToLink={onConvertToLink}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showEmbed && videoId && (
|
|
||||||
<div className="p-6 pt-1">
|
|
||||||
<iframe
|
|
||||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
|
||||||
title={title}
|
|
||||||
className="aspect-video w-full rounded-lg"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default YouTubeCard;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import {
|
|
||||||
HiEllipsisHorizontal,
|
|
||||||
HiLink,
|
|
||||||
HiPencil,
|
|
||||||
HiTrash,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import Dropdown from "../Dropdown";
|
|
||||||
|
|
||||||
interface YouTubeDropdownProps {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
onConvertToLink: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onUpdate: (url: string, title: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const YouTubeDropdown = ({
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
onConvertToLink,
|
|
||||||
onDelete,
|
|
||||||
onUpdate,
|
|
||||||
}: YouTubeDropdownProps) => {
|
|
||||||
const { openModal, setModalState } = useModal();
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
setModalState("EDIT_YOUTUBE", {
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
onSave: onUpdate,
|
|
||||||
});
|
|
||||||
openModal("EDIT_YOUTUBE");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Edit`,
|
|
||||||
action: handleEdit,
|
|
||||||
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Convert to link`,
|
|
||||||
action: onConvertToLink,
|
|
||||||
icon: <HiLink className="h-4 w-4 text-dark-900" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete`,
|
|
||||||
action: onDelete,
|
|
||||||
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default YouTubeDropdown;
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import { mergeAttributes, Node } from "@tiptap/core";
|
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
|
|
||||||
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
|
||||||
import YouTubeNodeView from "./YouTubeNodeView";
|
|
||||||
|
|
||||||
export interface YouTubeOptions {
|
|
||||||
inline: boolean;
|
|
||||||
HTMLAttributes: Record<string, undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
youTube: {
|
|
||||||
setYouTubeEmbed: (options: {
|
|
||||||
videoId: string;
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
thumbnailUrl?: string;
|
|
||||||
showEmbed?: boolean;
|
|
||||||
}) => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const YouTubeNode = Node.create<YouTubeOptions>({
|
|
||||||
name: "youtube",
|
|
||||||
group: "block",
|
|
||||||
atom: true,
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
inline: false,
|
|
||||||
HTMLAttributes: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
videoId: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-video-id"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.videoId) return {};
|
|
||||||
return { "data-video-id": attributes.videoId as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-url"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.url) return {};
|
|
||||||
return { "data-url": attributes.url as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
default: "YouTube Video",
|
|
||||||
parseHTML: (element) => element.getAttribute("data-title"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
return { "data-title": attributes.title as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
thumbnailUrl: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-thumbnail"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.thumbnailUrl) return {};
|
|
||||||
return { "data-thumbnail": attributes.thumbnailUrl as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
showEmbed: {
|
|
||||||
default: true,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-show-embed"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
return { "data-show-embed": attributes.showEmbed as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: "div[data-youtube]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
return [
|
|
||||||
"div",
|
|
||||||
mergeAttributes(
|
|
||||||
{ "data-youtube": "" },
|
|
||||||
this.options.HTMLAttributes,
|
|
||||||
HTMLAttributes,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
return ReactNodeViewRenderer(YouTubeNodeView);
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
const nodeType = this.type;
|
|
||||||
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
key: new PluginKey("youtubePaste"),
|
|
||||||
props: {
|
|
||||||
handlePaste: (view, event) => {
|
|
||||||
const text = event.clipboardData?.getData("text/plain");
|
|
||||||
if (!text) return false;
|
|
||||||
|
|
||||||
// Check if the pasted text is a YouTube URL
|
|
||||||
const youtubeRegex =
|
|
||||||
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
|
|
||||||
const match = youtubeRegex.exec(text);
|
|
||||||
|
|
||||||
if (!match || !isYouTubeUrl(text)) return false;
|
|
||||||
|
|
||||||
const videoId = extractVideoId(text);
|
|
||||||
if (!videoId) return false;
|
|
||||||
|
|
||||||
const { state, dispatch } = view;
|
|
||||||
const { tr } = state;
|
|
||||||
|
|
||||||
const node = nodeType.create({
|
|
||||||
videoId,
|
|
||||||
url: text,
|
|
||||||
title: "Loading...",
|
|
||||||
showEmbed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
tr.replaceSelectionWith(node);
|
|
||||||
dispatch(tr);
|
|
||||||
|
|
||||||
// Fetch metadata asynchronously and update the node
|
|
||||||
void fetchYouTubeMetadata(text).then((metadata) => {
|
|
||||||
const { state: newState, dispatch: newDispatch } = view;
|
|
||||||
const { tr: newTr } = newState;
|
|
||||||
|
|
||||||
newState.doc.descendants((n, pos) => {
|
|
||||||
if (
|
|
||||||
n.type.name === "youtube" &&
|
|
||||||
n.attrs.videoId === videoId &&
|
|
||||||
n.attrs.title === "Loading..."
|
|
||||||
) {
|
|
||||||
newTr.setNodeMarkup(pos, undefined, {
|
|
||||||
...n.attrs,
|
|
||||||
title: metadata?.title ?? "YouTube Video",
|
|
||||||
thumbnailUrl: metadata?.thumbnail_url ?? null,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newTr.docChanged) {
|
|
||||||
newDispatch(newTr);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
// commands for the YouTube embed node
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setYouTubeEmbed:
|
|
||||||
(options: {
|
|
||||||
videoId: string;
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
thumbnailUrl?: string;
|
|
||||||
showEmbed?: boolean;
|
|
||||||
}) =>
|
|
||||||
({ editor }) => {
|
|
||||||
return editor.commands.insertContent({
|
|
||||||
type: this.name,
|
|
||||||
attrs: {
|
|
||||||
videoId: options.videoId,
|
|
||||||
url: options.url,
|
|
||||||
title: options.title ?? "YouTube Video",
|
|
||||||
thumbnailUrl: options.thumbnailUrl,
|
|
||||||
showEmbed: options.showEmbed ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { NodeViewProps } from "@tiptap/react";
|
|
||||||
import { NodeViewWrapper } from "@tiptap/react";
|
|
||||||
|
|
||||||
import { extractVideoId } from "./utils";
|
|
||||||
import YouTubeCard from "./YouTubeCard";
|
|
||||||
|
|
||||||
export default function YouTubeNodeView({
|
|
||||||
node,
|
|
||||||
editor,
|
|
||||||
getPos,
|
|
||||||
deleteNode,
|
|
||||||
updateAttributes,
|
|
||||||
}: NodeViewProps) {
|
|
||||||
const { videoId, title, url, showEmbed } = node.attrs as {
|
|
||||||
videoId: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
showEmbed: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConvertToLink = () => {
|
|
||||||
const pos = getPos();
|
|
||||||
if (typeof pos !== "number") return;
|
|
||||||
|
|
||||||
const linkContent = {
|
|
||||||
type: "paragraph",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: url,
|
|
||||||
marks: [
|
|
||||||
{
|
|
||||||
type: "link",
|
|
||||||
attrs: {
|
|
||||||
href: url,
|
|
||||||
target: "_blank",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
|
||||||
.insertContentAt(pos, linkContent)
|
|
||||||
.run();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
deleteNode();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = (newUrl: string, newTitle: string) => {
|
|
||||||
if (newUrl !== url) {
|
|
||||||
const newVideoId = extractVideoId(newUrl);
|
|
||||||
updateAttributes({
|
|
||||||
url: newUrl,
|
|
||||||
title: newTitle,
|
|
||||||
videoId: newVideoId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateAttributes({ title: newTitle });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper>
|
|
||||||
<YouTubeCard
|
|
||||||
videoId={videoId}
|
|
||||||
url={url}
|
|
||||||
title={title}
|
|
||||||
showEmbed={showEmbed}
|
|
||||||
onConvertToLink={handleConvertToLink}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
/>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
export function extractVideoId(url: string): string | null {
|
|
||||||
if (!url) return null;
|
|
||||||
url = url.trim();
|
|
||||||
|
|
||||||
// youtube.com/watch?v=VIDEO_ID
|
|
||||||
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
|
|
||||||
if (watchMatch) return watchMatch[1] ?? null;
|
|
||||||
|
|
||||||
// youtu.be/VIDEO_ID
|
|
||||||
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
|
||||||
if (shortMatch) return shortMatch[1] ?? null;
|
|
||||||
|
|
||||||
// youtube.com/embed/VIDEO_ID
|
|
||||||
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
|
||||||
if (embedMatch) return embedMatch[1] ?? null;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL is a valid YouTube link
|
|
||||||
*/
|
|
||||||
export function isYouTubeUrl(url: string): boolean {
|
|
||||||
if (!url) return false;
|
|
||||||
const videoId = extractVideoId(url);
|
|
||||||
return videoId !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch YouTube video metadata using oEmbed API
|
|
||||||
* Returns title, author, thumbnail URL, etc.
|
|
||||||
* No API key required
|
|
||||||
*/
|
|
||||||
export async function fetchYouTubeMetadata(url: string): Promise<{
|
|
||||||
title: string;
|
|
||||||
author_name: string;
|
|
||||||
thumbnail_url: string;
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
|
||||||
const response = await fetch(oembedUrl);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
|
||||||
title: string;
|
|
||||||
author_name: string;
|
|
||||||
thumbnail_url: string;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
title: data.title || "YouTube Video",
|
|
||||||
author_name: data.author_name || "",
|
|
||||||
thumbnail_url: data.thumbnail_url || "",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch YouTube metadata:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ interface Props {
|
|||||||
positionFromTop?: "sm" | "md" | "lg";
|
positionFromTop?: "sm" | "md" | "lg";
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
centered?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
@@ -18,7 +17,6 @@ const Modal: React.FC<Props> = ({
|
|||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
isVisible,
|
||||||
closeOnClickOutside,
|
closeOnClickOutside,
|
||||||
centered = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -62,7 +60,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||||
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@@ -73,7 +71,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const env = createEnv({
|
|||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
KAN_ADMIN_API_KEY: z.string().optional(),
|
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
@@ -52,8 +51,6 @@ export const env = createEnv({
|
|||||||
VK_CLIENT_SECRET: z.string().optional(),
|
VK_CLIENT_SECRET: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||||
NOVU_API_KEY: z.string().optional(),
|
|
||||||
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
|
||||||
// Generic OIDC Provider
|
// Generic OIDC Provider
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -78,7 +75,6 @@ export const env = createEnv({
|
|||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||||
EMAIL_FROM: z.string().optional(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,19 +87,11 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
||||||
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
|
|
||||||
.string()
|
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
|
||||||
.refine(
|
|
||||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
.transform((s) => (s === "" ? undefined : s))
|
||||||
@@ -141,9 +129,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
|
|
||||||
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
|
||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface UseDragToScrollOptions {
|
|
||||||
/**
|
|
||||||
* Whether drag-to-scroll is enabled
|
|
||||||
*/
|
|
||||||
enabled?: boolean;
|
|
||||||
/**
|
|
||||||
* The direction to scroll
|
|
||||||
*/
|
|
||||||
direction?: "horizontal" | "vertical" | "both";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to enable drag-to-scroll functionality on a scrollable element
|
|
||||||
* @param options Configuration options
|
|
||||||
* @returns Ref to attach to the scrollable element and mouse event handlers
|
|
||||||
*/
|
|
||||||
export function useDragToScroll({
|
|
||||||
enabled = true,
|
|
||||||
direction = "horizontal",
|
|
||||||
}: UseDragToScrollOptions = {}) {
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const startPosRef = useRef({ x: 0, y: 0 });
|
|
||||||
const scrollStartRef = useRef({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || !isDragging) return;
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - startPosRef.current.x;
|
|
||||||
const deltaY = e.clientY - startPosRef.current.y;
|
|
||||||
|
|
||||||
if (direction === "horizontal" || direction === "both") {
|
|
||||||
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
|
|
||||||
}
|
|
||||||
if (direction === "vertical" || direction === "both") {
|
|
||||||
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
document.body.style.cursor = "grabbing";
|
|
||||||
document.body.style.userSelect = "none";
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
document.body.style.cursor = "";
|
|
||||||
document.body.style.userSelect = "";
|
|
||||||
};
|
|
||||||
}, [enabled, isDragging, direction]);
|
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const container = scrollRef.current;
|
|
||||||
|
|
||||||
// Check if the click is on an interactive or draggable element
|
|
||||||
// We need to be careful not to interfere with react-beautiful-dnd dragging
|
|
||||||
const isInteractiveElement =
|
|
||||||
target.closest("a") ||
|
|
||||||
target.closest("button") ||
|
|
||||||
target.closest("input") ||
|
|
||||||
target.closest("textarea") ||
|
|
||||||
target.closest("[role='button']") ||
|
|
||||||
target.closest("[draggable='true']") ||
|
|
||||||
target.closest(".react-beautiful-dnd-drag-handle") ||
|
|
||||||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
|
|
||||||
target.closest("[data-rbd-draggable-id]");
|
|
||||||
|
|
||||||
// Don't start dragging if clicking on interactive elements
|
|
||||||
if (isInteractiveElement) return;
|
|
||||||
|
|
||||||
// Enable drag-to-scroll for any non-interactive element within the container
|
|
||||||
if (container.contains(target)) {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
scrollStartRef.current = {
|
|
||||||
x: container.scrollLeft,
|
|
||||||
y: container.scrollTop,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
ref: scrollRef,
|
|
||||||
onMouseDown: handleMouseDown,
|
|
||||||
isDragging,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { Locale as DateFnsLocale } from "date-fns";
|
|
||||||
import { useLingui } from "@lingui/react";
|
import { useLingui } from "@lingui/react";
|
||||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
|
||||||
|
|
||||||
import type { Locale } from "~/locales";
|
import type { Locale } from "~/locales";
|
||||||
import { useLinguiContext } from "~/providers/lingui";
|
import { useLinguiContext } from "~/providers/lingui";
|
||||||
@@ -10,20 +8,6 @@ export function useLocalisation() {
|
|||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
const { locale, setLocale, availableLocales } = useLinguiContext();
|
const { locale, setLocale, availableLocales } = useLinguiContext();
|
||||||
|
|
||||||
const dateLocaleMap: Partial<Record<Locale, DateFnsLocale>> = {
|
|
||||||
en: enGB,
|
|
||||||
fr,
|
|
||||||
de,
|
|
||||||
es,
|
|
||||||
it,
|
|
||||||
nl,
|
|
||||||
ru,
|
|
||||||
pl,
|
|
||||||
"pt-BR": ptBR,
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
|
||||||
|
|
||||||
const handleSetLocale = async (newLocale: Locale) => {
|
const handleSetLocale = async (newLocale: Locale) => {
|
||||||
await activateLocale(newLocale);
|
await activateLocale(newLocale);
|
||||||
setLocale(newLocale);
|
setLocale(newLocale);
|
||||||
@@ -31,9 +15,9 @@ export function useLocalisation() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale,
|
||||||
dateLocale: currentDateLocale,
|
|
||||||
setLocale: handleSetLocale,
|
setLocale: handleSetLocale,
|
||||||
availableLocales,
|
availableLocales,
|
||||||
|
formatDate: i18n.date,
|
||||||
formatNumber: i18n.number,
|
formatNumber: i18n.number,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import type { Permission } from "@kan/shared";
|
|
||||||
import { useContext } from "react";
|
|
||||||
|
|
||||||
import { WorkspaceContext } from "~/providers/workspace";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
interface UsePermissionsResult {
|
|
||||||
permissions: Permission[];
|
|
||||||
role: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
hasPermission: (permission: Permission) => boolean;
|
|
||||||
canViewCard: boolean;
|
|
||||||
canCreateCard: boolean;
|
|
||||||
canEditCard: boolean;
|
|
||||||
canDeleteCard: boolean;
|
|
||||||
canCreateList: boolean;
|
|
||||||
canEditList: boolean;
|
|
||||||
canDeleteList: boolean;
|
|
||||||
canCreateBoard: boolean;
|
|
||||||
canEditBoard: boolean;
|
|
||||||
canDeleteBoard: boolean;
|
|
||||||
canArchiveBoard: boolean;
|
|
||||||
canViewComment: boolean;
|
|
||||||
canCreateComment: boolean;
|
|
||||||
canEditComment: boolean;
|
|
||||||
canDeleteComment: boolean;
|
|
||||||
canInviteMember: boolean;
|
|
||||||
canEditMember: boolean;
|
|
||||||
canRemoveMember: boolean;
|
|
||||||
canViewWorkspace: boolean;
|
|
||||||
canEditWorkspace: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePermissions(): UsePermissionsResult {
|
|
||||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
|
||||||
const workspaceContext = useContext(WorkspaceContext);
|
|
||||||
|
|
||||||
// If WorkspaceProvider is not available, return safe defaults
|
|
||||||
if (!workspaceContext) {
|
|
||||||
const emptyPermissions: UsePermissionsResult = {
|
|
||||||
permissions: [],
|
|
||||||
role: null,
|
|
||||||
isLoading: false,
|
|
||||||
hasPermission: () => false,
|
|
||||||
canViewCard: false,
|
|
||||||
canCreateCard: false,
|
|
||||||
canEditCard: false,
|
|
||||||
canDeleteCard: false,
|
|
||||||
canCreateList: false,
|
|
||||||
canEditList: false,
|
|
||||||
canDeleteList: false,
|
|
||||||
canCreateBoard: false,
|
|
||||||
canEditBoard: false,
|
|
||||||
canDeleteBoard: false,
|
|
||||||
canArchiveBoard: false,
|
|
||||||
canViewComment: false,
|
|
||||||
canCreateComment: false,
|
|
||||||
canEditComment: false,
|
|
||||||
canDeleteComment: false,
|
|
||||||
canInviteMember: false,
|
|
||||||
canEditMember: false,
|
|
||||||
canRemoveMember: false,
|
|
||||||
canViewWorkspace: false,
|
|
||||||
canEditWorkspace: false,
|
|
||||||
};
|
|
||||||
return emptyPermissions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { workspace } = workspaceContext;
|
|
||||||
|
|
||||||
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
|
||||||
{ workspacePublicId: workspace.publicId },
|
|
||||||
{
|
|
||||||
enabled: !!workspace.publicId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const permissions = (data?.permissions ?? []) as Permission[];
|
|
||||||
const role = data?.role ?? null;
|
|
||||||
|
|
||||||
const hasPermission = (permission: Permission): boolean => {
|
|
||||||
return permissions.includes(permission);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
permissions,
|
|
||||||
role,
|
|
||||||
isLoading,
|
|
||||||
hasPermission,
|
|
||||||
canViewCard: hasPermission("card:view"),
|
|
||||||
canCreateCard: hasPermission("card:create"),
|
|
||||||
canEditCard: hasPermission("card:edit"),
|
|
||||||
canDeleteCard: hasPermission("card:delete"),
|
|
||||||
canCreateList: hasPermission("list:create"),
|
|
||||||
canEditList: hasPermission("list:edit"),
|
|
||||||
canDeleteList: hasPermission("list:delete"),
|
|
||||||
canCreateBoard: hasPermission("board:create"),
|
|
||||||
canEditBoard: hasPermission("board:edit"),
|
|
||||||
canDeleteBoard: hasPermission("board:delete"),
|
|
||||||
canArchiveBoard: hasPermission("board:edit"),
|
|
||||||
canViewComment: hasPermission("comment:view"),
|
|
||||||
canCreateComment: hasPermission("comment:create"),
|
|
||||||
canEditComment: hasPermission("comment:edit"),
|
|
||||||
canDeleteComment: hasPermission("comment:delete"),
|
|
||||||
canInviteMember: hasPermission("member:invite"),
|
|
||||||
canEditMember: hasPermission("member:edit"),
|
|
||||||
canRemoveMember: hasPermission("member:remove"),
|
|
||||||
canViewWorkspace: hasPermission("workspace:view"),
|
|
||||||
canEditWorkspace: hasPermission("workspace:edit"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
6
apps/web/src/instrumentation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await require("pino");
|
||||||
|
await require("next-logger");
|
||||||
|
}
|
||||||
|
}
|
||||||
2576
apps/web/src/locales/de/messages.po
Normal file
2576
apps/web/src/locales/es/messages.po
Normal file
2576
apps/web/src/locales/fr/messages.po
Normal file
@@ -7,7 +7,6 @@ export const locales = [
|
|||||||
"nl",
|
"nl",
|
||||||
"ru",
|
"ru",
|
||||||
"pl",
|
"pl",
|
||||||
"ptbr"
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Locale = (typeof locales)[number];
|
export type Locale = (typeof locales)[number];
|
||||||
@@ -23,5 +22,4 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
ru: "Русский",
|
||||||
pl: "Polski",
|
pl: "Polski",
|
||||||
ptbr: "Português",
|
|
||||||
};
|
};
|
||||||
|
|||||||
2576
apps/web/src/locales/it/messages.po
Normal file
2576
apps/web/src/locales/nl/messages.po
Normal file
2581
apps/web/src/locales/pl/messages.po
Normal file
2576
apps/web/src/locales/ru/messages.po
Normal file
@@ -12,7 +12,6 @@ import posthog from "posthog-js";
|
|||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||||
import { ModalProvider } from "~/providers/modal";
|
import { ModalProvider } from "~/providers/modal";
|
||||||
import { PopupProvider } from "~/providers/popup";
|
import { PopupProvider } from "~/providers/popup";
|
||||||
@@ -81,23 +80,21 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
)}
|
)}
|
||||||
<script src="/__ENV.js" />
|
<script src="/__ENV.js" />
|
||||||
<main className="font-sans">
|
<main className="font-sans">
|
||||||
<KeyboardShortcutProvider>
|
<LinguiProviderWrapper>
|
||||||
<LinguiProviderWrapper>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ModalProvider>
|
||||||
<ModalProvider>
|
<PopupProvider>
|
||||||
<PopupProvider>
|
{posthogKey ? (
|
||||||
{posthogKey ? (
|
<PostHogProvider client={posthog}>
|
||||||
<PostHogProvider client={posthog}>
|
{getLayout(<Component {...pageProps} />)}
|
||||||
{getLayout(<Component {...pageProps} />)}
|
</PostHogProvider>
|
||||||
</PostHogProvider>
|
) : (
|
||||||
) : (
|
getLayout(<Component {...pageProps} />)
|
||||||
getLayout(<Component {...pageProps} />)
|
)}
|
||||||
)}
|
</PopupProvider>
|
||||||
</PopupProvider>
|
</ModalProvider>
|
||||||
</ModalProvider>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
</LinguiProviderWrapper>
|
||||||
</LinguiProviderWrapper>
|
|
||||||
</KeyboardShortcutProvider>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,34 +2,9 @@ import { toNodeHandler } from "better-auth/node";
|
|||||||
|
|
||||||
import { initAuth } from "@kan/auth/server";
|
import { initAuth } from "@kan/auth/server";
|
||||||
import { createDrizzleClient } from "@kan/db/client";
|
import { createDrizzleClient } from "@kan/db/client";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
export const auth = initAuth(createDrizzleClient());
|
export const auth = initAuth(createDrizzleClient());
|
||||||
|
|
||||||
const authHandler = toNodeHandler(auth.handler);
|
export default toNodeHandler(auth.handler);
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
async (req, res) => {
|
|
||||||
/**
|
|
||||||
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
|
||||||
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
|
||||||
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
|
||||||
*/
|
|
||||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
||||||
if (forwardedProto) {
|
|
||||||
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
|
||||||
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const forwardedHost = req.headers["x-forwarded-host"];
|
|
||||||
if (forwardedHost) {
|
|
||||||
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
|
||||||
req.headers["host"] = h?.split(",")[0]?.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await authHandler(req, res);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
export default withRateLimit(
|
res: NextApiResponse,
|
||||||
{ points: 100, duration: 60 },
|
) {
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -18,11 +17,12 @@ export default withRateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const downloadFilename = typeof filename === "string"
|
const downloadUrl = decodeURIComponent(url);
|
||||||
? encodeURIComponent(filename)
|
const downloadFilename =
|
||||||
: "attachment";
|
(typeof filename === "string" ? decodeURIComponent(filename) : null) ??
|
||||||
|
"attachment";
|
||||||
|
|
||||||
const upstream = await fetch(url);
|
const upstream = await fetch(downloadUrl);
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
return res.status(upstream.status).json({
|
return res.status(upstream.status).json({
|
||||||
@@ -36,7 +36,7 @@ export default withRateLimit(
|
|||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
`attachment; filename="${downloadFilename}"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = await upstream.arrayBuffer();
|
const buffer = await upstream.arrayBuffer();
|
||||||
@@ -45,5 +45,4 @@ export default withRateLimit(
|
|||||||
console.error("Error downloading attachment:", error);
|
console.error("Error downloading attachment:", error);
|
||||||
return res.status(500).json({ message: "Failed to download attachment" });
|
return res.status(500).json({ message: "Failed to download attachment" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
export default withRateLimit(
|
res: NextApiResponse,
|
||||||
{ points: 100, duration: 60 },
|
) {
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -21,5 +20,4 @@ export default withRateLimit(
|
|||||||
console.error("Error fetching OSS friends:", error);
|
console.error("Error fetching OSS friends:", error);
|
||||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { env } from "next-runtime-env";
|
|||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -31,5 +31,4 @@ export default withRateLimit(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating portal session" });
|
return res.status(500).json({ error: "Error creating portal session" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import { createNextApiContext } from "@kan/api/trpc";
|
|||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const workspaceSlugSchema = z
|
const workspaceSlugSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(64)
|
.max(24)
|
||||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||||
|
|
||||||
interface CheckoutSessionRequest {
|
interface CheckoutSessionRequest {
|
||||||
@@ -22,9 +21,10 @@ interface CheckoutSessionRequest {
|
|||||||
stripeCustomerId: string;
|
stripeCustomerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
const stripe = createStripeClient();
|
const stripe = createStripeClient();
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@@ -88,7 +88,6 @@ export default withRateLimit(
|
|||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
payment_method_collection: "always",
|
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||||
@@ -115,5 +114,4 @@ export default withRateLimit(
|
|||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return res.status(500).json({ error: "Error creating checkout session" });
|
return res.status(500).json({ error: "Error creating checkout session" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { createNextApiContext } from "@kan/api/trpc";
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { integrations } from "@kan/db/schema";
|
import { integrations } from "@kan/db/schema";
|
||||||
import { addYears } from "date-fns";
|
import { addYears } from "date-fns";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
|
) {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return res.status(405).json({ message: "Method not allowed" });
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
}
|
}
|
||||||
@@ -48,5 +48,4 @@ export default withRateLimit(
|
|||||||
console.error("Trello authentication error:", err);
|
console.error("Trello authentication error:", err);
|
||||||
return res.status(400).json({ message: "Trello authentication failed" });
|
return res.status(400).json({ message: "Trello authentication failed" });
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
|
||||||
@@ -3,14 +3,12 @@ import { createNextApiHandler } from "@trpc/server/adapters/next";
|
|||||||
|
|
||||||
import { appRouter } from "@kan/api/root";
|
import { appRouter } from "@kan/api/root";
|
||||||
import { createTRPCContext } from "@kan/api/trpc";
|
import { createTRPCContext } from "@kan/api/trpc";
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
const nextApiHandler = createNextApiHandler({
|
const nextApiHandler = createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createTRPCContext,
|
createContext: createTRPCContext,
|
||||||
onError:
|
onError:
|
||||||
env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||||
@@ -19,16 +17,11 @@ const nextApiHandler = createNextApiHandler({
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withRateLimit(
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
{ points: 100, duration: 60 },
|
if (req.method === "OPTIONS") {
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res.writeHead(200);
|
||||||
if (req.method === "OPTIONS") {
|
return res.end();
|
||||||
res.writeHead(200);
|
}
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await nextApiHandler(req, res);
|
return nextApiHandler(req, res);
|
||||||
return result;
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { Novu } from "@novu/api";
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenPayloadSchema = z.object({
|
|
||||||
subscriberId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ResponseData =
|
|
||||||
| { success: true }
|
|
||||||
| { success: false; error: string; code?: string };
|
|
||||||
|
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
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,
|
|
||||||
error: "Unsubscribe endpoint is not available.",
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
return res.status(405).json({
|
|
||||||
success: false,
|
|
||||||
error: "Method not allowed.",
|
|
||||||
code: "METHOD_NOT_ALLOWED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = requestSchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Invalid request payload.",
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: "Unsubscribe service is not configured.",
|
|
||||||
code: "NOT_CONFIGURED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verified = await jwtVerify(
|
|
||||||
parsedBody.data.token,
|
|
||||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
|
||||||
{
|
|
||||||
// We intentionally do not use exp/iat claims –
|
|
||||||
// tokens are long-lived and validated only by signature + payload.
|
|
||||||
clockTolerance: "0s",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
payload = tokenPayloadSchema.parse(verified.payload);
|
|
||||||
} catch {
|
|
||||||
return res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
error: "Your unsubscribe link is invalid or has expired.",
|
|
||||||
code: "INVALID_TOKEN",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await novu.subscribers.preferences.update(
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
email: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
payload.subscriberId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update Novu preferences", error);
|
|
||||||
return res.status(502).json({
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
"We could not update your email preferences right now. Please try again later.",
|
|
||||||
code: "NOVU_ERROR",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({ success: true });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { Upload } from "@aws-sdk/lib-storage";
|
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
|
||||||
import { assertPermission } from "@kan/api/utils/permissions";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
|
||||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
|
||||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
|
||||||
import { createS3Client, generateUID } from "@kan/shared/utils";
|
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
|
|
||||||
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
|
||||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
api: {
|
|
||||||
bodyParser: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { user, db } = await createNextApiContext(req);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
|
||||||
if (!bucket) {
|
|
||||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardPublicId = req.query.cardPublicId;
|
|
||||||
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
|
|
||||||
return res.status(400).json({ error: "Invalid cardPublicId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = req.headers["content-type"];
|
|
||||||
const contentLengthHeader = req.headers["content-length"];
|
|
||||||
const contentLength = contentLengthHeader
|
|
||||||
? Number.parseInt(contentLengthHeader, 10)
|
|
||||||
: NaN;
|
|
||||||
|
|
||||||
if (typeof contentType !== "string") {
|
|
||||||
return res.status(400).json({ error: "Missing content type" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
|
||||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLength > MAX_SIZE_BYTES) {
|
|
||||||
return res.status(400).json({ error: "File too large" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFilenameHeader =
|
|
||||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
|
||||||
|
|
||||||
const sanitizedFilename = originalFilenameHeader
|
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
||||||
.substring(0, 200);
|
|
||||||
|
|
||||||
// Get card and check permissions
|
|
||||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
|
||||||
db,
|
|
||||||
cardPublicId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!card) {
|
|
||||||
return res.status(404).json({ error: "Card not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has permission to edit the card
|
|
||||||
try {
|
|
||||||
await assertPermission(db, user.id, card.workspaceId, "card:edit");
|
|
||||||
} catch {
|
|
||||||
return res.status(403).json({ error: "Permission denied" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
|
|
||||||
|
|
||||||
const client = createS3Client();
|
|
||||||
|
|
||||||
const upload = new Upload({
|
|
||||||
client,
|
|
||||||
params: {
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: s3Key,
|
|
||||||
Body: req,
|
|
||||||
ContentType: contentType,
|
|
||||||
ContentLength: contentLength,
|
|
||||||
},
|
|
||||||
leavePartsOnError: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await upload.done();
|
|
||||||
|
|
||||||
// Create attachment record and log activity
|
|
||||||
const attachment = await cardAttachmentRepo.create(db, {
|
|
||||||
cardId: card.id,
|
|
||||||
filename: sanitizedFilename,
|
|
||||||
originalFilename: originalFilenameHeader,
|
|
||||||
contentType,
|
|
||||||
size: contentLength,
|
|
||||||
s3Key,
|
|
||||||
createdBy: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!attachment) {
|
|
||||||
return res.status(500).json({ error: "Failed to create attachment" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await cardActivityRepo.create(db, {
|
|
||||||
type: "card.updated.attachment.added",
|
|
||||||
cardId: card.id,
|
|
||||||
attachmentId: attachment.id,
|
|
||||||
toTitle: originalFilenameHeader,
|
|
||||||
createdBy: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({ attachment });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Attachment upload failed", error);
|
|
||||||
return res.status(500).json({ error: "Internal server error" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
import { createNextApiContext } from "@kan/api/trpc";
|
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
import { createS3Client } from "@kan/shared/utils";
|
|
||||||
|
|
||||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
|
||||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
api: {
|
|
||||||
bodyParser: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default withRateLimit(
|
|
||||||
{ points: 100, duration: 60 },
|
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { user, db } = await createNextApiContext(req);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
|
|
||||||
if (!bucket) {
|
|
||||||
return res.status(500).json({ error: "Avatar bucket not configured" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = req.headers["content-type"];
|
|
||||||
const contentLengthHeader = req.headers["content-length"];
|
|
||||||
const contentLength = contentLengthHeader
|
|
||||||
? Number.parseInt(contentLengthHeader, 10)
|
|
||||||
: NaN;
|
|
||||||
|
|
||||||
if (typeof contentType !== "string") {
|
|
||||||
return res.status(400).json({ error: "Missing content type" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowedContentTypes.includes(contentType)) {
|
|
||||||
return res.status(400).json({ error: "Invalid content type" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
|
||||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLength > MAX_SIZE_BYTES) {
|
|
||||||
return res.status(400).json({ error: "File too large" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFilenameHeader =
|
|
||||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
|
||||||
|
|
||||||
const sanitizedFilename = originalFilenameHeader
|
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
||||||
.substring(0, 200);
|
|
||||||
|
|
||||||
const s3Key = `${user.id}/${sanitizedFilename}`;
|
|
||||||
|
|
||||||
const client = createS3Client();
|
|
||||||
|
|
||||||
// Upload the file to S3
|
|
||||||
await client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: bucket,
|
|
||||||
Key: s3Key,
|
|
||||||
Body: req,
|
|
||||||
ContentType: contentType,
|
|
||||||
ContentLength: contentLength,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update user image in database
|
|
||||||
const updatedUser = await userRepo.update(db, user.id, {
|
|
||||||
image: s3Key,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
key: s3Key,
|
|
||||||
filename: sanitizedFilename,
|
|
||||||
contentType,
|
|
||||||
size: contentLength,
|
|
||||||
user: updatedUser,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Avatar upload failed", error);
|
|
||||||
return res.status(500).json({ error: "Internal server error" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
70
apps/web/src/pages/api/upload/image.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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 client = new S3Client({
|
||||||
|
region: env.S3_REGION ?? "",
|
||||||
|
endpoint: env.S3_ENDPOINT ?? "",
|
||||||
|
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
||||||
|
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedUrl = await getSignedUrl(
|
||||||
|
// @ts-ignore
|
||||||
|
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,26 +6,25 @@ import { appRouter } from "@kan/api";
|
|||||||
import { createRESTContext } from "@kan/api/trpc";
|
import { createRESTContext } from "@kan/api/trpc";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
export default async function handler(
|
||||||
{ points: 100, duration: 60 },
|
req: NextApiRequest,
|
||||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
res: NextApiResponse,
|
||||||
await cors(req, res);
|
) {
|
||||||
|
await cors(req, res);
|
||||||
|
|
||||||
const openApiHandler = createOpenApiNextHandler({
|
const openApiHandler = createOpenApiNextHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createRESTContext,
|
createContext: createRESTContext,
|
||||||
onError:
|
onError:
|
||||||
env.NODE_ENV === "development"
|
env.NODE_ENV === "development"
|
||||||
? ({ path, error }) => {
|
? ({ path, error }) => {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await openApiHandler(req, res);
|
return await openApiHandler(req, res);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { openApiDocument } from "@kan/api/openapi";
|
import { openApiDocument } from "@kan/api/openapi";
|
||||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
|
||||||
|
|
||||||
export default withRateLimit(
|
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
{ points: 100, duration: 60 },
|
res.status(200).send(openApiDocument);
|
||||||
(req: NextApiRequest, res: NextApiResponse) => {
|
};
|
||||||
res.status(200).send(openApiDocument);
|
|
||||||
},
|
export default handler;
|
||||||
);
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import ApiSettings from "~/views/settings/ApiSettings";
|
import ApiSettings from "~/views/settings/ApiSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const ApiSettingsPage: NextPageWithLayout = () => {
|
const ApiSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="api">
|
<SettingsLayout currentTab="api">
|
||||||
<ApiSettings />
|
<ApiSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import BillingSettings from "~/views/settings/BillingSettings";
|
import BillingSettings from "~/views/settings/BillingSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const BillingSettingsPage: NextPageWithLayout = () => {
|
const BillingSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="billing">
|
<SettingsLayout currentTab="billing">
|
||||||
<BillingSettings />
|
<BillingSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
import IntegrationsSettings from "~/views/settings/IntegrationsSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
const IntegrationsSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="integrations">
|
<SettingsLayout currentTab="integrations">
|
||||||
<IntegrationsSettings />
|
<IntegrationsSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
|
||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
import PermissionsSettings from "~/views/settings/PermissionsSettings";
|
|
||||||
|
|
||||||
const PermissionsSettingsPage: NextPageWithLayout = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsLayout currentTab="permissions">
|
|
||||||
<PermissionsSettings />
|
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
|
||||||
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
|
||||||
|
|
||||||
export default PermissionsSettingsPage;
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,13 +2,11 @@ import type { NextPageWithLayout } from "~/pages/_app";
|
|||||||
import { getDashboardLayout } from "~/components/Dashboard";
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
import WorkspaceSettings from "~/views/settings/WorkspaceSettings";
|
||||||
import Popup from "~/components/Popup";
|
|
||||||
|
|
||||||
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
const WorkspaceSettingsPage: NextPageWithLayout = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout currentTab="workspace">
|
<SettingsLayout currentTab="workspace">
|
||||||
<WorkspaceSettings />
|
<WorkspaceSettings />
|
||||||
<Popup />
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import { PageHead } from "~/components/PageHead";
|
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
|
||||||
|
|
||||||
type UnsubscribeStatus = "idle" | "processing" | "success" | "error";
|
|
||||||
|
|
||||||
export default function UnsubscribePage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [status, setStatus] = useState<UnsubscribeStatus>("idle");
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!router.isReady) return;
|
|
||||||
const value = router.query.token;
|
|
||||||
if (typeof value === "string") {
|
|
||||||
setToken(value);
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
setToken(value[0] ?? "");
|
|
||||||
} else {
|
|
||||||
setToken("");
|
|
||||||
}
|
|
||||||
}, [router.isReady, router.query.token]);
|
|
||||||
|
|
||||||
const handleUnsubscribe = async () => {
|
|
||||||
if (!token) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
t`Your unsubscribe link is missing a token. Please open the latest email and try again.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("processing");
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/unsubscribe", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const payload = (await response.json().catch(() => null)) as {
|
|
||||||
error?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
payload?.error ??
|
|
||||||
"We couldn't update your preferences. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("success");
|
|
||||||
} catch (error) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
t`We couldn't update your preferences. Please try again.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = t`Unsubscribe`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHead title={`${title} | kan.bn`} />
|
|
||||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
|
||||||
<PatternedBackground />
|
|
||||||
<div className="z-10 w-full max-w-md space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
|
||||||
{t`Do you want to unsubscribe?`}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
|
|
||||||
{t`Confirm your email preferences:`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Button
|
|
||||||
onClick={handleUnsubscribe}
|
|
||||||
disabled={status === "success"}
|
|
||||||
isLoading={status === "processing"}
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
{t`Unsubscribe`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{status === "success" && (
|
|
||||||
<p className="text-center text-sm text-light-900 dark:text-dark-800">
|
|
||||||
{t`You have been unsubscribed!`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status === "error" && (
|
|
||||||
<p className="mx-auto max-w-[300px] text-center text-sm font-medium text-red-600 dark:text-red-400">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,611 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogBackdrop,
|
|
||||||
DialogPanel,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@headlessui/react";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { HiXMark } from "react-icons/hi2";
|
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { useEventListener } from "~/hooks/useEventListener";
|
|
||||||
|
|
||||||
const ModifierKey = {
|
|
||||||
CONTROL: "CONTROL",
|
|
||||||
META: "META",
|
|
||||||
ALT: "ALT",
|
|
||||||
SHIFT: "SHIFT",
|
|
||||||
} as const;
|
|
||||||
type ModifierKey = (typeof ModifierKey)[keyof typeof ModifierKey];
|
|
||||||
|
|
||||||
const ModifierKeyInfo: Record<
|
|
||||||
ModifierKey,
|
|
||||||
{
|
|
||||||
macSymbol: string;
|
|
||||||
winName: string;
|
|
||||||
linuxName: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
CONTROL: { macSymbol: "⌃", winName: "Ctrl", linuxName: "Ctrl" },
|
|
||||||
META: { macSymbol: "⌘", winName: "Win", linuxName: "Super" },
|
|
||||||
ALT: { macSymbol: "⌥", winName: "Alt", linuxName: "Alt" },
|
|
||||||
SHIFT: { macSymbol: "⇧", winName: "Shift", linuxName: "Shift" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShortcutGroup = {
|
|
||||||
GENERAL: "GENERAL",
|
|
||||||
NAVIGATION: "NAVIGATION",
|
|
||||||
ACTIONS: "ACTIONS",
|
|
||||||
} as const;
|
|
||||||
type ShortcutGroup = (typeof ShortcutGroup)[keyof typeof ShortcutGroup];
|
|
||||||
|
|
||||||
const getShortcutGroupInfo = (): Record<ShortcutGroup, { label: string }> => ({
|
|
||||||
GENERAL: { label: t`General` },
|
|
||||||
NAVIGATION: { label: t`Navigation` },
|
|
||||||
ACTIONS: { label: t`Actions` },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface KeyStroke {
|
|
||||||
key: string;
|
|
||||||
modifiers?: ModifierKey[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Press {
|
|
||||||
type: "PRESS";
|
|
||||||
stroke: KeyStroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Sequence {
|
|
||||||
type: "SEQUENCE";
|
|
||||||
strokes: KeyStroke[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KeyboardShortcut = {
|
|
||||||
action: () => void;
|
|
||||||
description: string;
|
|
||||||
group: ShortcutGroup;
|
|
||||||
} & (Press | Sequence);
|
|
||||||
|
|
||||||
interface ShortcutTreeStepNode {
|
|
||||||
type: "STEP";
|
|
||||||
children: ShortcutTreeLevel;
|
|
||||||
}
|
|
||||||
interface ShortcutTreeActionNode {
|
|
||||||
type: "ACTION";
|
|
||||||
shortcut: KeyboardShortcut;
|
|
||||||
}
|
|
||||||
type ShortcutTreeNode = ShortcutTreeStepNode | ShortcutTreeActionNode;
|
|
||||||
type ShortcutTreeLevel = Record<string, ShortcutTreeNode>;
|
|
||||||
|
|
||||||
const SEQUENCE_TIMEOUT_MS = 1000;
|
|
||||||
|
|
||||||
const ShortcutConflictCode = {
|
|
||||||
ACTION_ON_SEQUENCE: "ACTION_ON_SEQUENCE",
|
|
||||||
DUPLICATE_ACTION: "DUPLICATE_ACTION",
|
|
||||||
SEQUENCE_ON_ACTION: "SEQUENCE_ON_ACTION",
|
|
||||||
} as const;
|
|
||||||
type ShortcutConflictCode = keyof typeof ShortcutConflictCode;
|
|
||||||
|
|
||||||
interface ShortcutConflictErrorOptions {
|
|
||||||
code: ShortcutConflictCode;
|
|
||||||
shortcut: KeyboardShortcut;
|
|
||||||
conflictPath: string;
|
|
||||||
existingShortcut?: KeyboardShortcut;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShortcutConflictError extends Error {
|
|
||||||
public readonly code: ShortcutConflictCode;
|
|
||||||
public readonly shortcut: KeyboardShortcut;
|
|
||||||
public readonly conflictPath: string;
|
|
||||||
public readonly existingShortcut?: KeyboardShortcut;
|
|
||||||
|
|
||||||
constructor(options: ShortcutConflictErrorOptions) {
|
|
||||||
super(ShortcutConflictError.formatMessage(options));
|
|
||||||
this.name = "ShortcutConflictError";
|
|
||||||
this.code = options.code;
|
|
||||||
this.shortcut = options.shortcut;
|
|
||||||
this.conflictPath = options.conflictPath;
|
|
||||||
this.existingShortcut = options.existingShortcut;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static stringifyShortcut(shortcut: KeyboardShortcut): string {
|
|
||||||
if (shortcut.type === "SEQUENCE") {
|
|
||||||
return shortcut.strokes.map(serializeKeyStroke).join(" → ");
|
|
||||||
}
|
|
||||||
return serializeKeyStroke(shortcut.stroke);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static formatMessage(options: ShortcutConflictErrorOptions): string {
|
|
||||||
const formatted = ShortcutConflictError.stringifyShortcut(options.shortcut);
|
|
||||||
switch (options.code) {
|
|
||||||
case ShortcutConflictCode.ACTION_ON_SEQUENCE:
|
|
||||||
return `Cannot register "${formatted}": desired action conflicts with existing sequence at "${options.conflictPath}"`;
|
|
||||||
case ShortcutConflictCode.DUPLICATE_ACTION:
|
|
||||||
return `Cannot register "${formatted}": action already registered`;
|
|
||||||
case ShortcutConflictCode.SEQUENCE_ON_ACTION:
|
|
||||||
return `Cannot register "${formatted}": desired sequence conflicts with existing action at "${options.conflictPath}"`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyboardShortcutContextType {
|
|
||||||
registerShortcut: (shortcut: KeyboardShortcut) => () => void;
|
|
||||||
openLegend: () => void;
|
|
||||||
openLegendKeys: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeyboardShortcutContext = createContext<
|
|
||||||
KeyboardShortcutContextType | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
export function KeyboardShortcutProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const treeRootRef = useRef<ShortcutTreeLevel>({});
|
|
||||||
const currentNodeRef = useRef<ShortcutTreeLevel>(treeRootRef.current);
|
|
||||||
const shortcutsRef = useRef<Map<string, KeyboardShortcut>>(new Map());
|
|
||||||
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
|
||||||
|
|
||||||
const openLegendShortcut: KeyboardShortcut = useMemo(
|
|
||||||
() => ({
|
|
||||||
type: "PRESS",
|
|
||||||
stroke: {
|
|
||||||
key: "/",
|
|
||||||
modifiers: ["META"],
|
|
||||||
},
|
|
||||||
action: () => setIsLegendOpen(true),
|
|
||||||
description: t`Open keyboard shortcuts`,
|
|
||||||
group: ShortcutGroup.GENERAL,
|
|
||||||
}),
|
|
||||||
[setIsLegendOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
|
||||||
if (isTypingInInput(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sequenceTimeoutRef.current) {
|
|
||||||
clearTimeout(sequenceTimeoutRef.current);
|
|
||||||
sequenceTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedKey = serializeEvent(event);
|
|
||||||
const node = currentNodeRef.current[serializedKey];
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
currentNodeRef.current = treeRootRef.current;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (node.type) {
|
|
||||||
case "STEP":
|
|
||||||
event.preventDefault();
|
|
||||||
currentNodeRef.current = node.children;
|
|
||||||
sequenceTimeoutRef.current = setTimeout(() => {
|
|
||||||
currentNodeRef.current = treeRootRef.current;
|
|
||||||
}, SEQUENCE_TIMEOUT_MS);
|
|
||||||
return;
|
|
||||||
case "ACTION":
|
|
||||||
event.preventDefault();
|
|
||||||
node.shortcut.action();
|
|
||||||
currentNodeRef.current = treeRootRef.current;
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const registerShortcut = useCallback(
|
|
||||||
(shortcut: KeyboardShortcut): (() => void) => {
|
|
||||||
const strokes =
|
|
||||||
shortcut.type === "SEQUENCE" ? shortcut.strokes : [shortcut.stroke];
|
|
||||||
|
|
||||||
const path = strokes.map(serializeKeyStroke);
|
|
||||||
const pathKey = path.join(" → ");
|
|
||||||
|
|
||||||
path.reduce((currentLevel, key, i) => {
|
|
||||||
const isLast = i === path.length - 1;
|
|
||||||
const existingNode = currentLevel[key];
|
|
||||||
|
|
||||||
if (env.NODE_ENV === "development" && existingNode) {
|
|
||||||
const conflictPath = path.slice(0, i + 1).join(" → ");
|
|
||||||
validateNoConflict(existingNode, isLast, shortcut, conflictPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLast) {
|
|
||||||
currentLevel[key] = {
|
|
||||||
type: "ACTION",
|
|
||||||
shortcut,
|
|
||||||
};
|
|
||||||
return currentLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingNode?.type === "STEP") {
|
|
||||||
return existingNode.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNode: ShortcutTreeStepNode = {
|
|
||||||
type: "STEP",
|
|
||||||
children: {},
|
|
||||||
};
|
|
||||||
currentLevel[key] = newNode;
|
|
||||||
return newNode.children;
|
|
||||||
}, treeRootRef.current);
|
|
||||||
|
|
||||||
shortcutsRef.current.set(pathKey, shortcut);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
removePathAndPrune(treeRootRef.current, path);
|
|
||||||
shortcutsRef.current.delete(pathKey);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
// Register built-in shortcut to open legend
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = registerShortcut(openLegendShortcut);
|
|
||||||
return cleanup;
|
|
||||||
}, [registerShortcut, openLegendShortcut]);
|
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (sequenceTimeoutRef.current) {
|
|
||||||
clearTimeout(sequenceTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const shortcutsArray = Array.from(shortcutsRef.current.values());
|
|
||||||
const groupedShortcuts = shortcutsArray.reduce<
|
|
||||||
Partial<Record<ShortcutGroup, KeyboardShortcut[]>>
|
|
||||||
>((acc, shortcut) => {
|
|
||||||
const group = shortcut.group;
|
|
||||||
acc[group] ??= [];
|
|
||||||
acc[group].push(shortcut);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const openLegend = useCallback(() => {
|
|
||||||
setIsLegendOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openLegendKeys = useMemo(
|
|
||||||
() => <FormattedShortcut shortcut={openLegendShortcut} />,
|
|
||||||
[openLegendShortcut],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardShortcutContext.Provider
|
|
||||||
value={{ registerShortcut, openLegend, openLegendKeys }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{/* Shortcut Legend */}
|
|
||||||
<Dialog
|
|
||||||
className="relative z-50"
|
|
||||||
open={isLegendOpen}
|
|
||||||
onClose={() => setIsLegendOpen(false)}
|
|
||||||
>
|
|
||||||
<DialogBackdrop
|
|
||||||
transition
|
|
||||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 flex min-h-full w-screen items-center justify-center overflow-y-auto p-4">
|
|
||||||
<DialogPanel
|
|
||||||
transition
|
|
||||||
className="relative w-full max-w-sm transform overflow-hidden rounded-lg border border-light-600 bg-white shadow-3xl-light dark:border-dark-600 dark:bg-dark-100 dark:shadow-3xl-dark"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-light-300 px-6 py-4 dark:border-dark-300">
|
|
||||||
<DialogTitle className="text-[14px] font-semibold text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Keyboard Shortcuts`}
|
|
||||||
</DialogTitle>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsLegendOpen(false)}
|
|
||||||
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-200"
|
|
||||||
>
|
|
||||||
<HiXMark className="h-5 w-5 text-neutral-700 dark:text-dark-700" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto p-6">
|
|
||||||
{shortcutsArray.length === 0 ? (
|
|
||||||
<p className="text-center text-sm text-neutral-600 dark:text-dark-600">
|
|
||||||
{t`No keyboard shortcuts registered.`}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.values(ShortcutGroup).map((group, idx) => {
|
|
||||||
const shortcuts = groupedShortcuts[group];
|
|
||||||
if (!shortcuts?.length) return null;
|
|
||||||
const groupInfo = getShortcutGroupInfo();
|
|
||||||
return (
|
|
||||||
<div key={`${group}-${idx}`}>
|
|
||||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-light-1000 dark:text-dark-1000">
|
|
||||||
{groupInfo[group].label}
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-y-2">
|
|
||||||
{shortcuts.map((shortcut) => (
|
|
||||||
<ShortcutListItem
|
|
||||||
key={shortcut.description}
|
|
||||||
shortcut={shortcut}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</KeyboardShortcutContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to register a keyboard shortcut. Cleanup is handled automatically.
|
|
||||||
* Returns both formatted keys and pre-built tooltip content.
|
|
||||||
*
|
|
||||||
* IMPORTANT: The shortcut object reference must be stable. If it changes
|
|
||||||
* on every render, the shortcut will be continuously registered and unregistered.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { keys, tooltipContent } = useKeyboardShortcut({
|
|
||||||
* type: "PRESS",
|
|
||||||
* stroke: { key: "k", modifiers: ["META"] },
|
|
||||||
* action: () => openCommandPalette(),
|
|
||||||
* description: "Open command palette",
|
|
||||||
* group: "GENERAL"
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Use tooltip for Tooltip component
|
|
||||||
* <Tooltip content={tooltipContent}>
|
|
||||||
* <button>Search</button>
|
|
||||||
* </Tooltip>
|
|
||||||
*
|
|
||||||
* // Or use keys directly for custom formatting
|
|
||||||
* <span>Press {keys} to search</span>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useKeyboardShortcuts(): KeyboardShortcutContextType {
|
|
||||||
const context = useContext(KeyboardShortcutContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useKeyboardShortcuts must be used within KeyboardShortcutProvider",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcut(shortcut: KeyboardShortcut): {
|
|
||||||
keys: ReactNode;
|
|
||||||
tooltipContent: ReactNode;
|
|
||||||
} {
|
|
||||||
const context = useContext(KeyboardShortcutContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useKeyboardShortcut must be used within KeyboardShortcutProvider",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { registerShortcut } = context;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = registerShortcut(shortcut);
|
|
||||||
return cleanup;
|
|
||||||
}, [shortcut, registerShortcut]);
|
|
||||||
|
|
||||||
const keys = <FormattedShortcut shortcut={shortcut} />;
|
|
||||||
const tooltipContent = (
|
|
||||||
<div className="flex flex-row items-center gap-2 text-[11px]">
|
|
||||||
{shortcut.description} {keys}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return { keys, tooltipContent };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShortcutListItem({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm text-dark-50 dark:text-dark-900">
|
|
||||||
{shortcut.description}
|
|
||||||
</span>
|
|
||||||
<FormattedShortcut shortcut={shortcut} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|
||||||
const kbdClassName =
|
|
||||||
"inline-flex h-5 w-5 items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-center text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950";
|
|
||||||
|
|
||||||
const stringifyModifier = (modifier: ModifierKey): string => {
|
|
||||||
const isMac =
|
|
||||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
|
|
||||||
const isLinux =
|
|
||||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
|
|
||||||
|
|
||||||
const info = ModifierKeyInfo[modifier];
|
|
||||||
if (isMac) return info.macSymbol;
|
|
||||||
if (isLinux) return info.linuxName;
|
|
||||||
return info.winName;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatStroke = (stroke: KeyStroke): ReactNode[] => {
|
|
||||||
const parts: ReactNode[] = [];
|
|
||||||
const modifierStrings = stroke.modifiers
|
|
||||||
? stroke.modifiers.map(stringifyModifier)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
modifierStrings.forEach((mod, index) => {
|
|
||||||
parts.push(
|
|
||||||
<kbd key={`mod-${index}-${mod}`} className={kbdClassName}>
|
|
||||||
{mod}
|
|
||||||
</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, 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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="inline-flex flex-shrink-0 items-center gap-1 text-[11px]">
|
|
||||||
{formatStroke(shortcut.stroke)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks for conflicts given existing nodes and current path
|
|
||||||
* Throws an error if conflict is found
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
function validateNoConflict(
|
|
||||||
existingNode: ShortcutTreeNode,
|
|
||||||
isLastKey: boolean,
|
|
||||||
shortcut: KeyboardShortcut,
|
|
||||||
conflictPath: string,
|
|
||||||
): void {
|
|
||||||
if (isLastKey) {
|
|
||||||
if (existingNode.type === "STEP") {
|
|
||||||
throw new ShortcutConflictError({
|
|
||||||
code: ShortcutConflictCode.ACTION_ON_SEQUENCE,
|
|
||||||
shortcut,
|
|
||||||
conflictPath,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new ShortcutConflictError({
|
|
||||||
code: ShortcutConflictCode.DUPLICATE_ACTION,
|
|
||||||
shortcut,
|
|
||||||
conflictPath,
|
|
||||||
existingShortcut: existingNode.shortcut,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (existingNode.type === "ACTION") {
|
|
||||||
throw new ShortcutConflictError({
|
|
||||||
code: ShortcutConflictCode.SEQUENCE_ON_ACTION,
|
|
||||||
shortcut,
|
|
||||||
conflictPath,
|
|
||||||
existingShortcut: existingNode.shortcut,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the user is currently typing in an input field
|
|
||||||
*/
|
|
||||||
function isTypingInInput(event: KeyboardEvent): boolean {
|
|
||||||
if (!event.target) return false;
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
const tagName = target.tagName.toLowerCase();
|
|
||||||
const isInput = tagName === "input" || tagName === "textarea";
|
|
||||||
const isContentEditable = target.isContentEditable;
|
|
||||||
|
|
||||||
return isInput || isContentEditable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes a KeyStroke to a consistent string format
|
|
||||||
* Returns lowercase string like "ctrl+shift+k" with alphabetically sorted modifiers
|
|
||||||
*/
|
|
||||||
function serializeKeyStroke(stroke: KeyStroke): string {
|
|
||||||
const key = stroke.key.toLowerCase();
|
|
||||||
if (!stroke.modifiers || stroke.modifiers.length === 0) return key;
|
|
||||||
const sorted = [...stroke.modifiers].sort();
|
|
||||||
return `${sorted.join("+")}+${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a keyboard event to a KeyStroke
|
|
||||||
*/
|
|
||||||
function eventToKeyStroke(event: KeyboardEvent): KeyStroke {
|
|
||||||
const modifiers: ModifierKey[] = [];
|
|
||||||
if (event.altKey) modifiers.push(ModifierKey.ALT);
|
|
||||||
if (event.ctrlKey) modifiers.push(ModifierKey.CONTROL);
|
|
||||||
if (event.metaKey) modifiers.push(ModifierKey.META);
|
|
||||||
// Only include shift if the key is a letter (shift wasn't consumed to produce the character)
|
|
||||||
if (event.shiftKey && /^[a-zA-Z]$/.test(event.key))
|
|
||||||
modifiers.push(ModifierKey.SHIFT);
|
|
||||||
return { key: event.key, modifiers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes a keyboard event to a consistent string format
|
|
||||||
*/
|
|
||||||
function serializeEvent(event: KeyboardEvent): string {
|
|
||||||
return serializeKeyStroke(eventToKeyStroke(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a path from the tree and prunes empty branches
|
|
||||||
*/
|
|
||||||
function removePathAndPrune(tree: ShortcutTreeLevel, path: string[]): void {
|
|
||||||
const [first, ...rest] = path;
|
|
||||||
if (!first) return;
|
|
||||||
|
|
||||||
if (rest.length === 0) {
|
|
||||||
delete tree[first];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = tree[first];
|
|
||||||
if (!node || node.type !== "STEP") return;
|
|
||||||
|
|
||||||
removePathAndPrune(node.children, rest);
|
|
||||||
|
|
||||||
if (Object.keys(node.children).length === 0) {
|
|
||||||
delete tree[first];
|
|
||||||
}
|
|
||||||
}
|
|
||||||