Compare commits

..

6 Commits

Author SHA1 Message Date
Henry
28d7d55deb chore: update readme 2025-06-14 22:48:46 +01:00
Henry
af273a103f fix: remove external from kan-network 2025-06-14 22:22:50 +01:00
Henry
f6659368e1 fix: allow empty strings for optional envs 2025-06-13 23:39:50 +01:00
Henry
607ce3b814 chore: tidy .env.example 2025-06-13 23:38:44 +01:00
Henry
feca755b8b feat: run migrations via entrypoint 2025-06-13 23:38:15 +01:00
Henry
2b3eef8c63 feat: add postgres to docker compose 2025-06-13 23:37:40 +01:00
445 changed files with 7594 additions and 206433 deletions

View File

@@ -1,62 +1,18 @@
# Environment
.env
.env.*
!.env.example
# Docker
docker-compose*.yml
docker-compose.override.yml
Dockerfile
./**/*/Dockerfile
.dockerignore
# Dependencies (rebuilt in Docker)
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
# Cloud compose (separate deployment)
cloud/
README.md
.next
.git

View File

@@ -1,10 +1,8 @@
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
# Required environment variables
# Required environment variables
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
# Fill if you want to use an external database
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
# Only required if deploying from compose
@@ -12,52 +10,30 @@ POSTGRES_PASSWORD=
# SMTP (optional)
SMTP_HOST=
SMTP_PORT=465
SMTP_PORT=465 # Port 587 will not work
SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
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)
NEXT_PUBLIC_DISABLE_EMAIL=
# S3 storage (optional)
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
S3_REGION=
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
NEXT_PUBLIC_STORAGE_URL=
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
NEXT_PUBLIC_STORAGE_DOMAIN=
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
# Auth config (optional)
NEXT_PUBLIC_ALLOW_CREDENTIALS=
NEXT_PUBLIC_DISABLE_SIGN_UP=
# API configuration (optional)
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
# Integration providers (optional)
TRELLO_APP_API_KEY=
TRELLO_APP_SECRET=
# Redis (optional - for rate limiting)
# If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
LOG_LEVEL=
# OAuth providers (optional)
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_SECRET=
DISCORD_CLIENT_ID=
@@ -95,3 +71,4 @@ TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=

1
.github/funding.yml vendored
View File

@@ -1 +0,0 @@
github: [kanbn]

View File

@@ -6,31 +6,23 @@ name: Docker
# documentation.
on:
schedule:
- cron: "21 21 * * *"
push:
# Only trigger on tags (releases), not main branch pushes
# Main branch builds are handled by workflow_run after Translate completes
branches: ["main"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
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:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs:
build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
permissions:
contents: read
@@ -42,8 +34,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
@@ -82,58 +72,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
type=raw,value=edge,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=${{ github.ref_type == 'tag' }}
type=raw,value=edge,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"
type=raw,value=latest,enable={{is_default_branch}}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
@@ -147,24 +86,6 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
- 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-to: type=gha,mode=max
@@ -182,10 +103,3 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
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}

View File

@@ -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

9
.gitignore vendored
View File

@@ -34,7 +34,6 @@ yarn-error.log*
.env
.env*.local
# vercel
.vercel
@@ -49,10 +48,4 @@ dist/
docker-compose.override.yml
# runtime env
__ENV.js
i18n.cache
# pgdata
/apps/web/pgdata
.claude
__ENV.js

4
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"cwd": "${workspaceFolder}/apps/web",
"cwd": "${workspaceFolder}/apps/nextjs",
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"sourceMapPathOverrides": {
@@ -14,4 +14,4 @@
}
}
]
}
}

333
AGENTS.md
View File

@@ -1,333 +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 using the `@kan/logger` package
- Show popup notifications for user-facing errors
### Logging
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
- Create a module-scoped logger: `const logger = createLogger("module-name")`
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
- Defaults to `debug` in development, `info` in production
- Never use `console.log` — always use the logger
## 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
## Adding a New Environment Variable
Update all of the following:
1. `.env.example` — add the variable with an empty value and a comment explaining it
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
3. `docker-compose.yml` — add to the `web` service `environment` section
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
5. `README.md` — add a row to the Environment Variables table
## Database Changes
- Always create migrations (never modify existing migrations)
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
- Run migrations with: `pnpm db:migrate`
- Update schema files in `packages/db/src/schema/`
- Test migrations on development database first
- Update TypeScript types after schema changes
- Consider index management for card operations
## API Endpoints
- Use tRPC procedures (not REST)
- Add OpenAPI metadata for documentation
- Validate inputs with Zod
- Check workspace permissions
- Create activity records for significant changes
## Frontend Components
- Use Tailwind for styling
- Follow existing component patterns
- Use tRPC hooks for data fetching
- Implement optimistic updates where appropriate
- Add proper loading and error states
## Testing Instructions
- Test database operations in transactions that rollback
- Test authorization checks
- Test index management when moving/deleting cards
- Test activity logging
- Test UI interactions
- Run `pnpm lint` and `pnpm typecheck` before committing
## Performance Considerations
- Use database indexes appropriately
- Batch operations when possible
- Avoid N+1 queries
- Use transactions for related operations
- Implement optimistic updates in UI
## Security
- Always check workspace membership before operations
- Validate all inputs
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
- Sanitize user input
## Internationalization
- All user-facing strings must use `t` template literal
- Add translations to locale files in `apps/web/src/locales/`
- Run `pnpm lingui:extract` to update translation files
- Update locale files for all languages
## Dependencies
- Use workspace dependencies (`workspace:*`) for internal packages
- Keep dependencies up to date
- Use catalog for shared dependency versions
## When Implementing Features
### Adding New Card Fields
1. Update schema in `packages/db/src/schema/cards.ts`
2. Create migration
3. Update repository functions
4. Add API endpoints
5. Update frontend components
6. Add activity tracking if needed
### Adding New Activity Types
1. Add to `activityTypes` array in schema
2. Create migration to update enum
3. Use in activity creation code
4. Update activity display components if needed
## Git & Commits
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
- Keep commits focused on single changes
- Reference issue numbers when applicable
## PR Instructions
- Title format: `feat: description` or `fix: description`
- Always run `pnpm lint` and `pnpm typecheck` before committing
- Provide clear description of changes
- Include screenshots for UI changes
- Keep PRs focused on a single feature/fix

View File

@@ -1,121 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased](https://github.com/kanbn/kan/compare/v0.2.4...HEAD)
## [0.2.4](https://github.com/kanbn/kan/compare/v0.2.3...v0.2.4) - 2025-01-14
### Added
- Button to view and update board public URL directly from board page
- Redirect from root to login page for self-hosted instances
### Changed
- Updated all label router endpoints to return consistent structure
- Added verbose error logging to magic link invitation process
### Fixed
- Updated tRPC dependencies and added OpenAPI meta to providers endpoint
## [0.2.3](https://github.com/kanbn/kan/compare/v0.2.2...v0.2.3) - 2025-07-07
### Added
- Mobile support with responsive navigation and layout
- Template selection when creating new boards with predefined lists and labels
- S3_FORCE_PATH_STYLE configuration for MinIO compatibility
### Changed
- Improved mobile UI styling and layout containers
- Enhanced iOS mobile support with viewport fixes and zoom prevention
- Improved card view layout for mobile devices
### Fixed
- Mobile dashboard min-width issues
- Dynamic viewport height on mobile devices
- Scroll functionality on card pages
## [0.2.2](https://github.com/kanbn/kan/compare/v0.2.1...v0.2.2) - 2025-06-28
### Added
- Localization and multi-language support
- URLs are now rendered as clickable links in card descriptions
### Changed
- Improved email configuration with optional SMTP authentication
## [0.2.1](https://github.com/kanbn/kan/compare/v0.2.0...v0.2.1) - 2025-06-19
### Added
- PostHog analytics integration
- Debug logging for email functionality
- Standalone mode support for Next.js
- Environment variable for configuring web port
- Docker Compose setup with PostgreSQL
### Changed
- SMTP authentication is now optional
- Added `SMTP_SECURE` configuration option
- Improved Docker build process with better context handling
### Fixed
- Default output mode for Next.js builds
- Container environment variable configuration
- Docker Compose volume naming
- Build context and static file copying in Docker
- Comment rendering inside contenteditable elements
## [0.2.0](https://github.com/kanbn/kan/compare/v0.1.0...v0.2.0) - 2025-06-10
### Added
- Trello integration with OAuth authentication and user field mapping
- Board visibility controls with dropdown menu and success notifications
- Markdown editor for card descriptions
- Email/password authentication for self-hosters
- Account deletion functionality with cascade delete for workspaces
- Enhanced scrollbar styles for dark mode
### Changed
- Simplified auth configuration to use `NEXT_PUBLIC_BASE_URL` instead of `BETTER_AUTH_URL`
- Updated self-hosting documentation
- Improved database schema with relation names
### Fixed
- List buttons stacking issue
- Editor onChange prop is now optional
- Renamed markdown prop to content in Editor component
- Router navigation for disabled signup redirects
## [0.1.0](https://github.com/kanbn/kan/releases/tag/v0.1.0) - 2025-06-02
### Added
- Initial release of Kan
- Core project management features
- Board, list, and card management
- User authentication system
- Workspace collaboration
- Comment system
- Activity logging
- Docker deployment support
- Self-hosting capabilities
### Changed
- Updated license link in footer

150
README.md
View File

@@ -27,14 +27,14 @@
- 🔍 **Labels & Filters**: Organise and find cards quickly
- 💬 **Comments**: Discuss and collaborate with your team
- 📝 **Activity Log**: Track all card changes with detailed activity history
- 🎨 **Templates** : Save time with reusable custom board templates
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## 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 🛠️
@@ -47,71 +47,39 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## 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.
<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:
1. Create a new file called `docker-compose.yml` and paste the following configuration:
```yaml
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:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "${WEB_PORT:-3000}:3000"
- "3000:3000"
networks:
- kan-network
env_file:
- .env
environment:
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- POSTGRES_URL=${POSTGRES_URL}
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
migrate:
condition: service_completed_successfully
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
- POSTGRES_DB=kan_db
- POSTGRES_USER=kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 5432:5432
volumes:
- 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
networks:
- kan-network
@@ -120,26 +88,26 @@ networks:
kan-network:
volumes:
kan_postgres_data:
postgres_data:
```
3. Start the containers in detached mode:
2. Start the containers in detached mode:
```bash
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 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 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 🧑‍💻
@@ -170,50 +138,34 @@ pnpm dev
## Environment Variables 🔐
| Variable | Description | Required | Example |
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `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` |
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | For Email | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
See `.env.example` for a complete list of supported environment variables.
@@ -227,12 +179,6 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## Sponsors ❤️
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -24,11 +24,9 @@ mintlify dev
### Publishing Changes
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
#### Troubleshooting
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
#BUMP

View File

@@ -11,10 +11,10 @@ https://kan.bn/api/v1
## Authentication
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request.
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
```
'Authorization': 'Bearer kan_123456789'
'x-api-key': kan_123456789
```
## Response codes

View File

@@ -1,140 +0,0 @@
---
title: "Introduction"
description: "Overview and quick start to run Kan on your own infrastructure using Docker Compose."
mode: "wide"
tag: "NEW"
---
This guide introduces how to self-host Kan. It starts with the minimal Docker Compose setup (web + PostgreSQL) and points you to optional features like email and S3-based file storage.
## What youll set up
<CardGroup cols={2}>
<Card title="Kan" icon="globe" color="#0284c7" horizontal>
Next.js application served on port 3000.
</Card>
<Card title="PostgreSQL 15" icon="database" color="#65a30d" horizontal>
Primary database for Kan data.
</Card>
</CardGroup>
<Note>
For file uploads (avatars), OAuth, and other advanced options, see the
Environment Variables section in the README and the dedicated [S3
guide](/guides/self-hosting/s3). The [full
compose](https://github.com/kanbn/kan/blob/main/docker-compose.yml) in the
repo includes a richer configuration via <code>.env</code>.
</Note>
## Prerequisites
- Docker and Docker Compose
- A long random string for <code>BETTER_AUTH_SECRET</code> (32+ chars)
## Quick start
<Steps>
<Step title="Create a docker-compose.yml">
Paste the following minimal configuration into a new <code>docker-compose.yml</code> file:
```yaml
services:
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
ports:
- "3000:3000"
networks:
- kan-network
environment:
NEXT_PUBLIC_BASE_URL: http://localhost:3000
BETTER_AUTH_SECRET: your_auth_secret
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on:
- postgres
restart: unless-stopped
postgres:
image: postgres:15
container_name: kan-db
environment:
POSTGRES_DB: kan_db
POSTGRES_USER: kan
POSTGRES_PASSWORD: your_postgres_password
ports:
- 5432:5432
volumes:
- kan_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- kan-network
networks:
kan-network:
volumes:
kan_postgres_data:
```
<Tip>
The example above is intentionally minimal. The repository provides a more feature-complete compose file at [docker-compose.yml](https://github.com/kanbn/kan/blob/main/docker-compose.yml) if you want environment-based configuration, OAuth, S3, and more.
</Tip>
</Step>
<Step title="Start the stack">
Bring everything up in detached mode:
```bash
docker compose up -d
```
Once started, open [http://localhost:3000](http://localhost:3000).
</Step>
<Step title="Manage the containers">
Useful commands while developing or testing:
- Stop the containers: <code>docker compose down</code>
- View logs: <code>docker compose logs -f</code>
- Restart: <code>docker compose restart</code>
</Step>
<Step title="Configure environment (optional)">
For a production-like setup and more features (email, OAuth, file uploads, etc.), create a <code>.env</code> file and set the relevant variables shown in the READMEs Environment Variables section.
<Accordion title="Common variables">
```bash
# Required
NEXT_PUBLIC_BASE_URL=http://localhost:3000
BETTER_AUTH_SECRET=replace_with_long_random_string
POSTGRES_URL=postgresql://kan:your_postgres_password@postgres:5432/kan_db
# Optional: Email
EMAIL_FROM="Kan <hello@mail.kan.bn>"
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_USER=resend
SMTP_PASSWORD=re_xxxx
SMTP_SECURE=true
# Optional: Auth toggles
NEXT_PUBLIC_ALLOW_CREDENTIALS=true
NEXT_PUBLIC_DISABLE_SIGN_UP=false
```
<Note type="warning">
If you plan to enable file uploads (avatars, etc.), youll also need S3 variables (<code>S3_ENDPOINT</code>, <code>S3_ACCESS_KEY_ID</code>, <code>S3_SECRET_ACCESS_KEY</code>, <code>NEXT_PUBLIC_STORAGE_URL</code>, <code>NEXT_PUBLIC_STORAGE_DOMAIN</code>, …). See the S3 guide linked at the top.
</Note>
</Accordion>
</Step>
</Steps>
## Reference
- [GitHub README](https://github.com/kanbn/kan/blob/main/README.md#self-hosting-)
- [GitHub docker-compose.yml](https://github.com/kanbn/kan/blob/main/docker-compose.yml)

View File

@@ -1,397 +0,0 @@
---
title: "Kan + MinIO (S3)"
mode: "wide"
tag: "NEW"
---
Deploy Kan with PostgreSQL and MinIO (S3-compatible storage) using Docker Compose, with clear steps and production notes.
## What youll set up
<CardGroup cols={2}>
<Card title="Kan" icon="globe" color="#0284c7" horizontal>
Kan web app (Next.js), on port 3000.
</Card>
<Card title="PostgreSQL 15" icon="database" color="#65a30d" horizontal>
PostgreSQL database for Kan.
</Card>
</CardGroup>
<Card title="MinIO (S3-compatible)" icon="cloud" color="#ca8a04" horizontal>
MinIO object storage (console port 9001, S3 API port 9000).
</Card>
## How it works
- Kan stores data in PostgreSQL.
- Kan uploads files (e.g., avatars) to MinIO over the S3 API.
- The browser fetches public files directly from MinIOs public URL.
- The Next.js image optimizer in Kan must be explicitly allowed to fetch from your storage host.
Key domain settings:
- <code>NEXT_PUBLIC_BASE_URL</code> → the Kan site
- <code>NEXT_PUBLIC_STORAGE_URL</code> → the public S3 base URL
- <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> → the exact S3 hostname (no scheme)
## Prerequisites
- Docker and Docker Compose
- Open local ports: 3000 (Kan), 5432 (Postgres), 9000/9001 (MinIO)
- A long random string for <code>BETTER_AUTH_SECRET</code> (32+ chars)
<Note type="warning">
For production youll want a reverse proxy (Traefik/Nginx/Caddy), valid TLS
certificates, and DNS for your domains (e.g., <code>kan.example.com</code>,{" "}
<code>s3.example.com</code>).
</Note>
## Quick start
<Tip type="info">
Why <code>localtest.me</code>? It resolves to <code>127.0.0.1</code>{" "}
automatically, so you can test domain-based configs locally without editing
hosts.
</Tip>
<Steps>
<Step title="Set environment variables">
Provide the minimum required configuration (local example):
```bash
NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000
BETTER_AUTH_SECRET=<long random string>
POSTGRES_URL=postgresql://kan:<password>@postgres:5432/kan_db
# MinIO/S3
S3_ENDPOINT=http://s3.localtest.me:9000
S3_ACCESS_KEY_ID=<minio-access-key>
S3_SECRET_ACCESS_KEY=<minio-secret-key>
S3_REGION=none
S3_FORCE_PATH_STYLE=true
# Public storage access
NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000
NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me
NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan
```
<Note type="info">
Issue #109 fix: make sure <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> exactly
equals the hostname that serves your images (no scheme, no port).
</Note>
Optional (see README for full list): Email (`EMAIL_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE`), OAuth/OIDC (`GOOGLE_*`, `GITHUB_*`, `OIDC_*`), auth toggles, Trello import, etc.
</Step>
<Step title="Create or review Docker Compose files">
You can start from the minimal compose at the repository root (<code>docker-compose.yml</code>) and review the production-oriented settings in <code>cloud/docker-compose.yml</code>.
Start with the minimal setup (web + postgres + minio) and ensure environment variables are passed to the web service.
<Accordion title="Docker Compose example">
```yaml
services:
postgres:
image: postgres:15
container_name: kan-db
ports:
- "5432:5432"
environment:
POSTGRES_USER: kan
POSTGRES_PASSWORD: changeme
POSTGRES_DB: kan_db
volumes:
- pg_data:/var/lib/postgresql/data
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: kan-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
environment:
# Use the same credentials in your .env
# as S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123456789
volumes:
- minio_data:/data
restart: unless-stopped
web:
image: ghcr.io/kanbn/kan:latest
container_name: kan-web
depends_on:
- postgres
- minio
ports:
- "3000:3000"
# Load variables from .env
# (see the "Set environment variables" step)
env_file:
- .env
restart: unless-stopped
volumes:
pg_data:
minio_data:
```
<Note type="info">
Ensure your <code>.env</code> contains values that match this compose file.
For example:
<ul>
<li>
<code>POSTGRES_URL=postgresql://kan:changeme@postgres:5432/kan_db</code>
</li>
<li>
<code>S3_ENDPOINT=http://s3.localtest.me:9000</code>
</li>
<li>
<code>S3_ACCESS_KEY_ID=minio</code> and{" "}
<code>S3_SECRET_ACCESS_KEY=minio123456789</code>
</li>
<li>
<code>S3_FORCE_PATH_STYLE=true</code>
</li>
<li>
<code>NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000</code>
</li>
<li>
<code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me</code>
</li>
<li>
<code>NEXT_PUBLIC_AVATAR_BUCKET_NAME=kan</code>
</li>
</ul>
</Note>
</Accordion>
</Step>
<Step title="Start services">
```bash
docker compose up -d
```
Then open:
<ul>
<li>
Kan: <a href="http://kan.localtest.me:3000">http://kan.localtest.me:3000</a>
</li>
<li>
MinIO Console:{" "}
<a href="http://minio.localtest.me:9001">http://minio.localtest.me:9001</a>
</li>
<li>
MinIO S3 API:{" "}
<a href="http://s3.localtest.me:9000">http://s3.localtest.me:9000</a>
</li>
</ul>
</Step>
<Step title="Initialize MinIO">
1) Log into the MinIO Console (http://minio.localtest.me:9001).
2. Create a bucket (e.g., <code>kan</code>).
3. For simple public avatars, apply a read-only policy so GET requests are allowed for objects:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetBucketLocation", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::kan"]
},
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::kan/*"]
}
]
}
```
<Note type="warning">
Alternatively, keep the bucket private and use presigned URLs. In that case,
ensure your server and browser access paths are correctly configured.
</Note>
</Step>
<Step title="Verify the setup">
<ul>
<li>Sign in to Kan and upload an avatar (Settings).</li>
<li>Confirm the object is created in your MinIO bucket.</li>
<li>The avatar should render without errors.</li>
</ul>
If you see a 400 from <code>/\_next/image</code> with “url parameter is not allowed”:
<ul>
<li>
<code>NEXT_PUBLIC_STORAGE_DOMAIN</code> must exactly match the S3 hostname
that serves images.
</li>
<li>
<code>NEXT_PUBLIC_STORAGE_URL</code> should use the same host (with
scheme/port).
</li>
<li>Ensure youre using the latest Kan image.</li>
</ul>
</Step>
</Steps>
## Production setup
<Tabs>
<Tab title="Local">
<ul>
<li><code>NEXT_PUBLIC_BASE_URL=http://kan.localtest.me:3000</code></li>
<li><code>S3_ENDPOINT=http://s3.localtest.me:9000</code></li>
<li><code>NEXT_PUBLIC_STORAGE_URL=http://s3.localtest.me:9000</code></li>
<li><code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.localtest.me</code></li>
<li>Keep <code>S3_FORCE_PATH_STYLE=true</code> for MinIO.</li>
</ul>
</Tab>
<Tab title="Production">
<ul>
<li><code>NEXT_PUBLIC_BASE_URL=https://kan.example.com</code></li>
<li><code>S3_ENDPOINT=https://s3.example.com</code></li>
<li><code>NEXT_PUBLIC_STORAGE_URL=https://s3.example.com</code></li>
<li><code>NEXT_PUBLIC_STORAGE_DOMAIN=s3.example.com</code></li>
<li>Keep <code>S3_FORCE_PATH_STYLE=true</code> for MinIO.</li>
<li>Put Kan and MinIO behind HTTPS with a reverse proxy (Traefik/Nginx) and valid TLS.</li>
</ul>
</Tab>
</Tabs>
## Troubleshooting
<AccordionGroup>
<Accordion title="Files upload but dont display">
<ul>
<li>If public: confirm GET is allowed on objects (bucket policy).</li>
<li>If private: ensure presigned URLs are generated and valid.</li>
<li>403 AccessDenied indicates permissions, not CORS. CORS is not required for simple <code>&lt;img&gt;</code> GETs.</li>
</ul>
</Accordion>
<Accordion title="Make the bucket public (read-only) with mc" defaultOpen>
Use your MinIO root credentials to allow anonymous reads:
```bash
# Replace with your MINIO_ROOT_PASSWORD
MINIO_PASS='<your-minio-password>'
# Point mc at MinIO via the container network (no ports required)
docker run --rm --network container:kan-minio minio/mc \
mc alias set local http://127.0.0.1:9000 minio "$MINIO_PASS"
# Allow public downloads from the bucket
docker run --rm --network container:kan-minio minio/mc \
mc anonymous set download local/kan
# Optional: verify anonymous status
docker run --rm --network container:kan-minio minio/mc \
mc anonymous get local/kan
```
</Accordion>
<Accordion title="Alternative: S3 bucket policy (AWS CLI)">
If you prefer a bucket policy, apply a public-read policy for objects:
```json policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::kan/*"]
}
]
}
```
```bash
# Use your MinIO root credentials
MINIO_PASS='<your-minio-password>'
docker run --rm --network container:kan-minio \
-e AWS_ACCESS_KEY_ID=minio \
-e AWS_SECRET_ACCESS_KEY="$MINIO_PASS" \
-e AWS_DEFAULT_REGION=us-east-1 -e AWS_S3_FORCE_PATH_STYLE=true \
-v "$PWD:/work" amazon/aws-cli \
s3api put-bucket-policy --bucket kan --policy file:///work/policy.json \
--endpoint-url http://127.0.0.1:9000
```
</Accordion>
<Accordion title="Next.js optimizer 400 — url parameter is not allowed">
<ul>
<li>
Exact match on <code>NEXT_PUBLIC_STORAGE_DOMAIN</code> with your storage
host.
</li>
<li>
Same host in <code>NEXT_PUBLIC_STORAGE_URL</code>.
</li>
<li>Update to the latest Kan image.</li>
</ul>
</Accordion>
<Accordion title="Next/Image: “url parameter is valid but upstream response is invalid”">
<ul>
<li>This means Next.js accepted the URL, but the upstream returned a non-image (e.g., 403 HTML/XML).</li>
<li>Fix: make the bucket/object publicly readable (see above), or use presigned URLs.</li>
<li>Sanity test from the web network (replace with your image URL):</li>
</ul>
```bash
IMG_URL="https://s3.example.com/kan/path/to/avatar.jpg"
# Headers/content-type as seen from the app network
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
-I -L --max-redirs 5 "$IMG_URL"
# Quick status + content-type summary
docker run --rm --network container:kan-web curlimages/curl:8.9.1 \
-s -o /dev/null -w "HTTP:%{http_code} CT:%{content_type} URL:%{url_effective}\n" -L "$IMG_URL"
```
</Accordion>
<Accordion title="Connectivity checks">
<ul>
<li>The Kan container must reach <code>S3_ENDPOINT</code>.</li>
<li>Verify DNS/ports inside the container (e.g., <code>docker exec -it &lt;kan-container&gt; sh</code>).</li>
</ul>
</Accordion>
</AccordionGroup>
## References
- [Kan README](https://github.com/kanbn/kan/blob/main/README.md)
- [Cloud compose reference](https://github.com/kanbn/kan/blob/main/cloud/docker-compose.yml)
- [Kan #109 Issue](https://github.com/kanbn/kan/issues/109)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 433 KiB

View File

@@ -44,18 +44,6 @@
"group": "Get Started",
"pages": ["introduction"]
},
{
"group": "Guides",
"pages": [
{
"group": "Self-Hosting",
"pages": [
"guides/self-hosting/introduction",
"guides/self-hosting/s3"
]
}
]
},
{
"group": "Import",
"pages": ["imports/trello"]

View File

@@ -1,124 +1,79 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# ============================================
# Stage 1: Alpine base with pnpm and turbo
# ============================================
# 1. Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \
apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
corepack prepare pnpm@9.14.2 --activate && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
RUN apk update
RUN apk add --no-cache libc6-compat python3 make g++
# ============================================
# Stage 2: Prune the monorepo
# ============================================
FROM alpine AS pruner
# Setup pnpm and turbo on the alpine base
FROM alpine AS base
RUN corepack enable
# 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
# It might be the path to <ROOT> turborepo
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)
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
sed 's/^v//' || \
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
echo "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION
# Environment to skip .env validation on build
ENV CI=true
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
# ============================================
# Stage 3: Install dependencies
# ============================================
FROM alpine AS deps
WORKDIR /app
# Copy lockfile and package.json's of isolated subworkspace
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ .
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
# ============================================
# Stage 4: Build the web application
# ============================================
FROM alpine AS builder
ARG APP_VERSION
WORKDIR /app
COPY --from=deps /app/ ./
# Copy source code of isolated subworkspace
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 pnpm build --filter=${PROJECT}
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
# 4. Final image - runner stage to run the application
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
# ============================================
# Stage 5: Migration image (run-once container)
# ============================================
FROM node:${NODE_VERSION}-alpine AS migrate
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
COPY packages/db/migrations/ ./migrations/
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
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Copy the standalone Next.js server
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
# Copy static assets and public files
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
COPY --chown=nextjs:nodejs --from=builder /app/ ./
WORKDIR /app/apps/${APP_DIRNAME}
# Copy bootstrap script for runtime env var injection
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
USER 1000:1000
CMD ["./entrypoint.sh"]
EXPOSE 3000
CMD ["bootstrap.cjs"]

View File

@@ -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");

View File

@@ -12,12 +12,5 @@ if [ $? -ne 0 ]; then
fi
echo "\nStarting Next.js application..."
# Check if we should use standalone mode
if [ "$NEXT_PUBLIC_USE_STANDALONE_OUTPUT" = "true" ]; then
echo "Starting in standalone mode..."
exec node .next/standalone/apps/web/server.js
else
echo "Starting in standard mode..."
exec pnpm start
fi
# Start the Next.js application from the web app's directory
exec node .next/standalone/apps/web/server.js

View File

@@ -1,13 +0,0 @@
{
"version": "1.10",
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
},
"buckets": {
"json": {
"include": ["src/locales/[locale]/messages.json"]
}
},
"$schema": "https://lingo.dev/schema/i18n.json"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
import { defineConfig } from "@lingui/cli";
import { formatter } from "@lingui/format-json";
export default defineConfig({
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
sourceLocale: "en",
catalogs: [
{
path: "src/locales/{locale}/messages",
include: ["src"],
exclude: ["**/node_modules/**"],
},
],
format: formatter({ style: "lingui" }),
});

View File

@@ -10,25 +10,9 @@ configureRuntimeEnv();
/** @type {import("next").NextConfig} */
const config = {
output:
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
? "standalone"
: undefined,
output: "standalone",
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 */
transpilePackages: [
"@kan/api",
@@ -39,60 +23,28 @@ const config = {
],
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
// temporarily ignore eslint errors during build until we fix all the errors sigh
eslint: { ignoreDuringBuilds: true },
images: {
remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [
{ protocol: "https", hostname: "**" },
{
protocol: "http",
hostname: "localhost",
},
];
return patterns;
})(),
},
turbopack: {
rules: {
"*.svg": {
loaders: ["@svgr/webpack"],
as: "*.js",
},
},
},
serverExternalPackages: ["pino"],
experimental: {
// instrumentationHook: true,
swcPlugins: [["@lingui/swc-plugin", {}]],
},
async rewrites() {
return [
remotePatterns: [
{
source: "/settings",
destination: "/settings/account",
protocol: "https",
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
];
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
],
},
experimental: {
instrumentationHook: true,
},
};
// Only allow external images when OIDC is configured (for OIDC provider avatars)
if (
env("OIDC_CLIENT_ID") &&
env("OIDC_CLIENT_SECRET") &&
env("OIDC_DISCOVERY_URL")
) {
config.images?.remotePatterns?.push({
protocol: "https",
hostname: "**",
});
}
export default config;

View File

@@ -4,43 +4,28 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build --turbo",
"build": "pnpm next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev --turbo",
"dev": "pnpm with-env next dev | pino-pretty",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --",
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile --typescript",
"lingui:extract:watch": "lingui extract --watch"
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/lib-storage": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^3.3.4",
"@kan/api": "workspace:*",
"@kan/auth": "workspace:*",
"@kan/db": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",
"@lingui/macro": "^5.3.2",
"@lingui/react": "^5.3.2",
"@novu/api": "^3.11.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:",
"@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-typography": "^3.18.0",
"@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0",
@@ -49,28 +34,25 @@
"@trpc/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.26.2",
"geist": "^1.3.1",
"jose": "^6.1.2",
"next": "15.5.9",
"next": "^14.2.15",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
"next-themes": "^0.4.6",
"nextjs-cors": "^2.2.0",
"posthog-js": "^1.254.0",
"pino": "^9.6.0",
"react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1",
"react-contenteditable": "^3.3.7",
"react-dom": "catalog:react18",
"react-hook-form": "^7.51.1",
"react-icons": "^5.5.0",
"react-image-crop": "^11.0.10",
"react-lottie-player": "^1.5.5",
"react-markdown": "^10.1.0",
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8",
"zod": "catalog:"
},
"devDependencies": {
@@ -79,11 +61,6 @@
"@kan/stripe": "workspace:*",
"@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0",
"@types/node": "^20.17.7",
"@types/react": "catalog:react18",
"@types/react-beautiful-dnd": "^13.1.7",
@@ -91,10 +68,10 @@
"dotenv-cli": "^7.4.4",
"eslint": "catalog:",
"jiti": "^1.21.6",
"pino-pretty": "^13.0.0",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vitest": "^3.0.0"
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,39 +0,0 @@
{
"background_color": "#000000",
"categories": [
"business",
"productivity",
"utilities"
],
"description": "The open source Trello alternative.",
"display": "standalone",
"icons": [
{
"sizes": "512x512",
"src": "/icon-512.png",
"type": "image/png",
"purpose": "any"
}
],
"id": "/",
"screenshots": [
{
"sizes": "1624x1561",
"src": "/screenshot-wide.png",
"type": "image/png",
"form_factor": "wide"
},
{
"sizes": "1290x2796",
"src": "/screenshot-narrow.png",
"type": "image/png",
"form_factor": "narrow"
}
],
"orientation": "portrait-primary",
"name": "Kan.bn | The open source alternative to Trello",
"scope": "/",
"short_name": "Kan.bn",
"start_url": "/",
"theme_color": "#000000"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><path fill="none" d="M0 0h192.756v192.756H0z"/><path fill="#313f94" d="M33.272 96.299c-.159-7.649-6.374-13.864-14.023-13.864S5.544 88.809 5.544 96.458h-2.71c0-8.446 6.693-15.298 15.139-15.458 8.446 0 15.458 6.853 15.458 15.299z" style="fill:currentColor"/><path fill="#313f94" d="M33.113 96.458c0-6.215-4.94-11.155-11.155-11.155s-10.677 4.94-10.677 11.155H8.253c0-7.012 5.737-12.589 12.749-12.749 6.853 0 12.43 6.374 12.589 12.589z" style="fill:currentColor"/><path fill="#313f94" d="M33.272 96.299c0-4.462-3.824-8.127-8.446-8.127s-7.968 3.665-7.968 8.287H13.99c0-5.418 4.144-9.88 9.562-9.88s9.721 4.462 9.88 9.721h-.16z" style="fill:currentColor"/><path fill="#313f94" d="M3.153 96.458c0 7.649 6.215 13.864 13.864 13.864 7.809 0 13.864-6.375 13.864-14.023h2.709c0 8.445-6.853 15.457-15.298 15.457S2.994 104.904 2.834 96.458z" style="fill:currentColor"/><path fill="#313f94" d="M3.153 96.299c0 6.215 4.94 11.154 11.155 11.154s10.836-4.939 10.677-11.154h3.028c0 7.012-5.737 12.748-12.589 12.748-7.012 0-12.589-6.373-12.589-12.589z" style="fill:currentColor"/><path fill="#313f94" d="M2.994 96.458c.159 4.462 3.984 8.128 8.446 8.128 4.622 0 7.968-3.666 7.968-8.287h2.868c0 5.418-4.143 9.881-9.562 9.881-5.417 0-9.72-4.303-9.72-9.722m54.341 3.507h-5.1l4.144-8.925zm-8.924 8.285 1.434-3.188h7.968l.479 3.188h7.968l-4.462-24.7H53.67l-13.227 24.7zm21.513 0 4.303-24.7h8.127l-4.302 24.7zm24.064-18.645h3.985c1.273 0 2.07.16 2.549.638.318.318.479.956.318 1.593-.158.797-.479 1.435-1.115 1.753-.479.479-1.434.638-2.709.638h-3.825zM90.8 108.25l1.435-8.605h3.187c.956 0 1.754.16 2.23.32.479.318.797.637.957 1.115q.237.716 0 1.912l-.16 1.912c-.318 1.594-.318 2.709-.318 3.346h8.287v-.797c-.16 0-.479 0-.479-.158-.158-.16-.158-.479 0-1.115l.639-3.826c.158-1.752.158-3.027-.32-3.664-.477-.797-1.434-1.436-2.867-2.072.797-.159 1.434-.479 2.07-.796.797-.638 1.436-1.275 1.912-2.072.639-.957.957-1.913 1.275-3.188q.24-1.434 0-2.39a5.3 5.3 0 0 0-.797-1.912 7.5 7.5 0 0 0-1.594-1.594c-.797-.319-1.594-.638-2.709-.797s-2.548-.319-4.302-.319H87.294l-4.303 24.7zm33.147-19.123c1.115 0 1.912.159 2.23.159.479.16.797.479.957.797.318.319.318.797.318 1.275-.16.638-.479 1.116-1.115 1.594-.479.319-1.436.637-2.709.637H120.6l.797-4.462zm-1.275 19.123c1.912 0 3.188-.158 3.984-.318s1.752-.479 2.549-.797c.957-.479 1.594-.797 2.072-1.275.797-.477 1.275-1.273 1.754-2.07a7.2 7.2 0 0 0 1.115-2.869c.158-1.434 0-2.709-.479-3.666-.637-1.115-1.434-1.752-2.709-2.071 2.391-1.434 3.824-3.187 4.143-5.259.479-2.231-.158-3.825-1.912-4.94-1.275-.957-3.346-1.435-6.533-1.435h-12.111l-4.303 24.7zm.478-10.357c1.594 0 2.549.318 3.027.637.479.479.637.957.479 1.754-.16.637-.479 1.273-1.115 1.752-.639.318-1.594.639-3.188.639h-3.346l.797-4.781zm18.645-14.343h7.969l-2.709 15.298c-.16.957-.16 1.754 0 2.232.158.477.477.955 1.115 1.273.637.32 1.275.479 2.07.479 1.117 0 2.232-.318 2.869-.955.797-.639 1.275-1.754 1.594-3.029l2.709-15.298h7.969l-2.551 14.501c-.318 1.754-.637 3.188-1.115 4.303-.479.957-1.115 1.912-1.912 2.709s-1.754 1.594-2.709 2.072c-1.115.637-2.23 1.115-3.506 1.434-1.434.16-2.869.32-4.303.32-1.754 0-3.188-.16-4.463-.32-1.273-.318-2.23-.797-3.027-1.434-.955-.637-1.594-1.275-2.072-2.072-.477-.797-.797-1.912-.797-3.186-.158-.797 0-2.232.32-3.984zm48.127 7.171h-7.809c0-.638-.318-1.275-.639-1.594-.477-.319-1.115-.478-2.07-.478-.957 0-1.754.159-2.23.478-.479.319-.639.638-.797 1.116 0 .478 0 .637.318.956s1.115.637 2.549.956l3.027.797c2.709.797 4.623 1.753 5.578 3.028.957 1.274 1.275 2.709.957 4.461-.32 1.436-.797 2.551-1.594 3.666s-1.754 2.072-2.709 2.709c-.957.637-2.232 1.115-3.666 1.594-1.434.318-3.027.479-4.621.479s-3.188-.16-4.621-.639q-2.153-.717-3.348-1.912c-1.195-1.195-1.434-1.752-1.752-2.867-.16-.797-.16-1.754-.16-2.869h7.809c0 .797.318 1.434.797 1.752.479.479 1.436.639 2.709.639q1.673 0 2.391-.479c.718-.479.639-.637.797-1.115 0-.479 0-.957-.318-1.275s-1.115-.637-2.551-.955l-2.709-.639c-1.273-.318-2.709-.797-3.824-1.434-1.115-.638-2.07-1.435-2.549-2.55-.479-.956-.479-2.071-.318-3.506.318-1.434.797-2.549 1.592-3.665.797-1.115 1.754-1.912 2.869-2.55.957-.797 2.23-1.115 3.506-1.434s2.551-.478 3.824-.478c1.594 0 2.869.159 4.145.478 1.273.319 2.07.797 2.867 1.275.639.478 1.115 1.115 1.436 1.753.477.637.637 1.275.797 1.912.157.637.317 1.434.317 2.39" style="fill:currentColor"/></g></svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 2000 456.5"><path d="M380.5 268.5c0 13.6-8 26-23.6 28.4-26.8 4.8-82.8 7.6-130 7.6s-103.2-2.8-130-7.6c-15.6-2.8-23.2-15.2-23.2-28.4v-89.2c0-13.6 10.4-26.4 23.2-28.4 8-1.6 26.4-2.8 40.8-2.8 5.6 0 9.6 4.4 10 10.8v62c27.6 0 51.6-1.6 79.6-1.6s51.6 1.6 79.6 1.6v-62c0-6.4 4.4-10.8 9.6-10.8 14.4 0 32.8 1.2 40.8 2.8 12.8 2.4 23.6 15.2 23.6 28.4zM226.4 0C101.6 0 0 102.4 0 228.4s101.6 228 226.4 228 226.4-102.4 226.4-228S352.1 0 226.4 0" class="st0" style="fill:currentColor"/><path d="M543.6 309.9c-70.4 0-105.6-51.2-105.6-108 0-56.4 36.8-107.2 106.4-107.2 26.8 0 46 5.6 61.6 16.4l-20 33.2c-11.2-7.2-24-12-42.4-12-38 0-58 30.4-58 68.4 0 38.8 19.2 71.6 58.8 71.6 22 0 35.6-7.6 46.4-16l18.4 32c-10.8 9.6-35.6 21.6-65.6 21.6M694 309.9c-52.4 0-75.6-40-75.6-79.6s22.8-80.4 75.2-80.4 76.4 39.6 76.4 79.2c0 38.8-23.2 80.8-76 80.8m-1.2-128c-22.8 0-29.6 19.2-29.6 47.2s8.8 48.4 31.2 48.4c22.8 0 30.4-18.8 30.4-47.2s-8.8-48.4-32-48.4M836.9 153.9v97.6c0 15.2 5.2 22.4 19.6 22.4 15.2 0 26.8-14.8 29.6-18.4V153.9h42.4v107.6c0 20 2.4 35.6 4.4 44.8h-41.6c-1.2-4.4-2.8-14-3.2-20-8 10.4-23.6 24-48.4 24-33.2 0-45.2-21.6-45.2-49.6V153.9zM1032.5 309.9c-48.8 0-78.4-32-78.4-80 0-51.2 33.6-80.4 80-80.4 24.4 0 38.8 7.2 46 12l-13.6 29.2c-6.4-4.4-16.4-8.8-31.2-8.8-23.6 0-36.4 18.4-36.4 46.4s12 48 37.2 48c16.8 0 27.6-6 32-8.8l12.8 28.4c-7.2 4.8-20.8 14-48.4 14M1194.5 306.3v-97.6c0-15.2-5.2-22.4-19.2-22.4-15.6 0-27.2 14.4-30 18.4v102h-42.4V81.1h42.4v90.8c7.6-8.4 22-22 46.8-22 33.2 0 45.2 21.2 45.2 49.6v106.4zM1314.1 81.1v84.4c6.8-6.8 20-16 36.8-16 37.6 0 63.6 26 63.6 78.8 0 52.4-34.8 81.2-80.4 81.2-34.4 0-55.2-8.4-62.4-12.4v-216zm0 192c2.8.8 9.2 3.2 20 3.2 22 0 35.2-16.4 35.2-47.6 0-28-9.2-44.8-30.8-44.8-12.8 0-22 8-24.8 11.2zM1519.8 306.3c-1.2-4-2.4-11.6-2.8-16.4-6.4 8-20.8 20-42 20-25.6 0-45.6-15.6-45.6-42.8 0-39.6 40-54 80.4-54h5.2v-8.8c0-12.8-5.2-20.4-24-20.4-19.2 0-32.4 10-38.4 14.4l-18-26.4c9.2-8 28.8-22 61.2-22 41.6 0 61.2 16.4 61.2 56.8v53.6c0 20.8 2.4 36 4.4 46zm-4.8-67.2h-5.2c-23.2 0-39.2 6.8-39.2 24.4 0 10.8 8.4 15.6 17.6 15.6 14.4 0 22.4-8 26.8-12.8zM1633.8 309.9c-27.2 0-44.4-8-53.6-14.4l14.4-30c5.6 4 20.4 12.4 37.2 12.4 15.6 0 24.4-4.4 24.4-13.2 0-10-16.8-12.8-38.4-23.2-20.8-10-32.8-22-32.8-44.8 0-28.8 22.4-47.2 56.8-47.2 26 0 41.6 8 48.8 12l-14.8 29.2c-5.6-4-17.6-9.6-32-9.6s-20.4 5.2-20.4 12.8c0 10 14 12.4 30.8 19.2 23.6 9.6 40.4 20.8 40.4 46.8-.8 33.2-23.6 50-60.8 50M1792.2 277.9c20.4 0 31.6-6.4 38.8-10.4l13.6 27.6c-10 5.6-25.6 14.8-56.4 14.8-50.4 0-79.2-32-79.2-80.8 0-48.4 33.2-79.2 74.8-79.2 47.6 0 70.4 33.2 65.2 90.4h-94c1.6 22.8 12.8 37.6 37.2 37.6m16-66.8c-.4-16.8-6.8-30.4-25.2-30.4-16.8 0-26.4 10.8-28.8 30.4z" class="st1" style="fill:currentColor" transform="translate(150.22 27.826)"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="892.4" height="170.507" viewBox="-86.2 14.747 1092.4 170.507"><path d="M3054.827 974.386a4.32 4.32 0 1 1 8.637 0 4.32 4.32 0 0 1-8.637 0M2917.055 962.946q0-4.106-1.587-6.09-1.586-1.98-4.818-1.98h-2.294v16.732h1.755q3.592.001 5.267-2.135 1.676-2.134 1.677-6.527m8.18-.285q0 7.478-4.02 11.522-4.02 4.047-11.304 4.046h-9.436v-29.87h10.095q7.023 0 10.845 3.677 3.82 3.677 3.82 10.625M2951.373 978.229h7.509v-29.996h-7.509zM2969.43 967.04q0 2.87.749 4.374.749 1.505 2.515 1.505 1.748 0 2.469-1.505.719-1.505.718-4.374 0-2.853-.728-4.306-.731-1.457-2.498-1.458-1.727.001-2.476 1.447-.749 1.444-.749 4.317m14.094 0q.001 5.466-2.87 8.524-2.871 3.059-8.036 3.059-4.955 0-7.883-3.128-2.93-3.126-2.93-8.455 0-5.448 2.871-8.467t8.058-3.02q3.206 0 5.664 1.398t3.792 4.003q1.334 2.603 1.334 6.086M2986.506 978.23h7.51v-22.282h-7.51zM2986.506 953.253h7.51v-5.022h-7.51zM3009.23 972.546c1.015 0 2.222-.258 3.63-.769v5.606q-1.517.669-2.89.953-1.372.286-3.217.286-3.781 0-5.455-1.899-1.667-1.897-1.668-5.828v-9.185H2997v-5.76h2.63v-5.69l7.567-1.317v7.007h4.791v5.76h-4.791v8.672q0 2.164 2.034 2.164M3026.612 972.546c1.013 0 2.22-.258 3.63-.769v5.606q-1.518.669-2.89.953-1.374.286-3.218.286-3.782 0-5.452-1.899-1.671-1.897-1.672-5.828v-9.185h-2.63v-5.76h2.63v-5.777l7.567-1.23v7.007h4.793v5.76h-4.793v8.672q0 2.164 2.035 2.164M3039.717 964.326q.155-1.83 1.047-2.685.895-.855 2.217-.855 1.445-.001 2.295.965.857.962.893 2.575zm10.59-6.145q-2.678-2.626-7.593-2.626-5.166 0-7.952 3.018-2.784 3.019-2.785 8.663.001 5.467 3.007 8.426 3.005 2.96 8.44 2.96 2.61 0 4.493-.355a13 13 0 0 0 3.627-1.266l-1.154-5.021c-.851.347-1.661.615-2.426.79q-1.658.386-3.638.386-2.171 0-3.427-1.061-1.258-1.065-1.335-2.932h13.422v-3.42q0-4.938-2.679-7.562M2935.2 964.326q.155-1.83 1.047-2.685.894-.855 2.219-.855 1.44-.001 2.294.965.855.962.893 2.575zm10.592-6.145q-2.68-2.626-7.595-2.626-5.167 0-7.95 3.018-2.785 3.019-2.786 8.663 0 5.467 3.007 8.426 3.004 2.96 8.44 2.96 2.61 0 4.493-.355a13 13 0 0 0 3.627-1.266l-1.155-5.021c-.851.347-1.661.615-2.425.79q-1.66.386-3.639.386-2.17 0-3.427-1.061-1.258-1.065-1.335-2.932h13.422v-3.42q0-4.938-2.677-7.562" style="fill:currentColor" transform="translate(-15746.429 -5136.262)scale(5.4348)"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 295 42"><rect width="100%" height="100%" fill="none"/><path d="M259.5 5.5c0-2.9.9-3.9 2.4-3.9s2.7.6 4.1 3.9l8.5 19.3V37c0 2.9-1.2 3.7-4.5 4.1v.8h15.7v-.8c-3.4-.4-4.5-1.3-4.5-4.1V24.8l8.2-18.6c1.6-3.6 3.2-4.4 5.6-4.8V.6h-11.5v.8c3.9.4 5.3 1.5 4.2 4.1L280.5 22l-7.4-16.9c-1-2.3-.4-3 3.9-3.6V.7h-24.7v.8c3.9.6 5.1 1.3 5.1 4.1v22.6L239.3.7h-8.9v.8c2.9.4 3.8 1.3 3.8 4.1v31.5c0 2.9-.8 3.9-1.8 3.9-.9 0-1.4-.4-2.7-3.9L216.3.5h-.8l-12.9 35c-1.4 3.9-3.6 5.2-5.8 5.7v.8h11.8v-.8c-4.1-.4-5.4-2-4.5-4.6l2.9-8h12.2l2.8 7.5c1.2 3.4.5 4.4-3 5v.8h22.4v-.8c-3.9-.6-5.1-1.3-5.1-4.1V8.8L258.2 42h1.3zM101.6 21.2c0-12.8 6.1-19.5 13.2-19.5 4.2 0 6.9.9 8.5 2.6 1.2 1.2 2.1 2.9 3.1 5.6h.8V1.3C123.1.5 119.4 0 115.7 0 105 0 93.9 6.9 93.9 21.2S105 42.4 115 42.4c4.4 0 8.3-.7 12-1.8l1.2-8.1-.7-.2c-.9 1.7-2 3.2-3.2 4.5-2.6 2.6-6.1 4-9.6 4-6.8-.1-13.1-6.6-13.1-19.6m88.7 1.2c-.9 0-2.1-.1-3.3-.3v-20h3.3c5.5 0 7.9 2.9 7.9 10.1 0 7.3-2.5 10.2-7.9 10.2M61.8.6v9.9h.8c1.2-6.3 3.7-8.3 7.9-8.3h2V37c0 2.9-1.2 3.7-4.5 4.1v.8h15.7v-.8c-3.4-.4-4.5-1.3-4.5-4.1V2.1h2c4.2 0 6.7 2 7.9 8.3h.8V.5H61.8zM49.3 42.4c6.8 0 14-3.9 14-11.9 0-4.7-2.5-8.6-6.9-11l-7.8-4.3c-4.1-2.3-5.3-4.4-5.3-7.4 0-3.4 2.5-6.1 7.2-6.1 2.3 0 4 .6 5.4 1.9 1.2 1.2 2.4 3.2 3.5 6.8h.7V1.3C57.3.5 54.4 0 51 0c-6.7 0-11.7 4.2-11.7 11.3 0 4.5 2.5 8.6 6.6 11l7.2 4c4.1 2.3 5.4 4.5 5.4 7.8 0 3.9-4 6.7-8.4 6.7-2.9 0-5.3-1-7-2.7-1.1-1.1-2.3-2.8-3.1-5h4.7v-.8c-1.3-.3-2.1-.5-3.2-3.1L32.3 7.3h-.8l-9 21.4c-1.2 2.9-2 3.4-3.4 3.6v.8h8v-.8c-2.9-.3-3.7-1.7-3-3.5l1.4-3.4h8.1l1.8 4.2c.8 1.9-.4 2.4-3.3 2.6v.8h6.4v7.9c3.2.9 7.3 1.5 10.8 1.5m101.6-.4 11.9-33.1.9 28.2c.1 2.8-1 3.6-4.4 4v.8h32.8v-.8c-3.9-.6-5.1-1.3-5.1-4.1V23.7c1.3.2 2.6.3 3.6.3 7.9 0 14.7-2.9 14.7-11.7S198.5.6 190.6.6h-14.1v.8c2.9.4 3.8 1.3 3.8 4.1V37c0 2.9-1.2 4.1-4.5 4.1h-.1c-3.5 0-5-1.3-5.1-4l-1.2-31.5c-.1-2.9.9-3.8 4-4.2V.6h-9.7l-10.6 29.7L142.5.6h-10.4v.8c3.1.4 4.1 1.4 4 4.2L135 35.8c-.1 3-1.7 4.8-4.6 5.3v.8h11v-.8c-2.8-.4-4.4-2.2-4.3-5.3l.9-27.5 12.1 33.6zM119 33.3c5.3 0 12.1-4.7 12.1-12.1 0-7.3-6.7-12.1-12.1-12.1-5.3 0-12.1 4.7-12.1 12.1 0 7.3 6.7 12.1 12.1 12.1m0-22.5c3.4 0 5.7 2.8 5.7 10.4 0 7.7-2.4 10.4-5.7 10.4-3.4 0-5.7-2.8-5.7-10.4 0-7.7 2.3-10.4 5.7-10.4m99.7 16.1h-11.1l5.5-15.2zM33.1 23.8h-6.8l3.4-8.3zm-13 2.1h.8V13.5h-.8c-1.3 4.4-2.6 5.4-5 5.4h-4.6V2.1h7.3c4.2 0 6.7 2 7.9 8.3h.8V.5H0v.8c2.9.4 3.8 1.3 3.8 4.1v31.5c0 2.9-.9 3.7-3.8 4.1v.8h15.6V41c-3.9-.6-5.1-1.3-5.1-4.1V20.4h4.6c2.3.1 3.7 1.1 5 5.5" class="currentLayer" style="fill:currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="-5 0 24 14"><path d="M11.48 4.37c-.714 0-1.207.284-1.54.678a.9.9 0 0 0-.16-.238c-.273-.308-.712-.452-1.3-.452-.576 0-1.056.18-1.39.462-.172-.28-.585-.467-1.082-.467-.65 0-1.358.092-1.778.769-.01-.244-.032-.373-.169-.518-.166-.176-.436-.222-.698-.222-.478 0-.838.209-1.097.634C1.89 5.616.98 7.59 1 8.546c.014.618.428 1.063 1.17 1.08.579.015 1.034-.14 1.323-.432.26.282.655.447 1.128.447.528 0 1.059-.158 1.372-.504.264.324.68.513 1.214.51a2.19 2.19 0 0 0 1.713-.842c.22.598.777.836 1.386.836 1.335 0 1.92-.918 2.41-2.369.11-.327.256-.873.28-1.33.041-.764-.282-1.571-1.517-1.571M2.598 8.02c.69-.12.864.129.842.36-.065.692-.7.847-1.251.834-.4-.009-.76-.193-.77-.657-.017-.812.811-2.69 1.201-3.314.18-.295.4-.441.75-.441.331 0 .413.17.408.374-.013.544-.892 2.197-1.18 2.843m2.249-.639a7 7 0 0 0-.203.657c.227-.056.397-.096.688-.089.332.008.544.146.544.42 0 .666-.735.86-1.245.86-.56 0-1.052-.318-1.052-.93 0-.718.39-1.807.755-2.532.448-.89.906-1.008 1.695-1.008.347 0 .747.148.747.475 0 .454-.385.627-.766.65a5 5 0 0 1-.563.006s-.127.194-.262.538c.706-.1 1.005.06.886.47-.161.551-.639.587-1.224.483zM8.32 5.665c-.19 0-.316.12-.409.251-.204.288-.648 1.44-.717 1.912-.047.324.093.39.246.39.245 0 .52-.26.614-.688 0 0-.467-.01-.338-.425.125-.402.362-.489.756-.505.775-.032.698.54.636.846-.201.99-.907 1.787-1.904 1.787-.683 0-1.107-.378-1.107-1.075 0-.496.246-1.275.44-1.731.416-.97.852-1.65 1.962-1.65.666 0 1.191.24 1.114.864-.056.457-.286.726-.702.759-.116.009-.593-.003-.435-.454.056-.157.078-.281-.156-.281m4.192.819a6.7 6.7 0 0 1-.668 1.79c-.467.84-1.035.959-1.53.953s-1.055-.19-1.06-.954c-.002-.549.235-1.324.436-1.832.35-.92.708-1.671 1.84-1.658 1.32.016 1.085 1.19.982 1.7m-1.12-.8c-.113-.001-.212.02-.3.158-.2.258-.784 1.854-.774 2.172.004.114.067.214.208.214.161 0 .25-.111.33-.246.184-.311.7-1.721.715-2.074.004-.102-.013-.222-.179-.224" style="fill:currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-48.214 -18.217 387.865 116.248"><path d="M1.785 65.652h31.62V55.27H13.23V15.665H1.785zm47.629 0v-34.43H37.97v34.43zm-5.721-39.13c3.99 0 6.474-2.644 6.474-5.95-.074-3.378-2.484-5.947-6.398-5.947-3.915 0-6.475 2.57-6.475 5.947 0 3.306 2.484 5.95 6.324 5.95zm11.034 39.13h11.444V46.424c0-1.029.074-2.058.377-2.791.826-2.056 2.709-4.186 5.871-4.186 4.142 0 5.799 3.158 5.799 7.784v18.42H89.66V45.91c0-10.576-5.646-15.497-13.176-15.497-6.173 0-8.884 3.451-10.39 5.802h.077v-4.993H54.727c.151 3.231 0 34.43 0 34.43m51.078-49.987H94.361v49.987h11.444V54.489l2.86-3.601 8.96 14.764h14.078l-15.056-21.373 13.174-14.54h-13.776s-9.411 13.008-10.24 14.552zm56.501 35.625c.151-.884.377-2.58.377-4.498 0-8.9-4.518-17.936-16.413-17.936-12.724 0-18.597 10.063-18.597 19.19 0 11.288 7.153 18.337 19.65 18.337 4.97 0 9.561-.732 13.327-2.275l-1.506-7.558c-3.088 1.024-6.25 1.537-10.164 1.537-5.345 0-10.012-2.195-10.389-6.871l23.715.072zm-23.79-7.742c.301-2.938 2.26-7.273 7.153-7.273 5.194 0 6.4 4.628 6.4 7.273h-13.552zm52.414-27.883v17.304h-.151c-1.657-2.422-5.12-4.038-9.71-4.038-8.81 0-16.564 7.05-16.49 19.094 0 11.164 7.003 18.435 15.735 18.435 4.744 0 9.26-2.058 11.52-6.024h.225l.453 5.216h10.163c-.15-2.424-.302-6.61-.302-10.723V15.664h-11.444zm0 34.05c0 .88-.075 1.763-.227 2.495-.675 3.16-3.386 5.361-6.699 5.361-4.742 0-7.83-3.818-7.83-9.84 0-5.654 2.637-10.208 7.906-10.208 3.538 0 6.022 2.423 6.7 5.433.15.663.15 1.398.15 2.058v4.7z" style="fill:currentColor"/><mask id="a"><rect width="100%" height="100%" fill="#fff"/><path d="M236.85 65.61V31.18h-11.444v34.43h11.445zm-5.72-39.13c3.99 0 6.474-2.644 6.474-5.948-.075-3.379-2.484-5.949-6.398-5.949-3.917 0-6.475 2.57-6.475 5.949 0 3.304 2.483 5.948 6.324 5.948zm12.054 39.13h11.443V46.385c0-1.028.075-2.058.377-2.792.827-2.057 2.71-4.186 5.872-4.186 4.14 0 5.797 3.157 5.797 7.786V65.61h11.443V45.869c0-10.575-5.645-15.496-13.174-15.496-6.173 0-8.884 3.45-10.39 5.8h.076v-4.992h-11.443c.149 3.23-.001 34.43-.001 34.43z"/></mask><path d="M213.882 7.245c0-3.015 2.508-5.462 5.6-5.462h64.568c3.093 0 5.6 2.447 5.6 5.462V72.57c0 3.016-2.507 5.461-5.6 5.461h-64.568c-3.092 0-5.6-2.445-5.6-5.46V7.244z" mask="url(#a)" style="fill:currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="66" height="24" class="wordmark" viewBox="0 0 66 24"><path d="M0 17.198h3.045c.356 3.077 3.045 4.664 5.927 4.664 3.045 0 5.409-1.814 5.409-4.632 0-2.883-2.332-3.595-6.186-4.47C2.559 11.53.777 9.587.777 6.283.777 2.623 3.984 0 8.486 0c4.178 0 7.806 2.332 8.032 6.057h-3.012c-.227-2.462-2.429-3.919-5.15-3.919-2.947 0-4.761 1.879-4.761 3.951 0 2.462 2.073 3.206 6.121 4.049 5.441 1.134 7.514 3.271 7.514 6.931 0 4.243-3.498 6.931-8.356 6.931C4.081 24 .291 21.636 0 17.198M18.267 19.304c0-3.336 2.818-5.344 10.3-5.538v-.972c0-2.494-1.36-3.725-3.563-3.725-2.267 0-3.401 1.49-3.563 3.077h-2.785c.324-2.721 2.559-4.988 6.348-4.988 4.049 0 6.316 2.202 6.316 5.636v10.721h-2.623v-2.073C27.757 23.093 26.008 24 23.709 24c-2.915 0-5.442-1.717-5.442-4.696m10.3-1.458v-2.3c-5.538.162-7.547 1.458-7.547 3.66 0 1.684 1.36 2.883 3.271 2.883 2.43 0 4.276-1.652 4.276-4.243M33.684 7.676h2.656v2.332c.907-1.684 2.623-2.785 4.923-2.785 3.595 0 5.506 2.364 5.506 5.862v10.429h-2.85v-9.911c0-2.915-1.296-4.275-3.498-4.275s-3.887 1.879-3.887 4.308v9.879h-2.85zM48.389 19.304c0-3.336 2.818-5.344 10.3-5.538v-.972c0-2.494-1.36-3.725-3.563-3.725-2.267 0-3.401 1.49-3.563 3.077h-2.785c.324-2.721 2.559-4.988 6.348-4.988 4.049 0 6.316 2.202 6.316 5.636v10.721h-2.623v-2.073C57.879 23.093 56.13 24 53.83 24c-2.915 0-5.441-1.717-5.441-4.696m10.299-1.458v-2.3c-5.538.162-7.547 1.458-7.547 3.66 0 1.684 1.36 2.883 3.271 2.883 2.43 0 4.276-1.652 4.276-4.243M61.043 1.018h-1.017v3.014h-.602V1.018h-1.017v-.5h2.636zM63.251 3.263l.873-2.745h.917V4.03h-.596V1.137l-.908 2.893h-.581l-.928-2.889V4.03h-.587V.518h.933z" style="fill:currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-50 -20 288.624 78.268"><g data-name="Calque 2"><g data-name="Groupe 1909" opacity=".35"><path fill="#0540ff" d="M68.234 103.2a4.226 4.226 0 0 1-3.767-6.125L73.8 78.55a4.226 4.226 0 0 1 7.548 3.8l-9.335 18.522a4.23 4.23 0 0 1-3.781 2.323Z" data-name="Tracé 10091" style="fill:currentColor" transform="translate(-54.53 -64.931)"/></g><g data-name="Groupe 1910" opacity=".35"><path fill="#0540ff" d="M1093.234 103.213a4.226 4.226 0 0 1-3.766-6.125l9.335-18.522a4.223 4.223 0 0 1 7.542 3.8l-9.335 18.522a4.23 4.23 0 0 1-3.776 2.325" data-name="Tracé 10092" style="fill:currentColor" transform="translate(-927.653 -64.946)"/></g><path fill="#0540ff" d="M140.7 103.184a4.22 4.22 0 0 1-3.759-2.3l-9.483-18.521a4.223 4.223 0 0 1 7.517-3.853l5.7 11.122 5.594-11.1a4.226 4.226 0 0 1 7.548 3.805l-9.335 18.522a4.22 4.22 0 0 1-3.758 2.322Z" data-name="Tracé 10093" style="fill:currentColor" transform="translate(-108.179 -64.918)"/><path fill="#0540ff" d="M13.708 103.2a4.22 4.22 0 0 1-3.762-2.3L.462 82.382a4.223 4.223 0 0 1 7.517-3.852l9.483 18.522a4.226 4.226 0 0 1-3.755 6.149Z" data-name="Tracé 10094" style="fill:currentColor" transform="translate(.003 -64.936)"/><path fill="#0540ff" d="M966.234 103.219a4.226 4.226 0 0 1-3.766-6.125l9.332-18.522a4.22 4.22 0 0 1 3.758-2.322h.013a4.22 4.22 0 0 1 3.758 2.3l9.483 18.522a4.223 4.223 0 0 1-7.517 3.852L975.6 89.8l-5.588 11.1a4.23 4.23 0 0 1-3.778 2.319" data-name="Tracé 10095" style="fill:currentColor" transform="translate(-819.471 -64.952)"/><path fill="#0540ff" d="M1165.708 103.2a4.22 4.22 0 0 1-3.762-2.3l-9.483-18.522a4.223 4.223 0 1 1 7.517-3.852l9.483 18.522a4.226 4.226 0 0 1-3.755 6.149Z" data-name="Tracé 10096" style="fill:currentColor" transform="translate(-981.302 -64.936)"/><path fill="#0540ff" d="m578.369 30.891-8.541-6.677 8.446-5.666a4 4 0 0 0-4.456-6.647l-11.737 7.869V4.186a4.186 4.186 0 1 0-8.372 0v29.783a4.186 4.186 0 0 0 8.372 0V28.32l11.36 8.871a4 4 0 1 0 4.927-6.3Z" data-name="Tracé 10097" style="fill:currentColor" transform="translate(-471.665)"/><path fill="#0540ff" d="M356.4 90.163a13.4 13.4 0 1 0-7 11.854 3.852 3.852 0 0 0 7-2.223zM343 96.09a5.853 5.853 0 1 1 5.853-5.853A5.853 5.853 0 0 1 343 96.09" data-name="Tracé 10098" style="fill:currentColor" transform="translate(-280.762 -65.455)"/><path fill="#0540ff" d="M782.4 90.163a13.4 13.4 0 1 0-7 11.854 3.853 3.853 0 0 0 7-2.223zM769 96.09a5.853 5.853 0 1 1 5.853-5.853A5.853 5.853 0 0 1 769 96.09" data-name="Tracé 10099" style="fill:currentColor" transform="translate(-643.64 -65.455)"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,11 +1,8 @@
import type { SocialProvider } from "better-auth/social-providers";
import { useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useMemo, useRef, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
FaApple,
@@ -17,7 +14,6 @@ import {
FaGoogle,
FaLinkedin,
FaMicrosoft,
FaOpenid,
FaReddit,
FaSpotify,
FaTiktok,
@@ -35,8 +31,6 @@ import Button from "~/components/Button";
import Input from "~/components/Input";
import { usePopup } from "~/providers/popup";
type AuthProvider = SocialProvider | "oidc";
interface FormValues {
name?: string;
email: string;
@@ -46,7 +40,6 @@ interface FormValues {
interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean;
callbackURL?: string;
}
const EmailSchema = z.object({
@@ -146,43 +139,16 @@ const availableSocialProviders = {
name: "Zoom",
icon: SiZoom,
},
oidc: {
id: "oidc",
name: "OIDC",
icon: FaOpenid,
},
};
export function Auth({
setIsMagicLinkSent,
isSignUp,
callbackURL: callbackURLProp,
}: AuthProps) {
const [isCloudEnv, setIsCloudEnv] = useState(false);
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | AuthProvider>(null);
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false);
useState<null | SocialProvider>(null);
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { showPopup } = usePopup();
const oidcProviderName = "OIDC";
const passwordRef = useRef<HTMLInputElement | null>(null);
const redirect = useSearchParams().get("next");
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
// Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => {
const credentialsAllowed =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
const emailSendingEnabled =
env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true";
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
setIsCloudEnv(isCloudEnv);
setIsEmailSendingEnabled(emailSendingEnabled);
setIsCredentialsEnabled(credentialsAllowed);
}, []);
const {
register,
@@ -200,7 +166,7 @@ export function Auth({
const handleLoginWithEmail = async (
email: string,
password?: string | null,
password?: string,
name?: string,
) => {
setIsLoginWithEmailPending(true);
@@ -212,13 +178,13 @@ export function Auth({
name,
email,
password,
callbackURL,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: t`Success`,
message: t`You have been signed up successfully.`,
header: "Success",
message: "You have been signed up successfully.",
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
@@ -229,13 +195,13 @@ export function Auth({
{
email,
password,
callbackURL,
callbackURL: "/boards",
},
{
onSuccess: () =>
showPopup({
header: t`Success`,
message: t`You have been logged in successfully.`,
header: "Success",
message: "You have been logged in successfully.",
icon: "success",
}),
onError: ({ error }) => setLoginError(error.message),
@@ -243,103 +209,44 @@ export function Auth({
);
}
} else {
// Only allow magic link if email sending is enabled and not in sign up mode
if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) {
await authClient.signIn.magicLink(
{
email,
callbackURL,
},
{
onSuccess: () => setIsMagicLinkSent(true, email),
onError: ({ error }) => setLoginError(error.message),
},
);
} else {
// Provide a clear error feedback when password omitted but magic link unavailable
setLoginError(
isSignUp
? t`Password is required to sign up.`
: t`Password is required to login.`,
);
}
await authClient.signIn.magicLink(
{
email,
callbackURL: "/boards",
},
{
onSuccess: () => setIsMagicLinkSent(true, email),
onError: ({ error }) => setLoginError(error.message),
},
);
}
setIsLoginWithEmailPending(false);
};
const handleLoginWithProvider = async (provider: AuthProvider) => {
const handleLoginWithProvider = async (provider: SocialProvider) => {
setIsLoginWithProviderPending(provider);
setLoginError(null);
let error;
if (provider === "oidc") {
// Use oauth2 signin for OIDC provider
const result = await authClient.signIn.oauth2({
providerId: "oidc",
callbackURL,
});
error = result.error;
} else {
// Use social signin for traditional social providers
const result = await authClient.signIn.social({
provider,
callbackURL,
});
error = result.error;
}
const { error } = await authClient.signIn.social({
provider,
callbackURL: "/boards",
});
setIsLoginWithProviderPending(null);
if (error) {
setLoginError(
t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
);
}
};
const onSubmit = async (values: FormValues) => {
// Treat empty password string as undefined to trigger magic link path
const sanitizedPassword = values.password?.trim()
? values.password
: undefined;
await handleLoginWithEmail(values.email, sanitizedPassword, values.name);
await handleLoginWithEmail(values.email, values.password, values.name);
};
const password = watch("password");
const isMagicLinkAvailable = useMemo(() => {
return isCloudEnv || (isEmailSendingEnabled && !isSignUp);
}, [isCloudEnv, isEmailSendingEnabled, isSignUp]);
// Determine if we should operate in magic link mode for current form state (login only)
const isMagicLinkMode = useMemo(() => {
// Magic link only viable when email sending enabled AND not sign up.
if (!isEmailSendingEnabled || isSignUp) return false;
// If credentials disabled we always default to magic link.
if (!isCredentialsEnabled) return true;
// Credentials enabled: user chooses magic link by leaving password blank.
return !password;
}, [isEmailSendingEnabled, isSignUp, isCredentialsEnabled, password]);
// Auto-focus password field when an error indicates it's required
useEffect(() => {
if (!isCredentialsEnabled) return;
// Focus when: sign up and missing password; login error requiring password; validation error on password.
const pwdEmpty = (password ?? "").length === 0;
let needsPassword = false;
if (isSignUp && pwdEmpty) {
needsPassword = true;
} else if (loginError?.toLowerCase().includes("password")) {
needsPassword = true;
} else if (errors.password) {
needsPassword = true;
}
if (needsPassword && passwordRef.current) {
passwordRef.current.focus();
}
}, [isSignUp, password, loginError, errors.password, isCredentialsEnabled]);
return (
<div className="space-y-6">
{socialProviders?.length !== 0 && (
@@ -350,104 +257,85 @@ export function Auth({
}
return (
<Button
key={key}
onClick={() => handleLoginWithProvider(key as AuthProvider)}
onClick={() => handleLoginWithProvider(key as SocialProvider)}
isLoading={isLoginWithProviderPending === key}
iconLeft={<provider.icon />}
fullWidth
size="lg"
>
<Trans>
Continue with{" "}
{key === "oidc" ? oidcProviderName : provider.name}
</Trans>
Continue with {provider.name}
</Button>
);
})}
</div>
)}
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
socialProviders?.length === 0 && (
<div className="flex w-full items-center gap-4">
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
<span className="text-center text-sm text-light-900 dark:text-dark-900">
{t`No authentication methods are currently available`}
<form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
or
</span>
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
{(isCredentialsEnabled || isMagicLinkAvailable) && (
<form onSubmit={handleSubmit(onSubmit)}>
{socialProviders?.length !== 0 && (
<div className="mb-[1.5rem] flex w-full items-center gap-4">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<span className="text-sm text-light-900 dark:text-dark-900">
{t`or`}
</span>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</div>
)}
<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 className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("email", { required: true })}
placeholder={t`Enter your email address`}
{...register("name", { required: true })}
placeholder="Enter your name"
/>
{errors.email && (
{errors.name && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`}
Please enter a valid name
</p>
)}
</div>
{isCredentialsEnabled && (
<div>
<Input
type="password"
{...register("password", { required: true })}
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
<div>
<Input
{...register("email", { required: true })}
placeholder="Enter your email address"
/>
{errors.email && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid email address
</p>
)}
</div>
<div className="mt-[1.5rem] flex items-center gap-4">
<Button
isLoading={isLoginWithEmailPending}
fullWidth
size="lg"
variant="secondary"
>
{isSignUp ? t`Sign up with ` : t`Continue with `}
{isMagicLinkMode ? t`magic link` : t`email`}
</Button>
</div>
</form>
)}
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
{isCredentialsEnabled && (
<div>
<Input
type="password"
{...register("password", { required: true })}
placeholder="Enter your password"
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
Please enter a valid password
</p>
)}
</div>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
<div className="mt-[1.5rem] flex items-center gap-4">
<Button
isLoading={isLoginWithEmailPending}
fullWidth
size="lg"
variant="secondary"
>
{isSignUp ? "Sign up with " : "Continue with "}
{!isCredentialsEnabled || (password && password.length !== 0)
? "email"
: "magic link"}
</Button>
</div>
</form>
</div>
);
}

View File

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

View File

@@ -5,14 +5,13 @@ import LoadingSpinner from "./LoadingSpinner";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "xs" | "sm" | "md" | "lg";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
href?: string;
fullWidth?: boolean;
openInNewTab?: boolean;
iconOnly?: boolean;
}
const Button = ({
@@ -25,24 +24,13 @@ const Button = ({
href,
fullWidth,
openInNewTab,
iconOnly,
...props
}: ButtonProps) => {
const classes = twMerge(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
size === "xs" && "text-xs px-2 py-1",
size === "sm" && "text-xs",
size === "lg" && "py-[0.65rem]",
fullWidth && "w-full",
iconOnly && "p-0",
iconOnly &&
(size === "xs"
? "h-6 w-6"
: size === "sm"
? "h-8 w-8"
: size === "lg"
? "h-10 w-10"
: "h-9 w-9"),
variant === "primary" &&
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
variant === "secondary" &&
@@ -58,60 +46,19 @@ const Button = ({
<span className="relative flex items-center justify-center">
{isLoading && (
<span className="absolute">
<LoadingSpinner size={size === "xs" ? "sm" : size} />
<LoadingSpinner size={size} />
</span>
)}
{iconOnly ? (
<div
className={twMerge(
"flex items-center",
isLoading ? "invisible" : "visible",
)}
>
{iconLeft ?? iconRight}
</div>
) : (
<div
className={twMerge(
fullWidth
? "grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2"
: "flex items-center",
isLoading ? "invisible" : "visible",
)}
>
{fullWidth && !iconLeft && iconRight && (
<span className="col-start-1 opacity-0">{iconRight}</span>
)}
{iconLeft && (
<span
className={twMerge(
fullWidth ? "col-start-1 justify-self-start" : "mr-2",
)}
>
{iconLeft}
</span>
)}
<span
className={twMerge(
fullWidth ? "col-start-2 justify-self-center text-center" : "",
)}
>
{children}
</span>
{iconRight && (
<span
className={twMerge(
fullWidth ? "col-start-3 justify-self-end" : "ml-1",
)}
>
{iconRight}
</span>
)}
{fullWidth && !iconRight && iconLeft && (
<span className="col-start-3 opacity-0">{iconLeft}</span>
)}
</div>
)}
<div
className={twMerge(
"flex items-center",
isLoading ? "invisible" : "visible",
)}
>
{iconLeft && <span className="mr-2">{iconLeft}</span>}
{children}
{iconRight && <span className="ml-1">{iconRight}</span>}
</div>
</span>
);

View File

@@ -31,7 +31,6 @@ interface CheckboxDropdownProps {
handleEdit?: (key: string) => void;
handleCreate?: () => void;
asChild?: boolean;
disabled?: boolean;
}
export default function CheckboxDropdown({
@@ -45,7 +44,6 @@ export default function CheckboxDropdown({
handleEdit,
handleCreate,
asChild = true,
disabled = false,
}: CheckboxDropdownProps) {
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
@@ -57,67 +55,53 @@ export default function CheckboxDropdown({
const renderMenuItems = (items: Item[], groupKey: string | null) => (
<>
{items.length > 0 ? (
items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
{items.map((item) => (
<Menu.Item key={item.key}>
<div
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleSelect(groupKey, { key: item.key, value: item.value });
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(groupKey, { key: item.key, value: item.value })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">{item.leftIcon}</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
{item.value}
</label>
{handleEdit && (
<button
className="invisible ml-auto group-hover:visible"
onClick={(event) => {
event.stopPropagation();
handleEdit(item.key);
}}
>
<input
id={item.key}
name={item.key}
type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()}
onChange={() =>
handleSelect(groupKey, { key: item.key, value: item.value })
}
checked={item.selected}
/>
{item.leftIcon && (
<span className="ml-3 flex items-center">{item.leftIcon}</span>
)}
<label
htmlFor={item.key}
className="ml-3 text-[12px] text-dark-900"
>
{item.value}
</label>
{handleEdit && (
<button
type="button"
className="invisible ml-auto group-hover:visible"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleEdit(item.key);
}}
>
<HiEllipsisHorizontal size={20} className="text-dark-900" />
</button>
)}
</div>
</Menu.Item>
))
) : (
!handleCreate && (
<div className="flex items-center p-2 text-[12px] text-dark-900">
No items
<HiEllipsisHorizontal size={20} className="text-dark-900" />
</button>
)}
</div>
)
)}
</Menu.Item>
))}
{handleCreate && (
<button
type="button"
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
handleCreate();
}}
onClick={() => handleCreate()}
>
<HiMiniPlus size={20} className="pr-1.5" />
{createNewItemLabel}
@@ -134,8 +118,7 @@ export default function CheckboxDropdown({
<>
<Menu.Button
as={asChild ? "div" : undefined}
disabled={disabled}
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
className="h-full w-full cursor-pointer focus-visible:outline-none"
>
{children}
</Menu.Button>

View File

@@ -1,58 +0,0 @@
import { twMerge } from "tailwind-merge";
interface CircularProgressProps {
progress: number; // 0-100
size?: "sm" | "md" | "lg";
className?: string;
}
const CircularProgress = ({
progress,
size = "md",
className,
}: CircularProgressProps) => {
const radius = 40;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<div className={twMerge("relative", className)}>
<svg
className={twMerge(
"-rotate-90 transform",
size === "sm" && "h-4 w-4",
size === "md" && "h-5 w-5",
size === "lg" && "h-8 w-8",
)}
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="14"
className="text-light-300 dark:text-dark-400"
/>
<circle
cx="50"
cy="50"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="14"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={twMerge(
"transition-all duration-300 ease-in-out",
progress === 100 ? "text-green-500" : "text-blue-500",
)}
/>
</svg>
</div>
);
};
export default CircularProgress;

View File

@@ -1,202 +0,0 @@
import { useRouter } from "next/router";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Dialog,
DialogBackdrop,
DialogPanel,
} from "@headlessui/react";
import { t } from "@lingui/macro";
import { useState } from "react";
import { HiDocumentText, HiFolder, HiMagnifyingGlass } from "react-icons/hi2";
import { useDebounce } from "~/hooks/useDebounce";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
type SearchResult =
| {
publicId: string;
title: string;
description: string | null;
slug: string;
updatedAt: Date | null;
createdAt: Date;
type: "board";
}
| {
publicId: string;
title: string;
description: string | null;
boardPublicId: string;
boardName: string;
listName: string;
cardNumber: number | null;
updatedAt: Date | null;
createdAt: Date;
type: "card";
};
export default function CommandPallette({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const { workspace } = useWorkspace();
const router = useRouter();
// Debounce to avoid too many reqs
const [debouncedQuery] = useDebounce(query, 300);
const {
data: searchResults,
isLoading,
isFetched,
isPlaceholderData,
} = api.workspace.search.useQuery(
{
workspacePublicId: workspace.publicId,
query: debouncedQuery,
},
{
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
placeholderData: (previousData) => previousData,
},
);
// Clear results when query is empty, otherwise show search results
const results =
debouncedQuery.trim().length === 0
? []
: ((searchResults ?? []) as SearchResult[]);
const hasSearched = Boolean(debouncedQuery.trim().length > 0);
return (
<Dialog
className="relative z-50"
open={isOpen}
onClose={() => {
onClose();
setQuery("");
}}
>
<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 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<DialogPanel
transition
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
>
<Combobox>
<div className="grid grid-cols-1">
<ComboboxInput
autoFocus
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700"
placeholder={t`Search boards and cards...`}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && results.length > 0) {
event.preventDefault();
// Find the active option or fallback to first option
const targetOption =
document.querySelector(
'[data-headlessui-state*="active"][role="option"]',
) ?? document.querySelector('[role="option"]');
if (targetOption) {
(targetOption as HTMLElement).click();
}
}
}}
/>
<HiMagnifyingGlass
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
aria-hidden="true"
/>
</div>
{results.length > 0 && (
<ComboboxOptions
static
className={`max-h-72 scroll-py-2 overflow-y-auto py-2 ${
isPlaceholderData ? "opacity-75" : ""
}`}
>
{results.map((result) => {
const url =
result.type === "board"
? `/boards/${result.publicId}`
: `/cards/${result.publicId}`;
return (
<ComboboxOption
key={`${result.type}-${result.publicId}`}
value={result}
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
onClick={() => {
void router.push(url);
onClose();
setQuery("");
}}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
{result.type === "board" ? (
<HiFolder className="h-4 w-4 text-light-600 dark:text-dark-600" />
) : (
<HiDocumentText className="h-4 w-4 text-light-600 dark:text-dark-600" />
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" &&
result.cardNumber != null &&
workspace.cardPrefix && (
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
{workspace.cardPrefix}-{result.cardNumber}
</span>
)}
</div>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">
{`${t`in`} ${result.boardName}${result.listName}`}
</div>
)}
</div>
</div>
</ComboboxOption>
);
})}
</ComboboxOptions>
)}
{hasSearched &&
!isLoading &&
searchResults !== undefined &&
results.length === 0 && (
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
{t`No results found for "${debouncedQuery}".`}
</div>
)}
</Combobox>
</DialogPanel>
</div>
</div>
</Dialog>
);
}

View File

@@ -1,173 +1,11 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
TbLayoutSidebarRightCollapse,
TbLayoutSidebarRightExpand,
} from "react-icons/tb";
import Link from "next/link";
import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
import Button from "./Button";
import Modal from "./modal";
import FeedbackButton from "./FeedbackButton";
import SideNavigation from "./SideNavigation";
interface DashboardProps {
children: React.ReactNode;
rightPanel?: React.ReactNode;
hasRightPanel?: boolean;
}
export function getDashboardLayout(
page: React.ReactElement,
rightPanel?: React.ReactNode,
hasRightPanel = false,
) {
return (
<WorkspaceProvider>
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
{page}
</Dashboard>
</WorkspaceProvider>
);
}
export default function Dashboard({
children,
rightPanel,
hasRightPanel = false,
}: DashboardProps) {
const { resolvedTheme } = useTheme();
const { openModal, closeModal, modalContentType } = useModal();
const { availableWorkspaces, hasLoaded } = useWorkspace();
const { showPopup } = usePopup();
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const sideNavRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const sideNavButtonRef = useRef<HTMLButtonElement>(null);
const rightPanelButtonRef = useRef<HTMLButtonElement>(null);
const toggleSideNav = () => {
setIsSideNavOpen(!isSideNavOpen);
if (!isSideNavOpen) {
setIsRightPanelOpen(false);
}
};
const closeSideNav = () => {
setIsSideNavOpen(false);
};
const toggleRightPanel = () => {
setIsRightPanelOpen(!isRightPanelOpen);
if (!isRightPanelOpen) {
setIsSideNavOpen(false);
}
};
useClickOutside(sideNavRef, (event) => {
if (sideNavButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isSideNavOpen) {
setIsSideNavOpen(false);
}
});
useClickOutside(rightPanelRef, (event) => {
if (rightPanelButtonRef.current?.contains(event.target as Node)) {
return;
}
if (isRightPanelOpen) {
setIsRightPanelOpen(false);
}
});
useEffect(() => {
const partnerActivated = searchParams.get("partner_activated");
const partnerError = searchParams.get("partner_error");
if (partnerActivated) {
showPopup({
header: t`License activated`,
message: t`Your license has been activated successfully.`,
icon: "success",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_activated");
router.replace(`?${params.toString()}`);
} else if (partnerError) {
const messages: Record<string, string> = {
invalid_license: t`That license key could not be found. Please contact support.`,
license_inactive: t`Your license is not active. Please check your account.`,
missing_license: t`No license key was provided. Please try activating again.`,
};
showPopup({
header: t`License activation failed`,
message:
messages[partnerError] ??
t`Something went wrong during license activation.`,
icon: "error",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_error");
router.replace(`?${params.toString()}`);
}
}, [searchParams, showPopup, router]);
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
openModal("NEW_WORKSPACE", undefined, undefined, false);
}
}
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
useEffect(() => {
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
if (
!userLoading &&
user &&
isCredentialsEnabled &&
user.hasMagicLinkAccount &&
!user.hasPassword &&
!sessionStorage.getItem("set_password_prompted")
) {
sessionStorage.setItem("set_password_prompted", "1");
openModal("SET_PASSWORD_PROMPT");
}
}, [user, userLoading, openModal]);
const isDarkMode = resolvedTheme === "dark";
export default function Dashboard(props: { children: React.ReactNode }) {
const { data, isLoading } = api.user.getUser.useQuery();
return (
<>
@@ -175,116 +13,28 @@ export default function Dashboard({
html {
height: 100vh;
overflow: hidden;
min-width: 320px;
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
}
`}</style>
<div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
{/* Mobile Header */}
<div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
<button
ref={sideNavButtonRef}
onClick={toggleSideNav}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
>
{isSideNavOpen ? (
<TbLayoutSidebarLeftCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarLeftExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
{hasRightPanel && (
<button
ref={rightPanelButtonRef}
onClick={toggleRightPanel}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
>
{isRightPanelOpen ? (
<TbLayoutSidebarRightCollapse
size={20}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarRightExpand
size={20}
className="text-light-900 dark:text-dark-900"
/>
)}
</button>
)}
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
<div className="my-auto flex w-full items-center justify-between">
<Link href="/">
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<FeedbackButton />
</div>
</div>
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
<div
ref={sideNavRef}
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
>
<SideNavigation
user={{
displayName: user?.name ?? session?.user.name,
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
onCloseSideNav={closeSideNav}
/>
</div>
<div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
<div className="relative flex h-full min-h-0 w-full overflow-hidden">
<div className="h-full w-full overflow-y-auto">{children}</div>
{/* Mobile Right Panel */}
{hasRightPanel && rightPanel && (
<div
ref={rightPanelRef}
className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="h-full">{rightPanel}</div>
</div>
)}
{/* Desktop Right Panel */}
{hasRightPanel && rightPanel && (
<div className="hidden md:block">{rightPanel}</div>
)}
</div>
</div>
<div className="flex h-full w-full">
<SideNavigation
user={{ email: data?.email, image: data?.image }}
isLoading={isLoading}
/>
<div className="w-full overflow-hidden">{props.children}</div>
</div>
</div>
<Modal
modalSize="sm"
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
>
{user?.hasPassword ? (
<div className="p-5">
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
<p className="mb-6 text-sm text-light-900">
{t`Your account already has a password. You can change it from your account settings.`}
</p>
<Button
variant="secondary"
onClick={closeModal}
fullWidth
size="lg"
>
{t`Close`}
</Button>
</div>
) : (
<ChangePasswordFormConfirmation hasPassword={false} />
)}
</Modal>
</>
);
}

View File

@@ -1,141 +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;
weekStartsOn?: 0 | 1 | 6;
}
const DateSelector = ({
selectedDate,
onDateSelect,
weekStartsOn = 1,
}: 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 });
return eachDayOfInterval({
start: weekStart,
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
}, [weekStartsOn]);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
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, weekStartsOn]);
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;

View File

@@ -1,5 +1,3 @@
import { t } from "@lingui/core/macro";
import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -12,23 +10,21 @@ export function DeleteLabelConfirmation({
labelPublicId: string;
refetch: () => void;
}) {
const { closeModal, closeModals } = useModal();
const { closeModal } = useModal();
const { showPopup } = usePopup();
const deleteLabelMutation = api.label.delete.useMutation({
onSuccess: () => {
refetch();
closeModals(2);
},
onSuccess: () => refetch(),
onError: () =>
showPopup({
header: t`Error deleting label`,
message: t`Please try again later, or contact customer support.`,
header: "Error deleting label",
message: "Please try again later, or contact customer support.",
icon: "error",
}),
});
const handleDeleteLabel = () => {
closeModal();
deleteLabelMutation.mutate({
labelPublicId,
});
@@ -38,17 +34,17 @@ export function DeleteLabelConfirmation({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{t`Are you sure you want to delete this label?`}
Are you sure you want to delete this label?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{t`This action can't be undone.`}
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
{t`Cancel`}
Cancel
</Button>
<Button onClick={handleDeleteLabel}>{t`Delete`}</Button>
<Button onClick={handleDeleteLabel}>Delete</Button>
</div>
</div>
);

View File

@@ -6,7 +6,7 @@ export default function Dropdown({
children,
disabled,
}: {
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
items: { label: string; action: () => void; icon?: React.ReactNode }[];
children: React.ReactNode;
disabled?: boolean;
}) {
@@ -15,7 +15,7 @@ export default function Dropdown({
<div>
<Menu.Button
disabled={disabled}
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 focus:outline-none dark:hover:bg-dark-200"
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200"
>
{children}
</Menu.Button>
@@ -30,14 +30,13 @@ export default function Dropdown({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<Menu.Items className="absolute right-0 z-30 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex flex-col">
{items.map((item) => (
<Menu.Item key={item.label} disabled={item.disabled}>
<Menu.Item key={item.label}>
<button
onClick={item.action}
disabled={item.disabled ?? !item.action}
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
>
{item.icon}
{item.label}

View File

@@ -1,4 +1,3 @@
import type { Range as TiptapRange } from "@tiptap/core";
import type { Editor as TiptapEditor } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
@@ -6,9 +5,6 @@ import type {
} from "@tiptap/suggestion";
import type { Instance as TippyInstance } from "tippy.js";
import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import Link from "@tiptap/extension-link";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import {
BubbleMenu,
@@ -17,16 +13,9 @@ import {
ReactRenderer,
useEditor,
} from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import {
HiH1,
HiH2,
@@ -42,11 +31,6 @@ import {
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import tippy from "tippy.js";
import { Markdown } from "tiptap-markdown";
import { getAvatarUrl } from "~/utils/helpers";
import Avatar from "./Avatar";
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -59,7 +43,7 @@ declare module "@tiptap/core" {
export interface SlashCommandItem {
title: string;
icon?: React.ReactNode;
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
command?: (props: { editor: TiptapEditor; range: Range }) => void;
disabled?: boolean;
}
@@ -82,16 +66,6 @@ export interface RenderSuggestionsProps {
command: (item: SlashCommandItem) => void;
}
export interface WorkspaceMember {
publicId: string;
user: {
id: string | null;
name: string | null;
image: string | null;
} | null;
email: string;
}
const CommandsList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
{
@@ -205,131 +179,6 @@ const RenderSuggestions = () => {
};
};
interface MentionItem {
id: string;
label: string;
image: string | null;
}
const MentionList = forwardRef<
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
{
items: MentionItem[];
command: (item: MentionItem) => void;
}
>(({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
if (event.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (event.key === "Enter") {
const item = items[selectedIndex];
if (item) {
command(item);
}
return true;
}
return false;
},
}));
return (
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
{items.length > 0 ? (
items.map((item, index) => (
<button
key={item.id}
onClick={() => command(item)}
className={twMerge(
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
)}
>
<Avatar
size="xs"
name={item.label}
imageUrl={item.image ? getAvatarUrl(item.image) : undefined}
email={item.label}
/>
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
{item.label}
</span>
</button>
))
) : (
<div className="flex items-center justify-start p-2">
<span className="text-[12px] text-dark-900 dark:text-dark-1000">
No results
</span>
</div>
)}
</div>
</div>
);
});
MentionList.displayName = "MentionList";
const renderMentionSuggestions = () => {
let reactRenderer: ReactRenderer;
let popup: TippyInstance[];
return {
onStart: (props: any) => {
reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: any) {
reactRenderer.updateProps(props);
if (!props.clientRect) return;
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
popup[0]?.hide();
return true;
}
return (
(
reactRenderer.ref as {
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
}
).onKeyDown?.(props) ?? false
);
},
onExit() {
popup[0]?.destroy();
reactRenderer.destroy();
},
};
};
const SlashCommands = Extension.create<SlashCommandsOptions>({
name: "slash-commands",
addOptions() {
@@ -387,170 +236,78 @@ export interface SlashNodeAttrs {
label?: string | null;
}
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
const headingCommands: SlashCommandItem[] = disableHeadings
? []
: [
{
title: "Heading 1",
icon: <HiH1 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 1 }).run(),
},
{
title: "Heading 2",
icon: <HiH2 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 2 }).run(),
},
{
title: "Heading 3",
icon: <HiH3 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 3 }).run(),
},
];
return [
...headingCommands,
{
title: "Bullet List",
icon: <HiOutlineListBullet />,
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
title: "Ordered List",
icon: <HiOutlineNumberedList />,
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
{
title: "Blockquote",
icon: <HiOutlineChatBubbleLeftEllipsis />,
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
};
const CommandItems: SlashCommandItem[] = [
{
title: "Heading 1",
icon: <HiH1 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 1 }).run(),
},
{
title: "Heading 2",
icon: <HiH2 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 2 }).run(),
},
{
title: "Heading 3",
icon: <HiH3 />,
command: ({ editor }) =>
editor.chain().focus().setHeading({ level: 3 }).run(),
},
{
title: "Bullet List",
icon: <HiOutlineListBullet />,
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
{
title: "Ordered List",
icon: <HiOutlineNumberedList />,
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
},
{
title: "Blockquote",
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({
content,
onChange,
onBlur,
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
}: {
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
heading: disableHeadings ? false : undefined,
}),
Link.configure({
openOnClick: true,
HTMLAttributes: {
class: "text-blue-600 hover:text-blue-800 underline cursor-pointer",
target: "_blank",
rel: "noopener noreferrer",
},
validate: (href) => /^https?:\/\//.test(href),
autolink: true,
linkOnPaste: true,
}),
Markdown.configure({ transformPastedText: true }),
StarterKit,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
: "Add description... (type '/' to open commands)",
}),
SlashCommands.configure({
commandItems: getCommandItems(disableHeadings),
commandItems: CommandItems,
suggestion: {
items: ({ query }: { query: string }) =>
filterSlashCommandItems(getCommandItems(disableHeadings), query),
filterSlashCommandItems(CommandItems, query),
startOfLine: true,
char: "/",
},
}),
Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion: {
char: "@",
items: ({ query }: { query: string }) => {
const withEmail = workspaceMembers.filter((member) => member.email);
const mapped = withEmail.map((member: WorkspaceMember) => ({
id: member.publicId,
label: member?.user?.name?.trim() || member.email || "",
image: member?.user?.image ?? null,
}));
const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0,
);
const q = query.toLowerCase().trim();
if (q === "") {
return all;
}
const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q),
);
return filtered;
},
command: ({ editor, range, props }) => {
const id = props.id ?? "";
const label = props.label ?? "";
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span>&nbsp;`;
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(mentionHTML)
.focus()
.run();
},
render: renderMentionSuggestions,
},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
}),
Typography.configure({
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []),
],
content,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
@@ -574,23 +331,14 @@ export default function Editor({
editable: !readOnly,
injectCSS: false,
},
[], // creating the editor only once
[content],
);
// this will sync external content changes without re-creating the editor instance
useEffect(() => {
if (!editor) return;
const currentHTML = editor.getHTML();
const safeContent = content ?? "";
if (safeContent !== currentHTML) {
editor.commands.setContent(safeContent, false);
}
}, [content, editor]);
return (
<div ref={containerRef}>
<style jsx global>{`
.tiptap p.is-empty::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
@@ -599,22 +347,11 @@ export default function Editor({
.tiptap p {
margin: 0 0 1rem 0 !important;
}
.tiptap .mention {
background-color: rgba(59, 130, 246, 0.1);
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
color: rgb(59, 130, 246);
text-decoration: none;
font-weight: 500;
}
.tiptap [data-youtube] {
margin: 1rem 0;
}
`}</style>
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent
editor={editor}
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-light-950 [&_p]:dark:text-dark-950 [&_ul]:!text-xs"
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
/>
</div>
);

View File

@@ -0,0 +1,148 @@
import Link from "next/link";
import { Menu, Transition } from "@headlessui/react";
import { Fragment, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import chatIconDark from "~/assets/chat-dark.json";
import chatIconLight from "~/assets/chat-light.json";
import Button from "~/components/Button";
import Input from "~/components/Input";
import LottieIcon from "~/components/LottieIcon";
import { usePopup } from "~/providers/popup";
import { useTheme } from "~/providers/theme";
import { api } from "~/utils/api";
interface NewFeedbackFormInput {
feedback: string;
}
const FeedbackButton: React.FC = () => {
const outsideRef = useRef<HTMLDivElement>(null);
const { activeTheme } = useTheme();
const { showPopup } = usePopup();
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const { handleSubmit, setValue, watch, reset } =
useForm<NewFeedbackFormInput>({
defaultValues: {
feedback: "",
},
});
const createFeedback = api.feedback.create.useMutation({
onSuccess: async () => {
reset();
showPopup({
header: "Feedback sent",
message: "Thank you for your feedback!",
icon: "success",
});
},
onError: async () => {
showPopup({
header: "Unable to send feedback",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
});
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
const onSubmit = (values: NewFeedbackFormInput) => {
createFeedback.mutate({
feedback: values.feedback,
url: window.location.href,
});
};
return (
<>
<div ref={outsideRef} />
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button
onMouseEnter={handleMouseEnter}
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
>
<LottieIcon
index={index}
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
isPlaying={isHovered}
/>
<span className="ml-1">Feedback</span>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
as="div"
static
className="absolute right-0 z-30 mt-2 w-[350px] 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"
>
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
<Input
placeholder="Ideas to improve this page..."
onChange={(e) => {
setValue("feedback", e.target.value);
}}
value={watch("feedback")}
contentEditable
className="max-h-[300px] min-h-[100px]"
onKeyDown={async (e) => {
e.stopPropagation();
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<div className="flex flex-row items-center justify-between pt-2">
<div>
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
Need help?{" "}
<Link
href="mailto:support@kan.bn"
className="text-blue-600 underline dark:text-blue-300"
>
Contact us
</Link>
, or see our{" "}
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline dark:text-blue-300"
>
docs
</Link>
.
</p>
</div>
<div className="justify-end">
<Button size="sm" isLoading={createFeedback.isPending}>
Send
</Button>
</div>
</div>
</form>
</Menu.Items>
</Transition>
</Menu>
</>
);
};
export default FeedbackButton;

View File

@@ -1,122 +0,0 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { useEffect } 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 { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
interface NewFeedbackFormInput {
feedback: string;
}
export default function FeedbackModal() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { handleSubmit, setValue, watch, reset } =
useForm<NewFeedbackFormInput>({
defaultValues: {
feedback: "",
},
});
const createFeedback = api.feedback.create.useMutation({
onSuccess: async () => {
reset();
closeModal();
showPopup({
header: t`Feedback sent`,
message: t`Thank you for your feedback!`,
icon: "success",
});
},
onError: async () => {
showPopup({
header: t`Unable to send feedback`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const onSubmit = (values: NewFeedbackFormInput) => {
createFeedback.mutate({
feedback: values.feedback,
url: window.location.href,
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
{t`Feedback`}
</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-200 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>
<Input
id="feedback"
placeholder={t`Ideas to improve this page...`}
onChange={(e) => setValue("feedback", e.target.value)}
contentEditable
value={watch("feedback")}
className="min-h-[120px]"
onKeyDown={async (e) => {
e.stopPropagation();
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
if (e.key === "Escape") {
closeModal();
}
}}
/>
</div>
<div className="mt-6 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="text-xs text-neutral-600 dark:text-dark-800">
<p>
{t`Need help?`}{" "}
<Link
href="mailto:support@kan.bn"
className="text-blue-600 underline dark:text-blue-300"
>
{t`Contact us`}
</Link>
{t`, or see our`}{" "}
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline dark:text-blue-300"
>
{t`docs`}
</Link>
.
</p>
</div>
<div>
<Button type="submit" isLoading={createFeedback.isPending}>
{t`Send feedback`}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -1,32 +0,0 @@
import { t } from "@lingui/core/macro";
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
import { useFontSize, type FontSize } from "~/providers/font-size";
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
{ value: "small", label: () => t`Small` },
{ value: "medium", label: () => t`Medium` },
{ value: "large", label: () => t`Large` },
];
export function FontSizeSelector() {
const { fontSize, setFontSize } = useFontSize();
return (
<div className="relative">
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="font-size-select"
value={fontSize}
onChange={(e) => setFontSize(e.target.value as FontSize)}
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm 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"
>
{fontSizeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label()}
</option>
))}
</select>
</div>
);
}

View File

@@ -55,7 +55,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<div className="flex w-full flex-col gap-1">
<div className="relative flex">
{prefix && (
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-sm dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
{prefix}
</div>
)}
@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{iconRight}
</div>
)}

View File

@@ -1,5 +1,4 @@
import { Listbox, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
@@ -32,14 +31,14 @@ export function LabelForm({
refetch: () => void;
isEdit?: boolean;
}) {
const { closeModal, entityId, openModal, setModalState } = useModal();
const { closeModal, entityId, openModal } = useModal();
const label = api.label.byPublicId.useQuery(
{
labelPublicId: entityId,
},
{
enabled: !!isEdit && entityId.length >= 12,
enabled: isEdit && !!entityId,
},
);
@@ -57,25 +56,20 @@ export function LabelForm({
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const createLabel = api.label.create.useMutation({
onSuccess: (newLabel) => {
onSuccess: () => {
const currentColourIndex = colours.findIndex(
(c) => c.code === watch("colour").code,
);
try {
refetch();
setModalState("NEW_LABEL_CREATED", newLabel.publicId);
if (!isCreateAnotherEnabled) {
closeModal();
} else {
const newFormState = {
name: "",
colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
};
reset(newFormState);
}
if (!isCreateAnotherEnabled) closeModal();
reset({
name: "",
colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
});
} catch (e) {
console.error(e);
console.log(e);
}
},
});
@@ -84,6 +78,10 @@ export function LabelForm({
onSuccess: () => {
refetch();
closeModal();
reset({
name: "",
colour: colours[0],
});
},
});
@@ -116,7 +114,7 @@ export function LabelForm({
<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">
{isEdit ? t`Edit label` : t`New label`}
{isEdit ? "Edit label" : "New label"}
</h2>
<button
type="button"
@@ -132,7 +130,7 @@ export function LabelForm({
<Input
id="label-name"
placeholder={t`Name`}
placeholder="Name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
@@ -207,10 +205,10 @@ export function LabelForm({
/>
</div>
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && (
<Toggle
label={t`Create another`}
label="Create another"
isChecked={!!isCreateAnotherEnabled}
onChange={() =>
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
@@ -221,19 +219,17 @@ export function LabelForm({
<div className="space-x-2">
{isEdit && (
<Button
type="button"
variant="secondary"
onClick={() => openModal("DELETE_LABEL", entityId)}
>
{t`Delete`}
Delete
</Button>
)}
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
disabled={!watch("name")}
>
{isEdit ? t`Update label` : t`Create label`}
{isEdit ? "Update label" : "Create label"}
</Button>
</div>
</div>

View File

@@ -1,26 +0,0 @@
import { HiLanguage } from "react-icons/hi2";
import { useLocalisation } from "~/hooks/useLocalisation";
import { localeNames } from "~/locales";
export function LanguageSelector() {
const { locale, setLocale, availableLocales } = useLocalisation();
return (
<div className="relative">
<HiLanguage className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as any)}
className="mt-8 block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm 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"
>
{availableLocales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
</div>
);
}

View File

@@ -2,11 +2,11 @@ import dynamic from "next/dynamic";
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
interface IconProps {
type IconProps = {
isPlaying: boolean;
index: number;
json: object;
}
};
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
return (
@@ -15,7 +15,7 @@ const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
animationData={json}
play={isPlaying}
loop={false}
style={{ width: 18, height: 18, fill: "white" }}
style={{ width: 20, height: 20, fill: "white" }}
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
/>
);

View File

@@ -1,113 +1,41 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiCheck, HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { HiXMark } from "react-icons/hi2";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import LoadingSpinner from "./LoadingSpinner";
const schema = z.object({
name: z
.string()
.min(1, { message: t`Workspace name is required` })
.max(64, { message: t`Workspace name cannot exceed 64 characters` }),
slug: z
.string()
.min(3, {
message: t`URL must be at least 3 characters long`,
})
.max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`,
})
.optional()
.or(z.literal("")),
});
type FormValues = z.infer<typeof schema>;
interface FormValues {
name: string;
}
export function NewWorkspaceForm() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { switchWorkspace, availableWorkspaces } = useWorkspace();
const {
register,
handleSubmit,
formState: { errors },
watch,
trigger,
clearErrors,
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
slug: "",
},
mode: "onSubmit",
});
const utils = api.useUtils();
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
// Validate slug only after debounce
useEffect(() => {
if (isTyping) {
// Clear errors while typing
clearErrors("slug");
} else if (debouncedSlug) {
// Validate after debounce
void trigger("slug");
}
}, [isTyping, debouncedSlug, trigger, clearErrors]);
const checkWorkspaceSlugAvailability =
api.workspace.checkSlugAvailability.useQuery(
{
workspaceSlug: debouncedSlug ?? "",
},
{
enabled: !!debouncedSlug && debouncedSlug.length >= 3 && !errors.slug,
},
);
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
const { switchWorkspace } = useWorkspace();
const { register, handleSubmit } = useForm<FormValues>();
const createWorkspace = api.workspace.create.useMutation({
onSuccess: (values) => {
if (values.publicId && values.name) {
void utils.workspace.all.invalidate();
switchWorkspace({
publicId: values.publicId,
name: values.name,
description: values.description,
slug: values.slug,
plan: values.plan,
cardPrefix: values.cardPrefix,
role: "admin",
weekStartDay: 1,
});
closeModal();
}
},
onError: () => {
showPopup({
header: t`Unable to create workspace`,
message: t`Please try again later, or contact customer support.`,
header: "Unable to create workspace",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
@@ -119,38 +47,22 @@ export function NewWorkspaceForm() {
if (nameElement) nameElement.focus();
}, []);
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
const onSubmit = (values: FormValues) => {
// Don't submit if slug is provided but not available
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
return;
}
createWorkspace.mutate({
name: values.name,
slug: values.slug,
});
};
const isSlugAvailable =
isValidSlug &&
isWorkspaceSlugAvailable?.isAvailable &&
!isWorkspaceSlugAvailable?.isReserved;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
{t`New workspace`}
New workspace
</h2>
<button
type="button"
className={twMerge(
"rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300",
!hasAvailableWorkspaces && "invisible",
)}
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
@@ -162,9 +74,8 @@ export function NewWorkspaceForm() {
<Input
id="workspace-name"
placeholder={t`Workspace name`}
placeholder="Workspace name"
{...register("name")}
errorMessage={errors.name?.message}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -172,59 +83,11 @@ export function NewWorkspaceForm() {
}
}}
/>
<div className="mt-4">
<Input
id="workspace-slug"
placeholder={t`workspace-url`}
{...register("slug")}
className={`${
isSlugAvailable
? "focus:ring-green-500 dark:focus:ring-green-500"
: ""
}`}
errorMessage={
errors.slug?.message ??
(isWorkspaceSlugAvailable?.isAvailable === false &&
isWorkspaceSlugAvailable?.isReserved === false
? t`This workspace URL has already been taken`
: isWorkspaceSlugAvailable?.isReserved
? t`This workspace URL is reserved`
: undefined)
}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
iconRight={
slug && slug.length >= 3 && !errors.slug ? (
isWorkspaceSlugAvailable?.isAvailable ? (
<HiCheck className="h-4 w-4 text-green-500" />
) : checkWorkspaceSlugAvailability.isPending || isTyping ? (
<LoadingSpinner />
) : null
) : null
}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div>
</div>
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<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>
<Button
type="submit"
isLoading={createWorkspace.isPending}
disabled={
createWorkspace.isPending ||
(!!slug &&
(checkWorkspaceSlugAvailability.isPending ||
isWorkspaceSlugAvailable?.isAvailable === false ||
isTyping))
}
>
{t`Create workspace`}
<Button type="submit" isLoading={createWorkspace.isPending}>
Create workspace
</Button>
</div>
</div>

View File

@@ -4,11 +4,6 @@ export const PageHead = ({ title }: { title: string }) => {
return (
<Head>
<title>{title}</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<link rel="manifest" href="/manifest.json" />
</Head>
);
};

View File

@@ -3,8 +3,8 @@ const PatternedBackground = () => (
<svg className="h-full w-full">
<pattern
id="pattern"
x="10"
y="10"
x="0.034759358288862785"
y="3.335370511841166"
width="14.423223834988539"
height="14.423223834988539"
patternUnits="userSpaceOnUse"

View File

@@ -1,130 +0,0 @@
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
interface PlainTextEditorProps {
content: string;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
onEnter?: (value: string) => void;
onEscape?: () => void;
readOnly?: boolean;
placeholder?: string;
className?: string;
}
export default function PlainTextEditor({
content,
onChange,
onBlur,
onEnter,
onEscape,
readOnly = false,
placeholder,
className,
}: PlainTextEditorProps) {
const onEnterRef = useRef(onEnter);
const onEscapeRef = useRef(onEscape);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
const contentRef = useRef(content);
useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);
useEffect(() => {
onEscapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
contentRef.current = content;
}, [content]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
hardBreak: false,
}),
Placeholder.configure({ placeholder }),
],
content,
editable: !readOnly,
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === "Enter") {
event.preventDefault();
onEnterRef.current?.(view.state.doc.textContent.trim());
return true;
}
if (event.key === "Escape") {
event.preventDefault();
// Reset to original content before calling the callback
editor?.commands.setContent(contentRef.current, false);
editor?.commands.blur();
onEscapeRef.current?.();
return true;
}
return false;
},
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
},
[],
);
useEffect(() => {
if (!editor) return;
if (content !== editor.getText()) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
return (
<>
<style jsx global>{`
.plain-text-editor p.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--placeholder-color, #9ca3af);
}
.plain-text-editor .tiptap p {
margin: 0 !important;
}
`}</style>
<EditorContent
editor={editor}
className={twMerge("plain-text-editor", className)}
/>
</>
);
}

View File

@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
return (
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
>
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
<Transition
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
<div className="p-4 relative">
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="flex-shrink-0">
{popupIcon === "success" && (
<HiOutlineCheckCircle
aria-hidden="true"
className="h-5 w-5 text-green-400"
className="h-6 w-6 text-green-400"
/>
)}
{popupIcon === "error" && (
<HiOutlineExclamationCircle
aria-hidden="true"
className="h-5 w-5 text-red-400"
className="h-6 w-6 text-red-400"
/>
)}
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
{popupHeader}
</p>
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
{popupMessage}
</p>
</div>
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
<div className="ml-4 flex flex-shrink-0">
<button
type="button"
onClick={() => {
hidePopup();
}}
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
>
<span className="sr-only">Close</span>
<HiXMark
aria-hidden="true"
className="h-4 w-4 text-dark-900"
className="h-5 w-5 text-dark-900"
/>
</button>
</div>

View File

@@ -1,73 +1,37 @@
import Link from "next/link";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
import LottieIcon from "~/components/LottieIcon";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
const Button: React.FC<{
href: string;
current: boolean;
name: string;
json: object;
isCollapsed?: boolean;
onCloseSideNav?: () => void;
keyboardShortcut: KeyboardShortcut;
}> = ({
href,
current,
name,
json,
isCollapsed = false,
keyboardShortcut,
onCloseSideNav,
}) => {
}> = ({ href, current, name, json }) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const isMobile = useIsMobile();
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
const handleClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
return (
<Link
href={href}
onMouseEnter={handleMouseEnter}
onClick={handleClick}
className={twMerge(
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
isCollapsed ? "md:justify-center" : "justify-between",
current
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
className={classNames(
current ? "bg-light-200 dark:bg-dark-200" : "dark:bg-dark-50",
"group flex items-center gap-x-3 rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-200",
)}
title={isCollapsed ? name : undefined}
>
<div
className={twMerge(
"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>
)}
<LottieIcon index={index} json={json} isPlaying={isHovered} />
{name}
</Link>
);
};

View File

@@ -1,168 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import {
HiChevronDown,
HiOutlineBanknotes,
HiOutlineBolt,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
HiOutlineUser,
} from "react-icons/hi2";
import { usePermissions } from "~/hooks/usePermissions";
import { useWorkspace } from "~/providers/workspace";
interface SettingsLayoutProps {
children: React.ReactNode;
currentTab: string;
}
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
const router = useRouter();
const { workspace } = useWorkspace();
const { canViewWorkspace, canEditWorkspace } = usePermissions();
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const isAdmin = workspace.role === "admin";
const settingsTabs = [
{
key: "account",
icon: <HiOutlineUser />,
label: t`Account`,
condition: true,
},
{
key: "workspace",
icon: <HiOutlineRectangleGroup />,
label: t`Workspace`,
condition: canViewWorkspace,
},
{
key: "permissions",
icon: <HiOutlineShieldCheck />,
label: t`Permissions`,
condition: isAdmin,
},
{
key: "billing",
label: t`Billing`,
icon: <HiOutlineBanknotes />,
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
},
{
key: "api",
icon: <HiOutlineCodeBracketSquare />,
label: t`API`,
condition: true,
},
{
key: "webhooks",
icon: <HiOutlineBolt />,
label: t`Webhooks`,
condition: isAdmin,
},
{
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
label: t`Integrations`,
condition: canEditWorkspace,
},
];
const availableTabs = settingsTabs.filter((tab) => tab.condition);
// Update selected tab when currentTab prop changes
useEffect(() => {
const tabIndex = availableTabs.findIndex((tab) => tab.key === currentTab);
if (tabIndex !== -1) {
setSelectedTabIndex(tabIndex);
}
}, [currentTab, availableTabs]);
const isTabActive = (tabKey: string) => {
return currentTab === tabKey;
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]">
<div className="m-auto max-w-[1100px] px-5 py-6 md:px-28 md:py-12">
<div className="mb-8 flex w-full justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Settings`}
</h1>
</div>
<div className="focus:outline-none">
<div className="sm:hidden">
{/* Mobile dropdown */}
<Listbox
value={selectedTabIndex}
onChange={(index) => {
const tabKey = availableTabs[index]?.key;
if (tabKey) {
void router.push(`/settings/${tabKey}`);
}
}}
>
<div className="relative mb-4">
<ListboxButton className="w-full appearance-none rounded-lg border-0 bg-light-50 py-2 pl-3 pr-10 text-left text-sm text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
{availableTabs[selectedTabIndex]?.label ?? "Select a tab"}
<HiChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
/>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-lg bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
{availableTabs.map((tab) => (
<ListboxOption
key={tab.key}
value={availableTabs.indexOf(tab)}
className="relative cursor-pointer select-none py-2 pl-3 pr-9 text-light-1000 dark:text-dark-1000"
>
{tab.label}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
</div>
<div className="hidden sm:block">
<div className="border-b border-gray-200 dark:border-white/10">
<nav
aria-label="Tabs"
className="-mb-px flex space-x-8 focus:outline-none"
>
{availableTabs.map((tab) => (
<Link
key={tab.key}
href={`/settings/${tab.key}`}
className={`whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium transition-colors focus:outline-none ${
isTabActive(tab.key)
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
}`}
>
{tab.label}
</Link>
))}
</nav>
</div>
</div>
<div className="focus:outline-none">{children}</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,44 +1,22 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { Button } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { HiBolt } from "react-icons/hi2";
import {
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import type { Subscription } 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 boardsIconLight from "~/assets/boards-light.json";
import membersIconDark from "~/assets/members-dark.json";
import membersIconLight from "~/assets/members-light.json";
import settingsIconDark from "~/assets/settings-dark.json";
import settingsIconLight from "~/assets/settings-light.json";
import templatesIconDark from "~/assets/templates-dark.json";
import templatesIconLight from "~/assets/templates-light.json";
import ButtonComponent from "~/components/Button";
import ReactiveButton from "~/components/ReactiveButton";
import UserMenu from "~/components/UserMenu";
import WorkspaceMenu from "~/components/WorkspaceMenu";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { useTheme } from "~/providers/theme";
interface SideNavigationProps {
user: UserType;
isLoading: boolean;
onCloseSideNav?: () => void;
}
interface UserType {
displayName?: string | null | undefined;
email?: string | null | undefined;
image?: string | null | undefined;
}
@@ -46,147 +24,38 @@ interface UserType {
export default function SideNavigation({
user,
isLoading,
onCloseSideNav,
}: SideNavigationProps) {
const router = useRouter();
const { workspace } = useWorkspace();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isInitialised, setIsInitialised] = useState(false);
const { data: workspaceData } = api.workspace.byId.useQuery(
{ workspacePublicId: workspace.publicId },
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
);
const subscriptions = workspaceData?.subscriptions as
| Subscription[]
| undefined;
useEffect(() => {
const savedState = localStorage.getItem("kan_sidebar-collapsed");
if (savedState !== null) {
setIsCollapsed(Boolean(JSON.parse(savedState)));
}
setIsInitialised(true);
}, []);
useEffect(() => {
if (isInitialised) {
localStorage.setItem(
"kan_sidebar-collapsed",
JSON.stringify(isCollapsed),
);
}
}, [isCollapsed, isInitialised]);
const { pathname } = router;
const { resolvedTheme } = useTheme();
const { activeTheme } = useTheme();
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const isDarkMode = activeTheme === "dark";
const isDarkMode = resolvedTheme === "dark";
const navigation: {
name: string;
href: string;
icon: object;
keyboardShortcut: KeyboardShortcut;
}[] = [
const navigation = [
{
name: t`Boards`,
name: "Boards",
href: "/boards",
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`,
href: "/templates",
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: "Members",
href: "/members",
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: "Settings",
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "S" }],
action: () => router.push("/settings"),
group: "NAVIGATION",
description: t`Go to settings`,
},
},
];
const toggleCollapse = () => {
setIsCollapsed(!isCollapsed);
};
return (
<>
<nav
className={twMerge(
"flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:border-r-0 md:py-0 md:pl-0",
isCollapsed && "md:w-auto",
)}
>
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
<div>
<div className="hidden h-[45px] items-center justify-between pb-3 md:flex">
{!isCollapsed && (
<Link href="/" className="block">
<h1 className="pl-2 text-[16px] font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn
</h1>
</Link>
)}
<Button
onClick={toggleCollapse}
className={twMerge(
"flex h-8 items-center justify-center rounded-md hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed ? "w-full" : "w-8",
)}
>
{isCollapsed ? (
<TbLayoutSidebarLeftExpand
size={18}
className="text-light-900 dark:text-dark-900"
/>
) : (
<TbLayoutSidebarLeftCollapse
size={18}
className="text-light-900 dark:text-dark-900"
/>
)}
</Button>
</div>
<div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
<WorkspaceMenu isCollapsed={isCollapsed} />
<WorkspaceMenu />
<ul role="list" className="space-y-1">
{navigation.map((item) => (
<li key={item.name}>
@@ -195,50 +64,16 @@ export default function SideNavigation({
current={pathname.includes(item.href)}
name={item.name}
json={item.icon}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
keyboardShortcut={item.keyboardShortcut}
/>
</li>
))}
</ul>
</div>
<div className="space-y-2">
<UserMenu
displayName={user.displayName ?? undefined}
email={user.email ?? "Email not provided?"}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
/>
{isCloudEnv &&
!hasActiveSubscription(subscriptions, "pro") &&
!hasActiveSubscription(subscriptions, "team") && (
<div className={twMerge(isCollapsed && "flex justify-center")}>
{isCollapsed ? (
<ButtonComponent
iconLeft={<HiBolt />}
variant="secondary"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
aria-label={t`Start free trial`}
title={t`Start free trial`}
iconOnly
/>
) : (
<ButtonComponent
iconLeft={<HiBolt />}
fullWidth
variant="secondary"
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
>
{t`Start free trial`}
</ButtonComponent>
)}
</div>
)}
</div>
<UserMenu
email={user.email ?? ""}
imageUrl={user.image ?? undefined}
isLoading={isLoading}
/>
</nav>
</>
);

View File

@@ -1,22 +1,23 @@
import { CgDarkMode } from "react-icons/cg";
import { useTheme } from "next-themes";
import { useTheme } from "~/providers/theme";
const ThemeToggle = () => {
const { theme, resolvedTheme, setTheme } = useTheme();
const { themePreference, switchTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
switchTheme(themePreference === "light" ? "dark" : "light");
};
return (
<button
onClick={toggleTheme}
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
aria-label={`Switch to ${resolvedTheme === "light" ? "dark" : "light"} theme`}
aria-label={`Switch to ${themePreference === "light" ? "dark" : "light"} theme`}
>
<CgDarkMode
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
resolvedTheme === "dark" ? "rotate-180" : "rotate-0"
themePreference === "dark" ? "rotate-180" : "rotate-0"
}`}
/>
</button>

View File

@@ -5,27 +5,18 @@ const Toggle = ({
isChecked,
onChange,
label,
disabled,
showLabel = true,
labelPosition = "before",
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
labelPosition?: "before" | "after";
}) => (
<div className="flex items-center">
{showLabel && labelPosition === "before" && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
<div className="mr-4 flex items-center justify-end">
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
<Switch
checked={isChecked}
onChange={onChange}
disabled={disabled}
className={twMerge(
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
isChecked && "bg-indigo-600 dark:bg-indigo-600",
@@ -40,11 +31,6 @@ const Toggle = ({
)}
/>
</Switch>
{showLabel && labelPosition === "after" && (
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
</div>
);

View File

@@ -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>
);
}

View File

@@ -1,84 +1,49 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useTheme } from "next-themes";
import { Fragment } from "react";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { env } from "~/env";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useTheme } from "~/providers/theme";
import { getAvatarUrl } from "~/utils/helpers";
interface UserMenuProps {
imageUrl: string | undefined;
displayName: string | undefined;
email: string;
isLoading: boolean;
isCollapsed?: boolean;
onCloseSideNav?: () => void;
}
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
export default function UserMenu({
imageUrl,
email,
displayName,
isLoading,
isCollapsed = false,
onCloseSideNav,
}: UserMenuProps) {
const router = useRouter();
const { theme, setTheme } = useTheme();
const { openModal } = useModal();
const { openLegend } = useKeyboardShortcuts();
const isMobile = useIsMobile();
const { themePreference, switchTheme } = useTheme();
const handleLogout = async () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
await authClient.signOut();
router.push("/login");
};
const handleLinkClick = () => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
};
const handleModalOpen = (modalType: string) => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
openModal(modalType);
};
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
return (
<Menu as="div" className="relative inline-block w-full text-left">
<div>
{isLoading ? (
<div className={twMerge(!isCollapsed && "flex")}>
<div className="flex">
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
<div
className={twMerge(
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
</div>
) : (
<Menu.Button
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
title={isCollapsed ? (displayName || email) : undefined}
>
<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">
{avatarUrl ? (
<Image
src={avatarUrl}
@@ -98,14 +63,7 @@ export default function UserMenu({
</svg>
</span>
)}
<span
className={twMerge(
"mx-2 truncate text-sm",
isCollapsed && "md:hidden",
)}
>
{displayName || email}
</span>
<span className="mx-2 truncate text-sm">{email}</span>
</Menu.Button>
)}
</div>
@@ -119,102 +77,52 @@ export default function UserMenu({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className={twMerge(
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "left-0 w-48" : "left-0 w-full",
)}
>
<Menu.Items className="absolute bottom-[40px] left-0 z-10 mt-2 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
<div className="p-1">
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
<span>{t`Theme`}</span>
<span>Theme</span>
</div>
<Menu.Item>
<button
onClick={() => setTheme("system")}
onClick={() => switchTheme("system")}
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"
>
<span
className={twMerge(
theme === "system" ? "visible" : "invisible",
className={classNames(
themePreference === "system" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
{t`System`}
System
</button>
</Menu.Item>
<Menu.Item>
<button
onClick={() => setTheme("dark")}
onClick={() => switchTheme("dark")}
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"
>
<span
className={twMerge(
theme === "dark" ? "visible" : "invisible",
className={classNames(
themePreference === "dark" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
{t`Dark`}
Dark
</button>
</Menu.Item>
<Menu.Item>
<button
onClick={() => setTheme("light")}
onClick={() => switchTheme("light")}
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"
>
<span
className={twMerge(
theme === "light" ? "visible" : "invisible",
className={classNames(
themePreference === "light" ? "visible" : "invisible",
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
)}
/>
{t`Light`}
</button>
</Menu.Item>
</div>
<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>
<Link
href="mailto:support@kan.bn"
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
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`Support`}
</Link>
</Menu.Item>
<Menu.Item>
<Link
href="https://docs.kan.bn"
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
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`Documentation`}
</Link>
</Menu.Item>
<Menu.Item>
<button
onClick={() => handleModalOpen("NEW_FEEDBACK")}
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`Feedback`}
Light
</button>
</Menu.Item>
</div>
@@ -224,29 +132,10 @@ export default function UserMenu({
onClick={handleLogout}
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`Logout`}
Logout
</button>
</Menu.Item>
</div>
{env.NEXT_PUBLIC_APP_VERSION && (
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<Link
href={
env.NEXT_PUBLIC_APP_VERSION.includes("+")
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
}
target="_blank"
rel="noreferrer"
onClick={handleLinkClick}
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
>
Version: {env.NEXT_PUBLIC_APP_VERSION}
</Link>
</Menu.Item>
</div>
)}
</div>
</Menu.Items>
</Transition>

View File

@@ -1,180 +1,92 @@
import { useRouter } from "next/navigation";
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { Menu, Transition } from "@headlessui/react";
import { Fragment } from "react";
import { HiCheck } from "react-icons/hi2";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
export default function WorkspaceMenu({
isCollapsed = false,
}: {
isCollapsed?: boolean;
}) {
export default function WorkspaceMenu() {
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const { data: hasPartnerSlot } =
api.workspace.hasAvailablePartnerSlot.useQuery();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: {
key: "k",
modifiers: ["META"],
},
action: () => setIsOpen(true),
description: t`Open command menu`,
group: "GENERAL",
});
return (
<>
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div>
{isLoading ? (
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
<div
className={twMerge(
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
isCollapsed && "md:hidden",
)}
/>
</div>
) : (
<div
className={twMerge(
"flex items-center justify-start gap-1",
isCollapsed && "md:flex-col-reverse md:items-center",
)}
>
<Menu.Button
className={twMerge(
"mb-1 flex h-[34px] min-w-0 flex-1 items-center justify-start rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed &&
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
)}
title={isCollapsed ? workspace.name : undefined}
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
<span className="text-xs font-bold leading-none text-white">
{workspace.name.charAt(0).toUpperCase()}
</span>
</span>
<span
className={twMerge(
"ml-2 min-w-0 flex-1 truncate text-left text-sm font-bold text-neutral-900 dark:text-dark-1000",
isCollapsed && "md:hidden",
)}
>
{workspace.name}
</span>
{workspace.plan === "pro" && (
<span
className={twMerge(
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
isCollapsed && "md:hidden",
)}
>
Pro
</span>
)}
</Menu.Button>
<Tooltip content={commandPaletteShortcutTooltipContent}>
<Button
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",
isCollapsed && "md:mb-2 md:h-9 md:w-9",
)}
onClick={() => setIsOpen(true)}
>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
</Tooltip>
</div>
)}
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className={twMerge(
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
isCollapsed ? "w-48" : "w-full",
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
<div>
{isLoading ? (
<div className="mb-1 flex p-1.5">
<div className="h-6 w-6 animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
</div>
) : (
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-bold leading-none text-white">
{workspace.name.charAt(0).toUpperCase()}
</span>
</span>
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
{workspace.name}
</span>
{workspace.plan === "pro" && (
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700">
Pro
</span>
)}
>
<div className="p-1">
{availableWorkspaces.map((availableWorkspace) => (
<div key={availableWorkspace.publicId} className="flex">
<Menu.Item>
<button
onClick={() => switchWorkspace(availableWorkspace)}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
<div className="flex min-w-0 flex-1 items-center">
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-medium leading-none text-white">
{availableWorkspace.name.charAt(0).toUpperCase()}
</span>
</Menu.Button>
)}
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute left-0 z-10 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
<div className="p-1">
{availableWorkspaces.map((availableWorkspace) => (
<div key={availableWorkspace.publicId} className="flex">
<Menu.Item>
<button
onClick={() => switchWorkspace(availableWorkspace)}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
<div>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-indigo-700">
<span className="text-xs font-medium leading-none text-white">
{availableWorkspace.name.charAt(0).toUpperCase()}
</span>
<span className="ml-2 truncate text-xs font-medium">
{availableWorkspace.name}
</span>
</div>
{workspace.publicId === availableWorkspace.publicId && (
<span>
<HiCheck className="h-4 w-4" aria-hidden="true" />
</span>
)}
</button>
</Menu.Item>
</div>
))}
</div>
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => {
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
openModal("NEW_WORKSPACE");
} else if (hasPartnerSlot) {
router.push(
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
}
}}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{t`Create workspace`}
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</>
</span>
<span className="ml-2 text-xs font-medium">
{availableWorkspace.name}
</span>
</div>
{workspace.name === availableWorkspace.name && (
<span>
<HiCheck className="h-4 w-4" aria-hidden="true" />
</span>
)}
</button>
</Menu.Item>
</div>
))}
</div>
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => openModal("NEW_WORKSPACE")}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
Create workspace
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,28 +7,14 @@ interface Props {
children: React.ReactNode;
modalSize?: "sm" | "md" | "lg";
positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
closeOnClickOutside?: boolean;
centered?: boolean;
}
const Modal: React.FC<Props> = ({
children,
modalSize = "sm",
positionFromTop = "md",
isVisible,
closeOnClickOutside,
centered = false,
}) => {
const {
isOpen,
closeModal,
closeOnClickOutside: modalCloseOnClickOutside,
} = useModal();
const shouldShow = isVisible ?? isOpen;
const shouldCloseOnClickOutside =
closeOnClickOutside ?? modalCloseOnClickOutside;
const { isOpen, closeModal } = useModal();
const modalSizeMap = {
sm: "max-w-[400px]",
@@ -43,12 +29,8 @@ const Modal: React.FC<Props> = ({
};
return (
<Transition.Root show={shouldShow} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={shouldCloseOnClickOutside ? closeModal : () => null}
>
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -61,8 +43,8 @@ const Modal: React.FC<Props> = ({
<div className="fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
</Transition.Child>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -73,7 +55,7 @@ const Modal: React.FC<Props> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
>
{children}
</Dialog.Panel>

View File

@@ -14,7 +14,6 @@ export const env = createEnv({
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
KAN_ADMIN_API_KEY: z.string().optional(),
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z
.string()
@@ -25,7 +24,7 @@ export const env = createEnv({
s.split(",").every((l) => z.string().url().safeParse(l).success),
)
.optional(),
POSTGRES_URL: z.string().url().optional().or(z.literal("")),
POSTGRES_URL: z.string().url(),
TRELLO_APP_API_KEY: z.string().optional(),
TRELLO_APP_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
@@ -52,12 +51,6 @@ export const env = createEnv({
VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(),
NOVU_API_KEY: z.string().optional(),
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
// Generic OIDC Provider
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_DISCOVERY_URL: z.string().optional(),
REDDIT_CLIENT_ID: z.string().optional(),
REDDIT_CLIENT_SECRET: z.string().optional(),
ROBLOX_CLIENT_ID: z.string().optional(),
@@ -76,9 +69,7 @@ export const env = createEnv({
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
REDIS_URL: z.string().url().optional().or(z.literal("")),
},
/**
@@ -88,22 +79,10 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: 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_AVATAR_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
.string()
.transform((s) => (s === "" ? undefined : s))
@@ -118,13 +97,6 @@ export const env = createEnv({
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY: z
.string()
.transform((s) => (s === "" ? undefined : s))
.refine(
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
)
.optional(),
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
@@ -133,23 +105,12 @@ export const env = createEnv({
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === "lint",

View File

@@ -1,59 +0,0 @@
import type { RefObject } from "react";
import { useEventListener } from "./useEventListener";
/** Supported event types. */
type EventType =
| "mousedown"
| "mouseup"
| "touchstart"
| "touchend"
| "focusin"
| "focusout";
/**
* Custom hook that handles clicks outside a specified element.
* @template T - The type of the element's reference.
* @param {RefObject<T> | RefObject<T>[]} ref - The React ref object(s) representing the element(s) to watch for outside clicks.
* @param {(event: MouseEvent | TouchEvent | FocusEvent) => void} handler - The callback function to be executed when a click outside the element occurs.
* @param {EventType} [eventType] - The mouse event type to listen for (optional, default is 'mousedown').
* @param {?AddEventListenerOptions} [eventListenerOptions] - The options object to be passed to the `addEventListener` method (optional).
* @returns {void}
* @example
* ```tsx
* const containerRef = useRef(null);
* useClickOutside([containerRef], () => {
* // Handle clicks outside the container.
* });
* ```
*/
export function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T> | RefObject<T>[],
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
eventType: EventType = "mousedown",
eventListenerOptions: AddEventListenerOptions = {},
): void {
useEventListener(
eventType,
(event) => {
const target = event.target as Node;
// Do nothing if the target is not connected element with document
if (!target.isConnected) {
return;
}
const isOutside = Array.isArray(ref)
? ref
.filter((r) => Boolean(r.current))
.every((r) => r.current && !r.current.contains(target))
: ref.current && !ref.current.contains(target);
if (isOutside) {
handler(event);
}
},
undefined,
eventListenerOptions,
);
}

View File

@@ -1,36 +0,0 @@
import { useState } from "react";
export function useClipboard({ timeout = 500 } = {}) {
const [error, setError] = useState<string | Error | null | undefined>(null);
const [copied, setCopied] = useState<boolean>(false);
const [copyTimeout, setCopyTimeout] = useState<number | undefined>(undefined);
const handleCopyResult = (hasError: boolean) => {
clearTimeout(copyTimeout);
setCopyTimeout(
setTimeout(() => setCopied(false), timeout) as unknown as number,
);
setCopied(hasError);
};
const copy = (value: string) => {
if ("clipboard" in navigator) {
navigator.clipboard
.writeText(value)
.then(() => handleCopyResult(true))
.catch((err) => setError(err));
} else {
setError(new Error("Error: navigator.clipboard is not supported"));
}
};
const reset = () => {
setError(null);
setCopied(false);
clearTimeout(copyTimeout);
};
return { copy, reset, error, copied };
}

View File

@@ -1,101 +0,0 @@
import { useEffect, useRef, useState } from "react";
interface UseDragToScrollOptions {
/**
* Whether drag-to-scroll is enabled
*/
enabled?: boolean;
/**
* The direction to scroll
*/
direction?: "horizontal" | "vertical" | "both";
}
/**
* Hook to enable drag-to-scroll functionality on a scrollable element
* @param options Configuration options
* @returns Ref to attach to the scrollable element and mouse event handlers
*/
export function useDragToScroll({
enabled = true,
direction = "horizontal",
}: UseDragToScrollOptions = {}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const startPosRef = useRef({ x: 0, y: 0 });
const scrollStartRef = useRef({ x: 0, y: 0 });
useEffect(() => {
if (!enabled || !isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!scrollRef.current) return;
const deltaX = e.clientX - startPosRef.current.x;
const deltaY = e.clientY - startPosRef.current.y;
if (direction === "horizontal" || direction === "both") {
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
}
if (direction === "vertical" || direction === "both") {
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [enabled, isDragging, direction]);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
const target = e.target as HTMLElement;
const container = scrollRef.current;
// Check if the click is on an interactive or draggable element
// We need to be careful not to interfere with react-beautiful-dnd dragging
const isInteractiveElement =
target.closest("a") ||
target.closest("button") ||
target.closest("input") ||
target.closest("textarea") ||
target.closest("[role='button']") ||
target.closest("[draggable='true']") ||
target.closest(".react-beautiful-dnd-drag-handle") ||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
target.closest("[data-rbd-draggable-id]");
// Don't start dragging if clicking on interactive elements
if (isInteractiveElement) return;
// Enable drag-to-scroll for any non-interactive element within the container
if (container.contains(target)) {
e.preventDefault();
setIsDragging(true);
startPosRef.current = { x: e.clientX, y: e.clientY };
scrollStartRef.current = {
x: container.scrollLeft,
y: container.scrollTop,
};
}
};
return {
ref: scrollRef,
onMouseDown: handleMouseDown,
isDragging,
};
}

View File

@@ -1,120 +0,0 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
// MediaQueryList Event based useEventListener interface
function useEventListener<K extends keyof MediaQueryListEventMap>(
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
element: RefObject<MediaQueryList>,
options?: boolean | AddEventListenerOptions,
): void;
// Window Event based useEventListener interface
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions,
): void;
// Element Event based useEventListener interface
function useEventListener<
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
T extends Element = K extends keyof HTMLElementEventMap
? HTMLDivElement
: SVGElement,
>(
eventName: K,
handler:
| ((event: HTMLElementEventMap[K]) => void)
| ((event: SVGElementEventMap[K]) => void),
element: RefObject<T>,
options?: boolean | AddEventListenerOptions,
): void;
// Document Event based useEventListener interface
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: RefObject<Document>,
options?: boolean | AddEventListenerOptions,
): void;
/**
* Custom hook that attaches event listeners to DOM elements, the window, or media query lists.
* @template KW - The type of event for window events.
* @template KH - The type of event for HTML or SVG element events.
* @template KM - The type of event for media query list events.
* @template T - The type of the DOM element (default is `HTMLElement`).
* @param {KW | KH | KM} eventName - The name of the event to listen for.
* @param {(event: WindowEventMap[KW] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | MediaQueryListEventMap[KM] | Event) => void} handler - The event handler function.
* @param {RefObject<T>} [element] - The DOM element or media query list to attach the event listener to (optional).
* @param {boolean | AddEventListenerOptions} [options] - An options object that specifies characteristics about the event listener (optional).
* @public
* @see [Documentation](https://usehooks-ts.com/react-hook/use-event-listener)
* @example
* ```tsx
* // Example 1: Attach a window event listener
* useEventListener('resize', handleResize);
* ```
* @example
* ```tsx
* // Example 2: Attach a document event listener with options
* const elementRef = useRef(document);
* useEventListener('click', handleClick, elementRef, { capture: true });
* ```
* @example
* ```tsx
* // Example 3: Attach an element event listener
* const buttonRef = useRef<HTMLButtonElement>(null);
* useEventListener('click', handleButtonClick, buttonRef);
* ```
*/
function useEventListener<
KW extends keyof WindowEventMap,
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
KM extends keyof MediaQueryListEventMap,
T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
>(
eventName: KW | KH | KM,
handler: (
event:
| WindowEventMap[KW]
| HTMLElementEventMap[KH]
| SVGElementEventMap[KH]
| MediaQueryListEventMap[KM]
| Event,
) => void,
element?: RefObject<T>,
options?: boolean | AddEventListenerOptions,
) {
// Create a ref that stores handler
const savedHandler = useRef(handler);
useIsomorphicLayoutEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
// Define the listening target
const targetElement: T | Window = element?.current ?? window;
if (!(targetElement && targetElement.addEventListener)) return;
// Create event listener that calls handler function stored in ref
const listener: typeof handler = (event) => {
savedHandler.current(event);
};
targetElement.addEventListener(eventName, listener, options);
// Remove event listener on cleanup
return () => {
targetElement.removeEventListener(eventName, listener, options);
};
}, [eventName, element, options]);
}
export { useEventListener };

View File

@@ -1,15 +0,0 @@
import { useEffect, useLayoutEffect } from "react";
/**
* Custom hook that uses either `useLayoutEffect` or `useEffect` based on the environment (client-side or server-side).
* @param {Function} effect - The effect function to be executed.
* @param {Array<any>} [dependencies] - An array of dependencies for the effect (optional).
* @example
* ```tsx
* useIsomorphicLayoutEffect(() => {
* // Code to be executed during the layout phase on the client side
* }, [dependency1, dependency2]);
* ```
*/
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;

View File

@@ -1,39 +0,0 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { useLingui } from "@lingui/react";
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
import { activateLocale } from "~/utils/i18n";
export function useLocalisation() {
const { i18n } = useLingui();
const { locale, setLocale, availableLocales } = useLinguiContext();
const dateLocaleMap: Partial<Record<Locale, DateFnsLocale>> = {
en: enGB,
fr,
de,
es,
it,
nl,
ru,
pl,
ptbr: ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
const handleSetLocale = async (newLocale: Locale) => {
await activateLocale(newLocale);
setLocale(newLocale);
};
return {
locale,
dateLocale: currentDateLocale,
setLocale: handleSetLocale,
availableLocales,
formatNumber: i18n.number,
};
}

View File

@@ -1,23 +0,0 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query]);
return matches;
}
export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
}

View File

@@ -1,48 +0,0 @@
import { useEffect } from "react";
import { useModal } from "~/providers/modal";
interface UseModalFormStateOptions<T> {
modalType: string;
initialValues: T;
resetOnClose?: boolean;
}
export function useModalFormState<T extends Record<string, any>>({
modalType,
initialValues,
resetOnClose = false,
}: UseModalFormStateOptions<T>) {
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
const isCurrentModal = modalContentType === modalType;
const savedState = getModalState(modalType) as T | undefined;
// get current form state (using the saved values if available, otherwise the initial values)
const formState = savedState || initialValues;
const saveFormState = (state: Partial<T>) => {
if (!isCurrentModal) return;
const currentState = getModalState(modalType) || initialValues;
const newState = { ...currentState, ...state };
setModalState(modalType, newState);
};
const clearFormState = () => {
clearModalState(modalType);
};
useEffect(() => {
if (resetOnClose && !isOpen && savedState) {
clearModalState(modalType);
}
}, [isOpen, resetOnClose, savedState, modalType, clearModalState]);
return {
formState,
saveFormState,
clearFormState,
isCurrentModal,
hasSavedState: !!savedState,
};
}

View File

@@ -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"),
};
}

View File

@@ -1,44 +0,0 @@
import type { RefObject } from "react";
import type { NextRouter } from "next/router";
import { useEffect, useRef } from "react";
const scrollPositions = new Map<string, number>();
export function useScrollRestore(
boardId: string | null | undefined,
scrollRef: RefObject<HTMLElement | null>,
router: NextRouter,
isReady: boolean,
) {
const restored = useRef(false);
useEffect(() => {
if (!boardId) return;
restored.current = false;
const saveScrollPosition = () => {
if (scrollRef.current) {
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
}
};
router.events.on("routeChangeStart", saveScrollPosition);
return () => router.events.off("routeChangeStart", saveScrollPosition);
}, [boardId, router.events, scrollRef]);
useEffect(() => {
if (restored.current || !isReady || !boardId) return;
restored.current = true;
const saved = scrollPositions.get(boardId);
if (saved === undefined) return;
// StrictModeDroppable delays rendering by one requestAnimationFrame
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
});
});
}, [isReady, scrollRef, boardId]);
}

View File

@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await require("pino");
await require("next-logger");
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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