Compare commits
1 Commits
refactor/w
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbfdec1bbf |
@@ -1,62 +1,18 @@
|
|||||||
# Environment
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Docker
|
docker-compose.override.yml
|
||||||
docker-compose*.yml
|
|
||||||
|
Dockerfile
|
||||||
|
./**/*/Dockerfile
|
||||||
|
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
# Dependencies (rebuilt in Docker)
|
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
./**/*/node_modules
|
||||||
|
|
||||||
# Build outputs (rebuilt in Docker)
|
|
||||||
**/.next
|
|
||||||
**/dist
|
|
||||||
**/out
|
|
||||||
**/.turbo
|
|
||||||
**/.cache
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.gitattributes
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# CI/CD
|
|
||||||
.github
|
|
||||||
.husky
|
|
||||||
.changeset
|
|
||||||
|
|
||||||
# Documentation (not needed in build)
|
|
||||||
*.md
|
|
||||||
!packages/db/migrations/**
|
|
||||||
LICENSE
|
|
||||||
CONTRIBUTING.md
|
|
||||||
AGENTS.md
|
|
||||||
CHANGELOG.md
|
|
||||||
|
|
||||||
# Test files
|
|
||||||
**/*.test.ts
|
|
||||||
**/*.test.tsx
|
|
||||||
**/*.spec.ts
|
|
||||||
**/*.spec.tsx
|
|
||||||
**/__tests__
|
|
||||||
**/coverage
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
pnpm-debug.log
|
pnpm-debug.log
|
||||||
**/pnpm-debug.log
|
./**/*/pnpm-debug.log
|
||||||
|
|
||||||
# Cloud compose (separate deployment)
|
README.md
|
||||||
cloud/
|
.next
|
||||||
|
.git
|
||||||
56
.env.example
@@ -1,63 +1,28 @@
|
|||||||
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
|
POSTGRES_URL=
|
||||||
|
|
||||||
# Required environment variables
|
EMAIL_FROM=
|
||||||
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
|
|
||||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
|
||||||
|
|
||||||
# Only required if deploying from compose
|
|
||||||
POSTGRES_PASSWORD=
|
|
||||||
|
|
||||||
# SMTP (optional)
|
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=465
|
SMTP_PORT=
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
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_BASE_URL=
|
||||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
NEXT_PUBLIC_STORAGE_URL=
|
||||||
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||||
|
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||||
|
|
||||||
# S3 storage (optional)
|
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
S3_ENDPOINT=
|
S3_ENDPOINT=
|
||||||
S3_ACCESS_KEY_ID=
|
S3_ACCESS_KEY_ID=
|
||||||
S3_SECRET_ACCESS_KEY=
|
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=
|
|
||||||
|
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||||
|
|
||||||
# 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_API_KEY=
|
||||||
TRELLO_APP_SECRET=
|
TRELLO_APP_SECRET=
|
||||||
|
|
||||||
# Redis (optional - for rate limiting)
|
|
||||||
# If not provided, rate limiting will use in-memory storage
|
|
||||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
|
||||||
|
|
||||||
# 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_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
@@ -95,3 +60,4 @@ TWITCH_CLIENT_SECRET=
|
|||||||
APPLE_CLIENT_ID=
|
APPLE_CLIENT_ID=
|
||||||
APPLE_CLIENT_SECRET=
|
APPLE_CLIENT_SECRET=
|
||||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||||
|
|
||||||
|
|||||||
1
.github/funding.yml
vendored
@@ -1 +0,0 @@
|
|||||||
github: [kanbn]
|
|
||||||
96
.github/workflows/docker-publish.yml
vendored
@@ -6,31 +6,23 @@ name: Docker
|
|||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "21 21 * * *"
|
||||||
push:
|
push:
|
||||||
# Only trigger on tags (releases), not main branch pushes
|
branches: ["main"]
|
||||||
# Main branch builds are handled by workflow_run after Translate completes
|
# Publish semver tags as releases.
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
workflow_run:
|
|
||||||
workflows: ["Translate"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
|
||||||
# Docker builds after Translate completes (success or failure)
|
|
||||||
# This ensures Docker always has the latest translations
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Use docker.io for Docker Hub if empty
|
# Use docker.io for Docker Hub if empty
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
# github.repository as <account>/<repo>
|
# github.repository as <account>/<repo>
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
concurrency:
|
|
||||||
group: docker-build-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -42,8 +34,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
# Install the cosign tool except on PR
|
||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
@@ -82,58 +72,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
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"
|
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
@@ -147,24 +86,6 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Build and push migrate Docker image
|
|
||||||
id: build-and-push-migrate
|
|
||||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: apps/web/Dockerfile
|
|
||||||
target: migrate
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: ${{ steps.meta-migrate.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta-migrate.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
APP_VERSION=${{ steps.version.outputs.version }}
|
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
@@ -182,10 +103,3 @@ jobs:
|
|||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|
||||||
- name: Sign the published migrate Docker image
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
env:
|
|
||||||
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
|
||||||
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
|
||||||
|
|||||||
56
.github/workflows/translate.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Translate
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
translate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --filter @kan/web...
|
|
||||||
|
|
||||||
- name: Extract translations
|
|
||||||
working-directory: apps/web
|
|
||||||
run: pnpm lingui:extract
|
|
||||||
|
|
||||||
- name: Lingo.dev
|
|
||||||
uses: lingodotdev/lingo.dev@main
|
|
||||||
with:
|
|
||||||
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
|
|
||||||
commit-message: "chore: update translations"
|
|
||||||
working-directory: apps/web
|
|
||||||
commit-author-name: "${{ github.actor }}"
|
|
||||||
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
|
|
||||||
parallel: true
|
|
||||||
|
|
||||||
- name: Compile translations
|
|
||||||
working-directory: apps/web
|
|
||||||
run: pnpm lingui:compile
|
|
||||||
|
|
||||||
- name: Commit compiled translations
|
|
||||||
run: |
|
|
||||||
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
|
|
||||||
git config --local user.name "${{ github.actor }}"
|
|
||||||
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
|
|
||||||
git add apps/web/src/locales/*/messages.ts
|
|
||||||
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
|
|
||||||
fi
|
|
||||||
7
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
@@ -50,9 +49,3 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# runtime env
|
# runtime env
|
||||||
__ENV.js
|
__ENV.js
|
||||||
i18n.cache
|
|
||||||
|
|
||||||
# pgdata
|
|
||||||
/apps/web/pgdata
|
|
||||||
|
|
||||||
.claude
|
|
||||||
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
|||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm dev",
|
"command": "pnpm dev",
|
||||||
"cwd": "${workspaceFolder}/apps/web",
|
"cwd": "${workspaceFolder}/apps/nextjs",
|
||||||
"skipFiles": ["<node_internals>/**"],
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"sourceMapPathOverrides": {
|
"sourceMapPathOverrides": {
|
||||||
|
|||||||
333
AGENTS.md
@@ -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
|
|
||||||
121
CHANGELOG.md
@@ -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
|
|
||||||
181
README.md
@@ -27,14 +27,14 @@
|
|||||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||||
- 💬 **Comments**: Discuss and collaborate with your team
|
- 💬 **Comments**: Discuss and collaborate with your team
|
||||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
- 📝 **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
|
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||||
|
|
||||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||||
|
|
||||||
## Screenshot 👁️
|
## Screenshot 👁️
|
||||||
|
|
||||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/8490104a-cd5d-49de-afc2-152fd8a93119" />
|
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
|
||||||
|
|
||||||
## Made With 🛠️
|
## Made With 🛠️
|
||||||
|
|
||||||
@@ -47,99 +47,34 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
|||||||
|
|
||||||
## Self Hosting 🐳
|
## Self Hosting 🐳
|
||||||
|
|
||||||
### One-click Deployments
|
### PostgreSQL Database Setup
|
||||||
|
|
||||||
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.
|
Kan requires a PostgreSQL database. You can run one using the official PostgreSQL Docker image:
|
||||||
|
|
||||||
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
|
|
||||||
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
|
||||||
|
|
||||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
|
||||||
|
|
||||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
depends_on:
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:15
|
|
||||||
container_name: kan-db
|
|
||||||
environment:
|
|
||||||
- POSTGRES_DB=kan_db
|
|
||||||
- POSTGRES_USER=kan
|
|
||||||
- POSTGRES_PASSWORD=${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
|
|
||||||
|
|
||||||
networks:
|
|
||||||
kan-network:
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
kan_postgres_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start the containers in detached mode:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
# Run PostgreSQL in a container using the official postgres image
|
||||||
|
docker run -d \
|
||||||
|
--name kan-db \
|
||||||
|
-e POSTGRES_DB=kan \
|
||||||
|
-e POSTGRES_USER=kan_user \
|
||||||
|
-e POSTGRES_PASSWORD=your_secure_password \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-v kan_postgres_data:/var/lib/postgresql/data \
|
||||||
|
postgres:15
|
||||||
|
|
||||||
|
# Your POSTGRES_URL should be:
|
||||||
|
# postgres://kan_user:your_secure_password@your_host:5432/kan
|
||||||
```
|
```
|
||||||
|
|
||||||
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`).
|
### Kan Application Deployment
|
||||||
|
|
||||||
**Managing containers:**
|
Deploy Kan with Docker using our pre-built image:
|
||||||
|
|
||||||
- To stop the containers: `docker compose down`
|
```bash
|
||||||
- To view logs: `docker compose logs -f`
|
docker pull ghcr.io/kanbn/kan:latest && docker run -it -p 3000:3000 --env-file .env ghcr.io/kanbn/kan:latest
|
||||||
- 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.
|
Make sure to create a `.env` file with the required environment variables (see the Environment Variables section below).
|
||||||
|
|
||||||
## Local Development 🧑💻
|
## Local Development 🧑💻
|
||||||
|
|
||||||
@@ -170,50 +105,34 @@ pnpm dev
|
|||||||
|
|
||||||
## Environment Variables 🔐
|
## Environment Variables 🔐
|
||||||
|
|
||||||
| Variable | Description | Required | Example |
|
| Variable | Description | Required | Example |
|
||||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
|
||||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||||
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
| `SMTP_USER` | SMTP username/email | For Email | `resend` |
|
||||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
|
||||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||||
| `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` |
|
|
||||||
|
|
||||||
See `.env.example` for a complete list of supported environment variables.
|
See `.env.example` for a complete list of supported environment variables.
|
||||||
|
|
||||||
|
|||||||
@@ -30,5 +30,3 @@ Install our Github App to auto propagate changes from your repo to your deployme
|
|||||||
|
|
||||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
- 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`
|
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||||
|
|
||||||
#BUMP
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ https://kan.bn/api/v1
|
|||||||
|
|
||||||
## Authentication
|
## 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
|
## Response codes
|
||||||
|
|||||||
@@ -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 you’ll 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 README’s 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.), you’ll 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)
|
|
||||||
@@ -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 you’ll 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 MinIO’s 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 you’ll 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 you’re 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 don’t 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><img></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 <kan-container> 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)
|
|
||||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 433 KiB |
@@ -44,18 +44,6 @@
|
|||||||
"group": "Get Started",
|
"group": "Get Started",
|
||||||
"pages": ["introduction"]
|
"pages": ["introduction"]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"group": "Guides",
|
|
||||||
"pages": [
|
|
||||||
{
|
|
||||||
"group": "Self-Hosting",
|
|
||||||
"pages": [
|
|
||||||
"guides/self-hosting/introduction",
|
|
||||||
"guides/self-hosting/s3"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"group": "Import",
|
"group": "Import",
|
||||||
"pages": ["imports/trello"]
|
"pages": ["imports/trello"]
|
||||||
|
|||||||
@@ -1,121 +1,80 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
|
||||||
|
|
||||||
ARG NODE_VERSION=20
|
ARG NODE_VERSION=20
|
||||||
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
ARG APP_DIRNAME=web
|
||||||
|
ARG PROJECT=@kan/web
|
||||||
|
|
||||||
# ============================================
|
# 1. Alpine image
|
||||||
# Stage 1: Alpine base with pnpm and turbo
|
|
||||||
# ============================================
|
|
||||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||||
RUN apk update && \
|
RUN apk update
|
||||||
apk add --no-cache libc6-compat && \
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
|
||||||
corepack enable && \
|
|
||||||
npm install turbo@2.3.1 --global && \
|
|
||||||
pnpm config set store-dir ~/.pnpm-store
|
|
||||||
|
|
||||||
# ============================================
|
# Setup pnpm and turbo on the alpine base
|
||||||
# Stage 2: Prune the monorepo
|
FROM alpine AS base
|
||||||
# ============================================
|
RUN corepack enable
|
||||||
FROM alpine AS pruner
|
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||||
|
# RUN npm install turbo@2.1.3 --global
|
||||||
|
RUN npm install turbo@2.3.1 --global
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN pnpm config set store-dir ~/.pnpm-store
|
||||||
|
|
||||||
|
# 2. Prune projects
|
||||||
|
FROM base AS pruner
|
||||||
|
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
||||||
|
# RUN export VERSION=$(npm run version)
|
||||||
|
|
||||||
|
ARG PROJECT
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# It might be the path to <ROOT> turborepo
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate version from git (fallback if APP_VERSION not provided)
|
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
|
||||||
sed 's/^v//' || \
|
|
||||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
|
||||||
echo "unknown") && \
|
|
||||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
|
||||||
|
|
||||||
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
# 3. Build the project
|
||||||
|
FROM base AS builder
|
||||||
|
ARG PROJECT
|
||||||
|
|
||||||
|
# Environment to skip .env validation on build
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Stage 3: Install dependencies
|
|
||||||
# ============================================
|
|
||||||
FROM alpine AS deps
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy lockfile and package.json's of isolated subworkspace
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
|
|
||||||
ENV CI=true
|
# First install the dependencies (as they change less often)
|
||||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# ============================================
|
# Copy source code of isolated subworkspace
|
||||||
# Stage 4: Build the web application
|
|
||||||
# ============================================
|
|
||||||
FROM alpine AS builder
|
|
||||||
ARG APP_VERSION
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=deps /app/ ./
|
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
|
||||||
|
|
||||||
# Force standalone output for production Docker image
|
RUN pnpm build --filter=${PROJECT}
|
||||||
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
|
||||||
ENV CI=true
|
|
||||||
|
|
||||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
# 4. Final image - runner stage to run the application
|
||||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
|
FROM base AS runner
|
||||||
|
ARG APP_DIRNAME
|
||||||
|
|
||||||
# ============================================
|
# Don't run production as root
|
||||||
# Stage 5: Migration image (run-once container)
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
# ============================================
|
RUN adduser --system --uid 1001 nextjs
|
||||||
FROM node:${NODE_VERSION}-alpine AS migrate
|
USER nextjs
|
||||||
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
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
|
|
||||||
# Copy the standalone Next.js server
|
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
WORKDIR /app/apps/${APP_DIRNAME}
|
||||||
# Copy static assets and public files
|
|
||||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
|
||||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
|
||||||
|
|
||||||
# Copy bootstrap script for runtime env var injection
|
ARG PORT=3000
|
||||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
ENV PORT=${PORT}
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
# Run migration on start for now (until we have a better way to handle this)
|
||||||
|
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["bootstrap.cjs"]
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bootstrap script for the distroless production image.
|
|
||||||
*
|
|
||||||
* Distroless images have no shell, so this Node.js script handles two tasks
|
|
||||||
* that would normally be done in an entrypoint.sh:
|
|
||||||
*
|
|
||||||
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
|
||||||
* environment variables. The file was originally created at build time by
|
|
||||||
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
|
||||||
* env vars are provided at *run* time via docker-compose / docker run.
|
|
||||||
*
|
|
||||||
* 2. Start the Next.js standalone server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const publicDir = path.join(__dirname, "apps", "web", "public");
|
|
||||||
|
|
||||||
if (!existsSync(publicDir)) {
|
|
||||||
mkdirSync(publicDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const envVars = {};
|
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
|
||||||
if (key.startsWith("NEXT_PUBLIC_")) {
|
|
||||||
envVars[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
path.join(publicDir, "__ENV.js"),
|
|
||||||
`self.__ENV = ${JSON.stringify(envVars)};`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 2. Start the Next.js standalone server
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
require("./apps/web/server.js");
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Run migrations from the Turborepo root context
|
|
||||||
echo "Running database migrations..."
|
|
||||||
# Navigate to the Turborepo root to run the db:migrate command
|
|
||||||
pnpm --prefix /app db:migrate
|
|
||||||
|
|
||||||
# Check if migration was successful
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "\nDatabase migration failed! Exiting."
|
|
||||||
exit 1
|
|
||||||
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
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
2989
apps/web/i18n.lock
@@ -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" }),
|
|
||||||
});
|
|
||||||
@@ -10,25 +10,8 @@ configureRuntimeEnv();
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output:
|
|
||||||
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
|
|
||||||
? "standalone"
|
|
||||||
: undefined,
|
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
/** Exclude build tools and dev-only packages from the standalone output */
|
|
||||||
outputFileTracingExcludes: {
|
|
||||||
"**/*": [
|
|
||||||
"@esbuild/**",
|
|
||||||
"esbuild/**",
|
|
||||||
"typescript/**",
|
|
||||||
"webpack/**",
|
|
||||||
"uglify-js/**",
|
|
||||||
"terser/**",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@kan/api",
|
"@kan/api",
|
||||||
@@ -39,60 +22,28 @@ const config = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
/** We already do linting and typechecking as separate tasks in CI */
|
/** We already do linting and typechecking as separate tasks in CI */
|
||||||
|
eslint: { ignoreDuringBuilds: true },
|
||||||
typescript: { ignoreBuildErrors: true },
|
typescript: { ignoreBuildErrors: true },
|
||||||
|
|
||||||
// temporarily ignore eslint errors during build until we fix all the errors sigh
|
|
||||||
eslint: { ignoreDuringBuilds: true },
|
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: (() => {
|
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 [
|
|
||||||
{
|
{
|
||||||
source: "/settings",
|
protocol: "https",
|
||||||
destination: "/settings/account",
|
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;
|
export default config;
|
||||||
|
|||||||
@@ -4,43 +4,28 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm with-env next build --turbo",
|
"build": "pnpm next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "pnpm with-env next dev --turbo",
|
"dev": "pnpm with-env next dev | pino-pretty",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"start": "pnpm with-env next start",
|
"start": "pnpm with-env next start",
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "dotenv -e ../../.env --",
|
"with-env": "dotenv -e ../../.env --"
|
||||||
"lingui:extract": "lingui extract",
|
|
||||||
"lingui:compile": "lingui compile --typescript",
|
|
||||||
"lingui:extract:watch": "lingui extract --watch"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.802.0",
|
"@aws-sdk/client-s3": "^3.802.0",
|
||||||
"@aws-sdk/lib-storage": "^3.802.0",
|
|
||||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@kan/api": "workspace:*",
|
"@kan/api": "workspace:*",
|
||||||
"@kan/auth": "workspace:*",
|
"@kan/auth": "workspace:*",
|
||||||
"@kan/db": "workspace:^",
|
"@kan/db": "workspace:^",
|
||||||
"@kan/logger": "workspace:^",
|
|
||||||
"@kan/shared": "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",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tiptap/extension-link": "^2.22.2",
|
|
||||||
"@tiptap/extension-mention": "^3.0.9",
|
|
||||||
"@tiptap/extension-placeholder": "^2.14.0",
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
"@tiptap/extension-typography": "^3.18.0",
|
|
||||||
"@tiptap/pm": "^2.14.0",
|
"@tiptap/pm": "^2.14.0",
|
||||||
"@tiptap/react": "^2.14.0",
|
"@tiptap/react": "^2.14.0",
|
||||||
"@tiptap/starter-kit": "^2.14.0",
|
"@tiptap/starter-kit": "^2.14.0",
|
||||||
@@ -49,28 +34,25 @@
|
|||||||
"@trpc/next": "^11.0.0-rc.660",
|
"@trpc/next": "^11.0.0-rc.660",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
|
"aws-sdk": "^2.1692.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.26.2",
|
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jose": "^6.1.2",
|
"next": "^14.2.15",
|
||||||
"next": "15.5.9",
|
"next-logger": "^5.0.1",
|
||||||
"next-runtime-env": "^1.7.2",
|
"next-runtime-env": "^1.7.2",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"nextjs-cors": "^2.2.0",
|
"nextjs-cors": "^2.2.0",
|
||||||
"posthog-js": "^1.254.0",
|
"pino": "^9.6.0",
|
||||||
"react": "catalog:react18",
|
"react": "catalog:react18",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-contenteditable": "^3.3.7",
|
"react-contenteditable": "^3.3.7",
|
||||||
"react-dom": "catalog:react18",
|
"react-dom": "catalog:react18",
|
||||||
"react-hook-form": "^7.51.1",
|
"react-hook-form": "^7.51.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-image-crop": "^11.0.10",
|
|
||||||
"react-lottie-player": "^1.5.5",
|
"react-lottie-player": "^1.5.5",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8",
|
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -79,11 +61,6 @@
|
|||||||
"@kan/stripe": "workspace:*",
|
"@kan/stripe": "workspace:*",
|
||||||
"@kan/tailwind-config": "workspace:*",
|
"@kan/tailwind-config": "workspace:*",
|
||||||
"@kan/tsconfig": "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/node": "^20.17.7",
|
||||||
"@types/react": "catalog:react18",
|
"@types/react": "catalog:react18",
|
||||||
"@types/react-beautiful-dnd": "^13.1.7",
|
"@types/react-beautiful-dnd": "^13.1.7",
|
||||||
@@ -91,10 +68,10 @@
|
|||||||
"dotenv-cli": "^7.4.4",
|
"dotenv-cli": "^7.4.4",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^1.21.6",
|
"jiti": "^1.21.6",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:"
|
||||||
"vitest": "^3.0.0"
|
|
||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config"
|
"prettier": "@kan/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -1,11 +1,8 @@
|
|||||||
import type { SocialProvider } from "better-auth/social-providers";
|
import type { SocialProvider } from "better-auth/social-providers";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
FaApple,
|
FaApple,
|
||||||
@@ -17,7 +14,6 @@ import {
|
|||||||
FaGoogle,
|
FaGoogle,
|
||||||
FaLinkedin,
|
FaLinkedin,
|
||||||
FaMicrosoft,
|
FaMicrosoft,
|
||||||
FaOpenid,
|
|
||||||
FaReddit,
|
FaReddit,
|
||||||
FaSpotify,
|
FaSpotify,
|
||||||
FaTiktok,
|
FaTiktok,
|
||||||
@@ -35,8 +31,6 @@ import Button from "~/components/Button";
|
|||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
|
|
||||||
type AuthProvider = SocialProvider | "oidc";
|
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
name?: string;
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -145,39 +139,16 @@ const availableSocialProviders = {
|
|||||||
name: "Zoom",
|
name: "Zoom",
|
||||||
icon: SiZoom,
|
icon: SiZoom,
|
||||||
},
|
},
|
||||||
oidc: {
|
|
||||||
id: "oidc",
|
|
||||||
name: "OIDC",
|
|
||||||
icon: FaOpenid,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
|
||||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||||
useState<null | AuthProvider>(null);
|
useState<null | SocialProvider>(null);
|
||||||
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
|
const isCredentialsEnabled =
|
||||||
const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false);
|
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const oidcProviderName = "OIDC";
|
|
||||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const redirect = useSearchParams().get("next");
|
|
||||||
const callbackURL = 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 {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -195,7 +166,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
|
|
||||||
const handleLoginWithEmail = async (
|
const handleLoginWithEmail = async (
|
||||||
email: string,
|
email: string,
|
||||||
password?: string | null,
|
password?: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
) => {
|
) => {
|
||||||
setIsLoginWithEmailPending(true);
|
setIsLoginWithEmailPending(true);
|
||||||
@@ -207,13 +178,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackURL,
|
callbackURL: "/boards",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Success`,
|
header: "Success",
|
||||||
message: t`You have been signed up successfully.`,
|
message: "You have been signed up successfully.",
|
||||||
icon: "success",
|
icon: "success",
|
||||||
}),
|
}),
|
||||||
onError: ({ error }) => setLoginError(error.message),
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
@@ -224,13 +195,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
{
|
{
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackURL,
|
callbackURL: "/boards",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () =>
|
onSuccess: () =>
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Success`,
|
header: "Success",
|
||||||
message: t`You have been logged in successfully.`,
|
message: "You have been logged in successfully.",
|
||||||
icon: "success",
|
icon: "success",
|
||||||
}),
|
}),
|
||||||
onError: ({ error }) => setLoginError(error.message),
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
@@ -238,103 +209,44 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only allow magic link if email sending is enabled and not in sign up mode
|
await authClient.signIn.magicLink(
|
||||||
if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) {
|
{
|
||||||
await authClient.signIn.magicLink(
|
email,
|
||||||
{
|
callbackURL: "/boards",
|
||||||
email,
|
},
|
||||||
callbackURL,
|
{
|
||||||
},
|
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||||
{
|
onError: ({ error }) => setLoginError(error.message),
|
||||||
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.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoginWithEmailPending(false);
|
setIsLoginWithEmailPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginWithProvider = async (provider: AuthProvider) => {
|
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||||
setIsLoginWithProviderPending(provider);
|
setIsLoginWithProviderPending(provider);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
|
const { error } = await authClient.signIn.social({
|
||||||
let error;
|
provider,
|
||||||
if (provider === "oidc") {
|
callbackURL: "/boards",
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoginWithProviderPending(null);
|
setIsLoginWithProviderPending(null);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
setLoginError(
|
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) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
// Treat empty password string as undefined to trigger magic link path
|
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||||
const sanitizedPassword = values.password?.trim()
|
|
||||||
? values.password
|
|
||||||
: undefined;
|
|
||||||
await handleLoginWithEmail(values.email, sanitizedPassword, values.name);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const password = watch("password");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{socialProviders?.length !== 0 && (
|
{socialProviders?.length !== 0 && (
|
||||||
@@ -345,104 +257,85 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||||
onClick={() => handleLoginWithProvider(key as AuthProvider)}
|
|
||||||
isLoading={isLoginWithProviderPending === key}
|
isLoading={isLoginWithProviderPending === key}
|
||||||
iconLeft={<provider.icon />}
|
iconLeft={<provider.icon />}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Trans>
|
Continue with {provider.name}
|
||||||
Continue with{" "}
|
|
||||||
{key === "oidc" ? oidcProviderName : provider.name}
|
|
||||||
</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!(isCredentialsEnabled || isMagicLinkAvailable) &&
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
socialProviders?.length === 0 && (
|
{socialProviders?.length !== 0 && (
|
||||||
<div className="flex w-full items-center gap-4">
|
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||||
{t`No authentication methods are currently available`}
|
or
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
<div className="space-y-2">
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
{isSignUp && isCredentialsEnabled && (
|
||||||
{socialProviders?.length !== 0 && (
|
|
||||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
|
||||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
|
||||||
{t`or`}
|
|
||||||
</span>
|
|
||||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{isSignUp && isCredentialsEnabled && (
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
{...register("name", { required: true })}
|
|
||||||
placeholder={t`Enter your name`}
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="mt-2 text-xs text-red-400">
|
|
||||||
{t`Please enter a valid name`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
{...register("email", { required: true })}
|
{...register("name", { required: true })}
|
||||||
placeholder={t`Enter your email address`}
|
placeholder="Enter your name"
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.name && (
|
||||||
<p className="mt-2 text-xs text-red-400">
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{t`Please enter a valid email address`}
|
Please enter a valid name
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{isCredentialsEnabled && (
|
<div>
|
||||||
<div>
|
<Input
|
||||||
<Input
|
{...register("email", { required: true })}
|
||||||
type="password"
|
placeholder="Enter your email address"
|
||||||
{...register("password", { required: true })}
|
/>
|
||||||
placeholder={t`Enter your password`}
|
{errors.email && (
|
||||||
/>
|
<p className="mt-2 text-xs text-red-400">
|
||||||
{errors.password && (
|
Please enter a valid email address
|
||||||
<p className="mt-2 text-xs text-red-400">
|
</p>
|
||||||
{errors.password.message ??
|
|
||||||
t`Please enter a valid password`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loginError && (
|
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
{isCredentialsEnabled && (
|
||||||
<Button
|
<div>
|
||||||
isLoading={isLoginWithEmailPending}
|
<Input
|
||||||
fullWidth
|
type="password"
|
||||||
size="lg"
|
{...register("password", { required: true })}
|
||||||
variant="secondary"
|
placeholder="Enter your password"
|
||||||
>
|
/>
|
||||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
{errors.password && (
|
||||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
<p className="mt-2 text-xs text-red-400">
|
||||||
</Button>
|
Please enter a valid password
|
||||||
</div>
|
</p>
|
||||||
</form>
|
)}
|
||||||
)}
|
</div>
|
||||||
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
|
)}
|
||||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const initials = name?.trim()
|
const initials = name
|
||||||
? getInitialsFromName(name)
|
? getInitialsFromName(name)
|
||||||
: inferInitialsFromEmail(email);
|
: inferInitialsFromEmail(email);
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import LoadingSpinner from "./LoadingSpinner";
|
|||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
size?: "xs" | "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
iconLeft?: React.ReactNode;
|
iconLeft?: React.ReactNode;
|
||||||
iconRight?: React.ReactNode;
|
iconRight?: React.ReactNode;
|
||||||
href?: string;
|
href?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
openInNewTab?: boolean;
|
openInNewTab?: boolean;
|
||||||
iconOnly?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = ({
|
const Button = ({
|
||||||
@@ -25,24 +24,13 @@ const Button = ({
|
|||||||
href,
|
href,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
openInNewTab,
|
openInNewTab,
|
||||||
iconOnly,
|
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) => {
|
}: ButtonProps) => {
|
||||||
const classes = twMerge(
|
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",
|
"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 === "sm" && "text-xs",
|
||||||
size === "lg" && "py-[0.65rem]",
|
size === "lg" && "py-[0.65rem]",
|
||||||
fullWidth && "w-full",
|
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" &&
|
variant === "primary" &&
|
||||||
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
||||||
variant === "secondary" &&
|
variant === "secondary" &&
|
||||||
@@ -58,60 +46,19 @@ const Button = ({
|
|||||||
<span className="relative flex items-center justify-center">
|
<span className="relative flex items-center justify-center">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<span className="absolute">
|
<span className="absolute">
|
||||||
<LoadingSpinner size={size === "xs" ? "sm" : size} />
|
<LoadingSpinner size={size} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{iconOnly ? (
|
<div
|
||||||
<div
|
className={twMerge(
|
||||||
className={twMerge(
|
"flex items-center",
|
||||||
"flex items-center",
|
isLoading ? "invisible" : "visible",
|
||||||
isLoading ? "invisible" : "visible",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{iconLeft && <span className="mr-2">{iconLeft}</span>}
|
||||||
{iconLeft ?? iconRight}
|
{children}
|
||||||
</div>
|
{iconRight && <span className="ml-1">{iconRight}</span>}
|
||||||
) : (
|
</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>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ interface CheckboxDropdownProps {
|
|||||||
handleEdit?: (key: string) => void;
|
handleEdit?: (key: string) => void;
|
||||||
handleCreate?: () => void;
|
handleCreate?: () => void;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckboxDropdown({
|
export default function CheckboxDropdown({
|
||||||
@@ -45,7 +44,6 @@ export default function CheckboxDropdown({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
asChild = true,
|
asChild = true,
|
||||||
disabled = false,
|
|
||||||
}: CheckboxDropdownProps) {
|
}: CheckboxDropdownProps) {
|
||||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -57,67 +55,53 @@ export default function CheckboxDropdown({
|
|||||||
|
|
||||||
const renderMenuItems = (items: Item[], groupKey: string | null) => (
|
const renderMenuItems = (items: Item[], groupKey: string | null) => (
|
||||||
<>
|
<>
|
||||||
{items.length > 0 ? (
|
{items.map((item) => (
|
||||||
items.map((item) => (
|
<Menu.Item key={item.key}>
|
||||||
<Menu.Item key={item.key}>
|
<div
|
||||||
<div
|
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
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
|
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
||||||
id={item.key}
|
</button>
|
||||||
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
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</Menu.Item>
|
||||||
)}
|
))}
|
||||||
{handleCreate && (
|
{handleCreate && (
|
||||||
<button
|
<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"
|
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) => {
|
onClick={() => handleCreate()}
|
||||||
e.preventDefault();
|
|
||||||
handleCreate();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<HiMiniPlus size={20} className="pr-1.5" />
|
<HiMiniPlus size={20} className="pr-1.5" />
|
||||||
{createNewItemLabel}
|
{createNewItemLabel}
|
||||||
@@ -134,8 +118,7 @@ export default function CheckboxDropdown({
|
|||||||
<>
|
<>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
as={asChild ? "div" : undefined}
|
as={asChild ? "div" : undefined}
|
||||||
disabled={disabled}
|
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||||
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,192 +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;
|
|
||||||
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="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
|
||||||
{result.title}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +1,11 @@
|
|||||||
import { useTheme } from "next-themes";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
TbLayoutSidebarLeftCollapse,
|
|
||||||
TbLayoutSidebarLeftExpand,
|
|
||||||
TbLayoutSidebarRightCollapse,
|
|
||||||
TbLayoutSidebarRightExpand,
|
|
||||||
} from "react-icons/tb";
|
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
|
||||||
|
|
||||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import FeedbackButton from "./FeedbackButton";
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
|
||||||
interface DashboardProps {
|
export default function Dashboard(props: { children: React.ReactNode }) {
|
||||||
children: React.ReactNode;
|
const { data, isLoading } = api.user.getUser.useQuery();
|
||||||
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 } = useModal();
|
|
||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -117,90 +13,26 @@ export default function Dashboard({
|
|||||||
html {
|
html {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 320px;
|
|
||||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
|
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</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">
|
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
||||||
{/* Mobile Header */}
|
<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="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">
|
<div className="my-auto flex w-full items-center justify-between">
|
||||||
<button
|
<Link href="/">
|
||||||
ref={sideNavButtonRef}
|
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||||
onClick={toggleSideNav}
|
kan.bn
|
||||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
</h1>
|
||||||
>
|
</Link>
|
||||||
{isSideNavOpen ? (
|
<FeedbackButton />
|
||||||
<TbLayoutSidebarLeftCollapse
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
|
<div className="flex h-full w-full">
|
||||||
<div
|
<SideNavigation
|
||||||
ref={sideNavRef}
|
user={{ email: data?.email, image: data?.image }}
|
||||||
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"} `}
|
isLoading={isLoading}
|
||||||
>
|
/>
|
||||||
<SideNavigation
|
<div className="w-full overflow-hidden">{props.children}</div>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -12,23 +10,21 @@ export function DeleteLabelConfirmation({
|
|||||||
labelPublicId: string;
|
labelPublicId: string;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { closeModal, closeModals } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
|
|
||||||
const deleteLabelMutation = api.label.delete.useMutation({
|
const deleteLabelMutation = api.label.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => refetch(),
|
||||||
refetch();
|
|
||||||
closeModals(2);
|
|
||||||
},
|
|
||||||
onError: () =>
|
onError: () =>
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Error deleting label`,
|
header: "Error deleting label",
|
||||||
message: t`Please try again later, or contact customer support.`,
|
message: "Please try again later, or contact customer support.",
|
||||||
icon: "error",
|
icon: "error",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDeleteLabel = () => {
|
const handleDeleteLabel = () => {
|
||||||
|
closeModal();
|
||||||
deleteLabelMutation.mutate({
|
deleteLabelMutation.mutate({
|
||||||
labelPublicId,
|
labelPublicId,
|
||||||
});
|
});
|
||||||
@@ -38,17 +34,17 @@ export function DeleteLabelConfirmation({
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex w-full flex-col justify-between pb-4">
|
<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">
|
<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>
|
</h2>
|
||||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||||
<Button variant="secondary" onClick={() => closeModal()}>
|
<Button variant="secondary" onClick={() => closeModal()}>
|
||||||
{t`Cancel`}
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDeleteLabel}>{t`Delete`}</Button>
|
<Button onClick={handleDeleteLabel}>Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
|||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -15,7 +15,7 @@ export default function Dropdown({
|
|||||||
<div>
|
<div>
|
||||||
<Menu.Button
|
<Menu.Button
|
||||||
disabled={disabled}
|
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}
|
{children}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
@@ -30,14 +30,13 @@ export default function Dropdown({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
<Menu.Items className="absolute right-0 z-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">
|
<div className="flex flex-col">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
<Menu.Item key={item.label}>
|
||||||
<button
|
<button
|
||||||
onClick={item.action}
|
onClick={item.action}
|
||||||
disabled={item.disabled ?? !item.action}
|
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Range as TiptapRange } from "@tiptap/core";
|
|
||||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||||
import type {
|
import type {
|
||||||
SuggestionKeyDownProps,
|
SuggestionKeyDownProps,
|
||||||
@@ -6,9 +5,6 @@ import type {
|
|||||||
} from "@tiptap/suggestion";
|
} from "@tiptap/suggestion";
|
||||||
import type { Instance as TippyInstance } from "tippy.js";
|
import type { Instance as TippyInstance } from "tippy.js";
|
||||||
import { Button } from "@headlessui/react";
|
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 Placeholder from "@tiptap/extension-placeholder";
|
||||||
import {
|
import {
|
||||||
BubbleMenu,
|
BubbleMenu,
|
||||||
@@ -17,16 +13,9 @@ import {
|
|||||||
ReactRenderer,
|
ReactRenderer,
|
||||||
useEditor,
|
useEditor,
|
||||||
} from "@tiptap/react";
|
} from "@tiptap/react";
|
||||||
import Typography from "@tiptap/extension-typography";
|
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import {
|
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||||
forwardRef,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
HiH1,
|
HiH1,
|
||||||
HiH2,
|
HiH2,
|
||||||
@@ -42,11 +31,6 @@ import {
|
|||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import tippy from "tippy.js";
|
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" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -59,7 +43,7 @@ declare module "@tiptap/core" {
|
|||||||
export interface SlashCommandItem {
|
export interface SlashCommandItem {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,16 +66,6 @@ export interface RenderSuggestionsProps {
|
|||||||
command: (item: SlashCommandItem) => void;
|
command: (item: SlashCommandItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceMember {
|
|
||||||
publicId: string;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
image: string | null;
|
|
||||||
} | null;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandsList = forwardRef<
|
const CommandsList = forwardRef<
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
{ 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>({
|
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
||||||
name: "slash-commands",
|
name: "slash-commands",
|
||||||
addOptions() {
|
addOptions() {
|
||||||
@@ -387,170 +236,78 @@ export interface SlashNodeAttrs {
|
|||||||
label?: string | null;
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
const CommandItems: SlashCommandItem[] = [
|
||||||
const headingCommands: SlashCommandItem[] = disableHeadings
|
{
|
||||||
? []
|
title: "Heading 1",
|
||||||
: [
|
icon: <HiH1 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 1",
|
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||||
icon: <HiH1 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
title: "Heading 2",
|
||||||
},
|
icon: <HiH2 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 2",
|
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||||
icon: <HiH2 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
title: "Heading 3",
|
||||||
},
|
icon: <HiH3 />,
|
||||||
{
|
command: ({ editor }) =>
|
||||||
title: "Heading 3",
|
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||||
icon: <HiH3 />,
|
},
|
||||||
command: ({ editor }) =>
|
{
|
||||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
title: "Bullet List",
|
||||||
},
|
icon: <HiOutlineListBullet />,
|
||||||
];
|
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||||
|
},
|
||||||
return [
|
{
|
||||||
...headingCommands,
|
title: "Ordered List",
|
||||||
{
|
icon: <HiOutlineNumberedList />,
|
||||||
title: "Bullet List",
|
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||||
icon: <HiOutlineListBullet />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
{
|
||||||
},
|
title: "Blockquote",
|
||||||
{
|
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||||
title: "Ordered List",
|
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||||
icon: <HiOutlineNumberedList />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
{
|
||||||
},
|
title: "Code Block",
|
||||||
{
|
icon: <HiOutlineCodeBracketSquare />,
|
||||||
title: "Blockquote",
|
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
},
|
||||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
];
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Code Block",
|
|
||||||
icon: <HiOutlineCodeBracketSquare />,
|
|
||||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
workspaceMembers,
|
|
||||||
enableYouTubeEmbed = true,
|
|
||||||
placeholder,
|
|
||||||
disableHeadings = false,
|
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
workspaceMembers: WorkspaceMember[];
|
|
||||||
enableYouTubeEmbed?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
disableHeadings?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit,
|
||||||
heading: disableHeadings ? false : undefined,
|
|
||||||
}),
|
|
||||||
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,
|
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: readOnly
|
placeholder: readOnly
|
||||||
? ""
|
? ""
|
||||||
: placeholder ??
|
: "Add description... (type '/' to open commands)",
|
||||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
|
||||||
}),
|
}),
|
||||||
SlashCommands.configure({
|
SlashCommands.configure({
|
||||||
commandItems: getCommandItems(disableHeadings),
|
commandItems: CommandItems,
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: ({ query }: { query: string }) =>
|
items: ({ query }: { query: string }) =>
|
||||||
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
filterSlashCommandItems(CommandItems, query),
|
||||||
startOfLine: true,
|
startOfLine: true,
|
||||||
char: "/",
|
char: "/",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
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> `;
|
|
||||||
|
|
||||||
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,
|
content,
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||||
@@ -574,23 +331,14 @@ export default function Editor({
|
|||||||
editable: !readOnly,
|
editable: !readOnly,
|
||||||
injectCSS: false,
|
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 (
|
return (
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
.tiptap p.is-empty::before {
|
.tiptap p.is-empty::before {
|
||||||
|
color: #adb5bd;
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -599,22 +347,11 @@ export default function Editor({
|
|||||||
.tiptap p {
|
.tiptap p {
|
||||||
margin: 0 0 1rem 0 !important;
|
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>
|
`}</style>
|
||||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={editor}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
148
apps/web/src/components/FeedbackButton.tsx
Normal 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;
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -55,7 +55,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
{prefix && (
|
{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}
|
{prefix}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{iconRight && type !== "password" && (
|
{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}
|
{iconRight}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Listbox, Transition } from "@headlessui/react";
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { Fragment, useEffect } from "react";
|
import { Fragment, useEffect } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
|
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
|
||||||
@@ -32,14 +31,14 @@ export function LabelForm({
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { closeModal, entityId, openModal, setModalState } = useModal();
|
const { closeModal, entityId, openModal } = useModal();
|
||||||
|
|
||||||
const label = api.label.byPublicId.useQuery(
|
const label = api.label.byPublicId.useQuery(
|
||||||
{
|
{
|
||||||
labelPublicId: entityId,
|
labelPublicId: entityId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!isEdit && entityId.length >= 12,
|
enabled: isEdit && !!entityId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,25 +56,20 @@ export function LabelForm({
|
|||||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||||
|
|
||||||
const createLabel = api.label.create.useMutation({
|
const createLabel = api.label.create.useMutation({
|
||||||
onSuccess: (newLabel) => {
|
onSuccess: () => {
|
||||||
const currentColourIndex = colours.findIndex(
|
const currentColourIndex = colours.findIndex(
|
||||||
(c) => c.code === watch("colour").code,
|
(c) => c.code === watch("colour").code,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
refetch();
|
refetch();
|
||||||
setModalState("NEW_LABEL_CREATED", newLabel.publicId);
|
if (!isCreateAnotherEnabled) closeModal();
|
||||||
if (!isCreateAnotherEnabled) {
|
reset({
|
||||||
closeModal();
|
name: "",
|
||||||
} else {
|
colour: colours[(currentColourIndex + 1) % colours.length],
|
||||||
const newFormState = {
|
isCreateAnotherEnabled,
|
||||||
name: "",
|
});
|
||||||
colour: colours[(currentColourIndex + 1) % colours.length],
|
|
||||||
isCreateAnotherEnabled,
|
|
||||||
};
|
|
||||||
reset(newFormState);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -84,6 +78,10 @@ export function LabelForm({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetch();
|
refetch();
|
||||||
closeModal();
|
closeModal();
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
colour: colours[0],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +114,7 @@ export function LabelForm({
|
|||||||
<div className="px-5 pt-5">
|
<div className="px-5 pt-5">
|
||||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||||
<h2 className="text-sm font-medium">
|
<h2 className="text-sm font-medium">
|
||||||
{isEdit ? t`Edit label` : t`New label`}
|
{isEdit ? "Edit label" : "New label"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -132,7 +130,7 @@ export function LabelForm({
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="label-name"
|
id="label-name"
|
||||||
placeholder={t`Name`}
|
placeholder="Name"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
@@ -207,10 +205,10 @@ export function LabelForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{!isEdit && (
|
||||||
<Toggle
|
<Toggle
|
||||||
label={t`Create another`}
|
label="Create another"
|
||||||
isChecked={!!isCreateAnotherEnabled}
|
isChecked={!!isCreateAnotherEnabled}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||||
@@ -221,19 +219,17 @@ export function LabelForm({
|
|||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => openModal("DELETE_LABEL", entityId)}
|
onClick={() => openModal("DELETE_LABEL", entityId)}
|
||||||
>
|
>
|
||||||
{t`Delete`}
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||||
disabled={!watch("name")}
|
|
||||||
>
|
>
|
||||||
{isEdit ? t`Update label` : t`Create label`}
|
{isEdit ? "Update label" : "Create label"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,11 @@ import dynamic from "next/dynamic";
|
|||||||
|
|
||||||
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
|
const Lottie = dynamic(() => import("react-lottie-player"), { ssr: false });
|
||||||
|
|
||||||
interface IconProps {
|
type IconProps = {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
index: number;
|
index: number;
|
||||||
json: object;
|
json: object;
|
||||||
}
|
};
|
||||||
|
|
||||||
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||||
return (
|
return (
|
||||||
@@ -15,7 +15,7 @@ const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
|||||||
animationData={json}
|
animationData={json}
|
||||||
play={isPlaying}
|
play={isPlaying}
|
||||||
loop={false}
|
loop={false}
|
||||||
style={{ width: 18, height: 18, fill: "white" }}
|
style={{ width: 20, height: 20, fill: "white" }}
|
||||||
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,112 +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 { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
import { HiXMark } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import { useDebounce } from "~/hooks/useDebounce";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
|
||||||
|
|
||||||
const schema = z.object({
|
interface FormValues {
|
||||||
name: z
|
name: string;
|
||||||
.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>;
|
|
||||||
|
|
||||||
export function NewWorkspaceForm() {
|
export function NewWorkspaceForm() {
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const { switchWorkspace, availableWorkspaces } = useWorkspace();
|
const { switchWorkspace } = useWorkspace();
|
||||||
const {
|
const { register, handleSubmit } = useForm<FormValues>();
|
||||||
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 createWorkspace = api.workspace.create.useMutation({
|
const createWorkspace = api.workspace.create.useMutation({
|
||||||
onSuccess: (values) => {
|
onSuccess: (values) => {
|
||||||
if (values.publicId && values.name) {
|
if (values.publicId && values.name) {
|
||||||
void utils.workspace.all.invalidate();
|
|
||||||
switchWorkspace({
|
switchWorkspace({
|
||||||
publicId: values.publicId,
|
publicId: values.publicId,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
slug: values.slug,
|
slug: values.slug,
|
||||||
plan: values.plan,
|
plan: values.plan,
|
||||||
role: "admin",
|
|
||||||
weekStartDay: 1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Unable to create workspace`,
|
header: "Unable to create workspace",
|
||||||
message: t`Please try again later, or contact customer support.`,
|
message: "Please try again later, or contact customer support.",
|
||||||
icon: "error",
|
icon: "error",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -118,38 +47,22 @@ export function NewWorkspaceForm() {
|
|||||||
if (nameElement) nameElement.focus();
|
if (nameElement) nameElement.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
// Don't submit if slug is provided but not available
|
|
||||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createWorkspace.mutate({
|
createWorkspace.mutate({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
slug: values.slug,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSlugAvailable =
|
|
||||||
isValidSlug &&
|
|
||||||
isWorkspaceSlugAvailable?.isAvailable &&
|
|
||||||
!isWorkspaceSlugAvailable?.isReserved;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="px-5 pt-5">
|
<div className="px-5 pt-5">
|
||||||
<div className="flex w-full items-center justify-between pb-4">
|
<div className="flex w-full items-center justify-between pb-4">
|
||||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
{t`New workspace`}
|
New workspace
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={twMerge(
|
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||||
"rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300",
|
|
||||||
!hasAvailableWorkspaces && "invisible",
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -161,9 +74,8 @@ export function NewWorkspaceForm() {
|
|||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="workspace-name"
|
id="workspace-name"
|
||||||
placeholder={t`Workspace name`}
|
placeholder="Workspace name"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
errorMessage={errors.name?.message}
|
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -171,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>
|
||||||
<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>
|
<div>
|
||||||
<Button
|
<Button type="submit" isLoading={createWorkspace.isPending}>
|
||||||
type="submit"
|
Create workspace
|
||||||
isLoading={createWorkspace.isPending}
|
|
||||||
disabled={
|
|
||||||
createWorkspace.isPending ||
|
|
||||||
(!!slug &&
|
|
||||||
(checkWorkspaceSlugAvailability.isPending ||
|
|
||||||
isWorkspaceSlugAvailable?.isAvailable === false ||
|
|
||||||
isTyping))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t`Create workspace`}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ export const PageHead = ({ title }: { title: string }) => {
|
|||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
|
||||||
/>
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
</Head>
|
</Head>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ const PatternedBackground = () => (
|
|||||||
<svg className="h-full w-full">
|
<svg className="h-full w-full">
|
||||||
<pattern
|
<pattern
|
||||||
id="pattern"
|
id="pattern"
|
||||||
x="10"
|
x="0.034759358288862785"
|
||||||
y="10"
|
y="3.335370511841166"
|
||||||
width="14.423223834988539"
|
width="14.423223834988539"
|
||||||
height="14.423223834988539"
|
height="14.423223834988539"
|
||||||
patternUnits="userSpaceOnUse"
|
patternUnits="userSpaceOnUse"
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||||
<Transition
|
<Transition
|
||||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||||
<div className="p-4 relative">
|
<div className="p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 mt-1">
|
<div className="flex-shrink-0">
|
||||||
{popupIcon === "success" && (
|
{popupIcon === "success" && (
|
||||||
<HiOutlineCheckCircle
|
<HiOutlineCheckCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-green-400"
|
className="h-6 w-6 text-green-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{popupIcon === "error" && (
|
{popupIcon === "error" && (
|
||||||
<HiOutlineExclamationCircle
|
<HiOutlineExclamationCircle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 text-red-400"
|
className="h-6 w-6 text-red-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||||
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||||
{popupHeader}
|
{popupHeader}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
{popupMessage}
|
{popupMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
<div className="ml-4 flex flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
<HiXMark
|
<HiXMark
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-4 w-4 text-dark-900"
|
className="h-5 w-5 text-dark-900"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,73 +1,37 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import LottieIcon from "~/components/LottieIcon";
|
import LottieIcon from "~/components/LottieIcon";
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
function classNames(...classes: string[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
const Button: React.FC<{
|
const Button: React.FC<{
|
||||||
href: string;
|
href: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
json: object;
|
json: object;
|
||||||
isCollapsed?: boolean;
|
}> = ({ href, current, name, json }) => {
|
||||||
onCloseSideNav?: () => void;
|
|
||||||
keyboardShortcut: KeyboardShortcut;
|
|
||||||
}> = ({
|
|
||||||
href,
|
|
||||||
current,
|
|
||||||
name,
|
|
||||||
json,
|
|
||||||
isCollapsed = false,
|
|
||||||
keyboardShortcut,
|
|
||||||
onCloseSideNav,
|
|
||||||
}) => {
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
setIndex((index) => index + 1);
|
setIndex((index) => index + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onClick={handleClick}
|
className={classNames(
|
||||||
className={twMerge(
|
current ? "bg-light-200 dark:bg-dark-200" : "dark:bg-dark-50",
|
||||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
"group flex 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",
|
||||||
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",
|
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? name : undefined}
|
|
||||||
>
|
>
|
||||||
<div
|
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||||
className={twMerge(
|
{name}
|
||||||
"flex items-center",
|
|
||||||
isCollapsed
|
|
||||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
|
||||||
: "gap-x-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
|
||||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
|
||||||
</div>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,22 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
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 boardsIconDark from "~/assets/boards-dark.json";
|
||||||
import boardsIconLight from "~/assets/boards-light.json";
|
import boardsIconLight from "~/assets/boards-light.json";
|
||||||
import membersIconDark from "~/assets/members-dark.json";
|
import membersIconDark from "~/assets/members-dark.json";
|
||||||
import membersIconLight from "~/assets/members-light.json";
|
import membersIconLight from "~/assets/members-light.json";
|
||||||
import settingsIconDark from "~/assets/settings-dark.json";
|
import settingsIconDark from "~/assets/settings-dark.json";
|
||||||
import settingsIconLight from "~/assets/settings-light.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 ReactiveButton from "~/components/ReactiveButton";
|
||||||
import UserMenu from "~/components/UserMenu";
|
import UserMenu from "~/components/UserMenu";
|
||||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useTheme } from "~/providers/theme";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
|
||||||
import { api } from "~/utils/api";
|
|
||||||
|
|
||||||
interface SideNavigationProps {
|
interface SideNavigationProps {
|
||||||
user: UserType;
|
user: UserType;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onCloseSideNav?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
displayName?: string | null | undefined;
|
|
||||||
email?: string | null | undefined;
|
email?: string | null | undefined;
|
||||||
image?: string | null | undefined;
|
image?: string | null | undefined;
|
||||||
}
|
}
|
||||||
@@ -47,148 +24,38 @@ interface UserType {
|
|||||||
export default function SideNavigation({
|
export default function SideNavigation({
|
||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
onCloseSideNav,
|
|
||||||
}: SideNavigationProps) {
|
}: SideNavigationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const [isInitialised, setIsInitialised] = useState(false);
|
|
||||||
const { openModal } = useModal();
|
|
||||||
|
|
||||||
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 { 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 = [
|
||||||
|
|
||||||
const navigation: {
|
|
||||||
name: string;
|
|
||||||
href: string;
|
|
||||||
icon: object;
|
|
||||||
keyboardShortcut: KeyboardShortcut;
|
|
||||||
}[] = [
|
|
||||||
{
|
{
|
||||||
name: t`Boards`,
|
name: "Boards",
|
||||||
href: "/boards",
|
href: "/boards",
|
||||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "B" }],
|
|
||||||
action: () => router.push("/boards"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to boards`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Templates`,
|
name: "Members",
|
||||||
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`,
|
|
||||||
href: "/members",
|
href: "/members",
|
||||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "M" }],
|
|
||||||
action: () => router.push("/members"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to members`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Settings`,
|
name: "Settings",
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "S" }],
|
|
||||||
action: () => router.push("/settings"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to settings`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleCollapse = () => {
|
|
||||||
setIsCollapsed(!isCollapsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="hidden h-[45px] items-center justify-between pb-3 md:flex">
|
<WorkspaceMenu />
|
||||||
{!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} />
|
|
||||||
<ul role="list" className="space-y-1">
|
<ul role="list" className="space-y-1">
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<li key={item.name}>
|
<li key={item.name}>
|
||||||
@@ -197,52 +64,16 @@ export default function SideNavigation({
|
|||||||
current={pathname.includes(item.href)}
|
current={pathname.includes(item.href)}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
json={item.icon}
|
json={item.icon}
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
onCloseSideNav={onCloseSideNav}
|
|
||||||
keyboardShortcut={item.keyboardShortcut}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<UserMenu
|
||||||
<div className="space-y-2">
|
email={user.email ?? ""}
|
||||||
<UserMenu
|
imageUrl={user.image ?? undefined}
|
||||||
displayName={user.displayName ?? undefined}
|
isLoading={isLoading}
|
||||||
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="/settings/workspace?upgrade=pro"
|
|
||||||
aria-label="Upgrade to Pro"
|
|
||||||
title="Upgrade to Pro"
|
|
||||||
iconOnly
|
|
||||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ButtonComponent
|
|
||||||
iconLeft={<HiBolt />}
|
|
||||||
fullWidth
|
|
||||||
variant="secondary"
|
|
||||||
href="/settings/workspace?upgrade=pro"
|
|
||||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
|
||||||
>
|
|
||||||
{t`Upgrade to Pro`}
|
|
||||||
</ButtonComponent>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { CgDarkMode } from "react-icons/cg";
|
import { CgDarkMode } from "react-icons/cg";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
|
import { useTheme } from "~/providers/theme";
|
||||||
|
|
||||||
const ThemeToggle = () => {
|
const ThemeToggle = () => {
|
||||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
const { themePreference, switchTheme } = useTheme();
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(theme === "light" ? "dark" : "light");
|
switchTheme(themePreference === "light" ? "dark" : "light");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
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
|
<CgDarkMode
|
||||||
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
|
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>
|
</button>
|
||||||
|
|||||||
@@ -5,27 +5,18 @@ const Toggle = ({
|
|||||||
isChecked,
|
isChecked,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
disabled,
|
|
||||||
showLabel = true,
|
|
||||||
labelPosition = "before",
|
|
||||||
}: {
|
}: {
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
|
||||||
showLabel?: boolean;
|
|
||||||
labelPosition?: "before" | "after";
|
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex items-center">
|
<div className="mr-4 flex items-center justify-end">
|
||||||
{showLabel && labelPosition === "before" && (
|
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
{label}
|
||||||
{label}
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
|
||||||
className={twMerge(
|
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",
|
"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",
|
isChecked && "bg-indigo-600 dark:bg-indigo-600",
|
||||||
@@ -40,11 +31,6 @@ const Toggle = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
{showLabel && labelPosition === "after" && (
|
|
||||||
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +1,49 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { env } from "~/env";
|
import { useTheme } from "~/providers/theme";
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
|
||||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
imageUrl: string | undefined;
|
imageUrl: string | undefined;
|
||||||
displayName: string | undefined;
|
|
||||||
email: string;
|
email: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isCollapsed?: boolean;
|
}
|
||||||
onCloseSideNav?: () => void;
|
|
||||||
|
function classNames(...classes: string[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserMenu({
|
export default function UserMenu({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
email,
|
email,
|
||||||
displayName,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isCollapsed = false,
|
|
||||||
onCloseSideNav,
|
|
||||||
}: UserMenuProps) {
|
}: UserMenuProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, setTheme } = useTheme();
|
const { themePreference, switchTheme } = useTheme();
|
||||||
const { openModal } = useModal();
|
|
||||||
const { openLegend } = useKeyboardShortcuts();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
|
|
||||||
router.push("/login");
|
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;
|
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu as="div" className="relative inline-block w-full text-left">
|
<Menu as="div" className="relative inline-block w-full text-left">
|
||||||
<div>
|
<div>
|
||||||
{isLoading ? (
|
{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="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||||
<div
|
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||||
className={twMerge(
|
|
||||||
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Menu.Button
|
<Menu.Button className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000">
|
||||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
|
||||||
title={isCollapsed ? (displayName || email) : undefined}
|
|
||||||
>
|
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
@@ -98,14 +63,7 @@ export default function UserMenu({
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="mx-2 truncate text-sm">{email}</span>
|
||||||
className={twMerge(
|
|
||||||
"mx-2 truncate text-sm",
|
|
||||||
isCollapsed && "md:hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{displayName || email}
|
|
||||||
</span>
|
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,102 +77,52 @@ export default function UserMenu({
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items
|
<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">
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
||||||
<span>{t`Theme`}</span>
|
<span>Theme</span>
|
||||||
</div>
|
</div>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<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"
|
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
|
<span
|
||||||
className={twMerge(
|
className={classNames(
|
||||||
theme === "system" ? "visible" : "invisible",
|
themePreference === "system" ? "visible" : "invisible",
|
||||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t`System`}
|
System
|
||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<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"
|
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
|
<span
|
||||||
className={twMerge(
|
className={classNames(
|
||||||
theme === "dark" ? "visible" : "invisible",
|
themePreference === "dark" ? "visible" : "invisible",
|
||||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t`Dark`}
|
Dark
|
||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<button
|
<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"
|
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
|
<span
|
||||||
className={twMerge(
|
className={classNames(
|
||||||
theme === "light" ? "visible" : "invisible",
|
themePreference === "light" ? "visible" : "invisible",
|
||||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t`Light`}
|
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`}
|
|
||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,29 +132,10 @@ export default function UserMenu({
|
|||||||
onClick={handleLogout}
|
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"
|
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>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
{env.NEXT_PUBLIC_APP_VERSION && (
|
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
|
||||||
<Menu.Item>
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
|
||||||
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
|
||||||
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
onClick={handleLinkClick}
|
|
||||||
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
|
||||||
</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,169 +1,92 @@
|
|||||||
import { Button, Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { Fragment } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { HiCheck } from "react-icons/hi2";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { Fragment, useState } from "react";
|
|
||||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import CommandPallette from "./CommandPallette";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
export default function WorkspaceMenu({
|
export default function WorkspaceMenu() {
|
||||||
isCollapsed = false,
|
|
||||||
}: {
|
|
||||||
isCollapsed?: boolean;
|
|
||||||
}) {
|
|
||||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||||
useWorkspace();
|
useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
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 (
|
return (
|
||||||
<>
|
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||||
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
<div>
|
||||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
{isLoading ? (
|
||||||
<div>
|
<div className="mb-1 flex p-1.5">
|
||||||
{isLoading ? (
|
<div className="h-6 w-6 animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
</div>
|
||||||
<div
|
) : (
|
||||||
className={twMerge(
|
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200">
|
||||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||||
isCollapsed && "md:hidden",
|
<span className="text-xs font-bold leading-none text-white">
|
||||||
)}
|
{workspace.name.charAt(0).toUpperCase()}
|
||||||
/>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
) : (
|
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
<div
|
{workspace.name}
|
||||||
className={twMerge(
|
</span>
|
||||||
"flex items-center justify-start gap-1",
|
{workspace.plan === "pro" && (
|
||||||
isCollapsed && "md:flex-col-reverse md:items-center",
|
<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>
|
||||||
<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.Button>
|
||||||
<div className="p-1">
|
)}
|
||||||
{availableWorkspaces.map((availableWorkspace) => (
|
</div>
|
||||||
<div key={availableWorkspace.publicId} className="flex">
|
|
||||||
<Menu.Item>
|
<Transition
|
||||||
<button
|
as={Fragment}
|
||||||
onClick={() => switchWorkspace(availableWorkspace)}
|
enter="transition ease-out duration-100"
|
||||||
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"
|
enterFrom="transform opacity-0 scale-95"
|
||||||
>
|
enterTo="transform opacity-100 scale-100"
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
leave="transition ease-in duration-75"
|
||||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
<span className="text-xs font-medium leading-none text-white">
|
leaveTo="transform opacity-0 scale-95"
|
||||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
>
|
||||||
</span>
|
<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>
|
||||||
<span className="ml-2 truncate text-xs font-medium">
|
</span>
|
||||||
{availableWorkspace.name}
|
<span className="ml-2 text-xs font-medium">
|
||||||
</span>
|
{availableWorkspace.name}
|
||||||
</div>
|
</span>
|
||||||
{workspace.publicId === availableWorkspace.publicId && (
|
</div>
|
||||||
<span>
|
{workspace.name === availableWorkspace.name && (
|
||||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
<span>
|
||||||
</span>
|
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||||
)}
|
</span>
|
||||||
</button>
|
)}
|
||||||
</Menu.Item>
|
</button>
|
||||||
</div>
|
</Menu.Item>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
</div>
|
||||||
<Menu.Item>
|
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||||
<button
|
<Menu.Item>
|
||||||
onClick={() =>
|
<button
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
onClick={() => openModal("NEW_WORKSPACE")}
|
||||||
? 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"
|
||||||
: openModal("NEW_WORKSPACE")
|
>
|
||||||
}
|
Create 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"
|
</button>
|
||||||
>
|
</Menu.Item>
|
||||||
{t`Create workspace`}
|
</div>
|
||||||
</button>
|
</Menu.Items>
|
||||||
</Menu.Item>
|
</Transition>
|
||||||
</div>
|
</Menu>
|
||||||
</Menu.Items>
|
|
||||||
</Transition>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { HiXMark } from "react-icons/hi2";
|
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Input from "~/components/Input";
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
|
||||||
|
|
||||||
interface EditYouTubeFormInput {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditYouTubeModalState {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
onSave: (url: string, title: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditYouTubeModal() {
|
|
||||||
const { closeModal, getModalState } = useModal();
|
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
|
||||||
const [urlError, setUrlError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Get initial values and callback from modal state
|
|
||||||
const modalState = getModalState("EDIT_YOUTUBE") as
|
|
||||||
| EditYouTubeModalState
|
|
||||||
| undefined;
|
|
||||||
const initialUrl = modalState?.url ?? "";
|
|
||||||
const initialTitle = modalState?.title ?? "";
|
|
||||||
const onSave = modalState?.onSave;
|
|
||||||
|
|
||||||
const { register, handleSubmit, watch, reset } =
|
|
||||||
useForm<EditYouTubeFormInput>({
|
|
||||||
defaultValues: {
|
|
||||||
url: initialUrl,
|
|
||||||
title: initialTitle,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form when modal state changes (when modal opens with new values)
|
|
||||||
useEffect(() => {
|
|
||||||
if (modalState) {
|
|
||||||
reset({
|
|
||||||
url: modalState.url,
|
|
||||||
title: modalState.title,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [modalState, reset]);
|
|
||||||
|
|
||||||
const currentUrl = watch("url");
|
|
||||||
|
|
||||||
const onSubmit = async (values: EditYouTubeFormInput) => {
|
|
||||||
// Validate URL
|
|
||||||
if (!isYouTubeUrl(values.url)) {
|
|
||||||
setUrlError(t`Please enter a valid YouTube URL`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUrlError(null);
|
|
||||||
setIsValidating(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If title is empty and URL changed, fetch new title
|
|
||||||
let finalTitle = values.title;
|
|
||||||
if (!finalTitle.trim() && values.url !== initialUrl) {
|
|
||||||
const metadata = await fetchYouTubeMetadata(values.url);
|
|
||||||
finalTitle = metadata?.title ?? "YouTube Video";
|
|
||||||
} else if (!finalTitle.trim()) {
|
|
||||||
// Keep existing title if no new title provided and URL unchanged
|
|
||||||
finalTitle = initialTitle || "YouTube Video";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSave) {
|
|
||||||
onSave(values.url, finalTitle);
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
setUrlError(t`Failed to fetch video information`);
|
|
||||||
} finally {
|
|
||||||
setIsValidating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-focus on title input (more useful for editing)
|
|
||||||
useEffect(() => {
|
|
||||||
const titleElement: HTMLElement | null =
|
|
||||||
document.querySelector<HTMLElement>("#youtube-title");
|
|
||||||
if (titleElement) titleElement.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Validate URL on change
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUrl && !isYouTubeUrl(currentUrl)) {
|
|
||||||
setUrlError(t`Please enter a valid YouTube URL`);
|
|
||||||
} else {
|
|
||||||
setUrlError(null);
|
|
||||||
}
|
|
||||||
}, [currentUrl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div className="px-5 pt-5">
|
|
||||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
|
||||||
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
closeModal();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="youtube-title"
|
|
||||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
|
||||||
>
|
|
||||||
{t`Title`}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="youtube-title"
|
|
||||||
placeholder={t`Enter a custom title`}
|
|
||||||
{...register("title")}
|
|
||||||
onKeyDown={async (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="youtube-url"
|
|
||||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
|
||||||
>
|
|
||||||
{t`YouTube URL`}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="youtube-url"
|
|
||||||
placeholder={t`https://www.youtube.com/watch?v=...`}
|
|
||||||
{...register("url", { required: true })}
|
|
||||||
onKeyDown={async (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{urlError && (
|
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
|
||||||
{urlError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
|
||||||
<div className="space-x-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => closeModal()}
|
|
||||||
>
|
|
||||||
{t`Cancel`}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
isLoading={isValidating}
|
|
||||||
disabled={!watch("url") || !!urlError}
|
|
||||||
>
|
|
||||||
{t`Save`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import YouTubeDropdown from "./YouTubeDropdown";
|
|
||||||
|
|
||||||
interface YouTubeCardProps {
|
|
||||||
videoId: string;
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
showEmbed?: boolean;
|
|
||||||
onConvertToLink: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onUpdate: (url: string, title: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const YouTubeCard = ({
|
|
||||||
videoId,
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
showEmbed = true,
|
|
||||||
onConvertToLink,
|
|
||||||
onDelete,
|
|
||||||
onUpdate,
|
|
||||||
}: YouTubeCardProps) => {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
|
|
||||||
<div className="flex items-center justify-between gap-6 p-0 px-6">
|
|
||||||
<h3 className="truncate text-sm font-medium">{title}</h3>
|
|
||||||
<div className="mt-3">
|
|
||||||
<YouTubeDropdown
|
|
||||||
url={url}
|
|
||||||
title={title}
|
|
||||||
onConvertToLink={onConvertToLink}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showEmbed && videoId && (
|
|
||||||
<div className="p-6 pt-1">
|
|
||||||
<iframe
|
|
||||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
|
||||||
title={title}
|
|
||||||
className="aspect-video w-full rounded-lg"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default YouTubeCard;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import {
|
|
||||||
HiEllipsisHorizontal,
|
|
||||||
HiLink,
|
|
||||||
HiPencil,
|
|
||||||
HiTrash,
|
|
||||||
} from "react-icons/hi2";
|
|
||||||
|
|
||||||
import { useModal } from "~/providers/modal";
|
|
||||||
import Dropdown from "../Dropdown";
|
|
||||||
|
|
||||||
interface YouTubeDropdownProps {
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
onConvertToLink: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onUpdate: (url: string, title: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const YouTubeDropdown = ({
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
onConvertToLink,
|
|
||||||
onDelete,
|
|
||||||
onUpdate,
|
|
||||||
}: YouTubeDropdownProps) => {
|
|
||||||
const { openModal, setModalState } = useModal();
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
setModalState("EDIT_YOUTUBE", {
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
onSave: onUpdate,
|
|
||||||
});
|
|
||||||
openModal("EDIT_YOUTUBE");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: t`Edit`,
|
|
||||||
action: handleEdit,
|
|
||||||
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Convert to link`,
|
|
||||||
action: onConvertToLink,
|
|
||||||
icon: <HiLink className="h-4 w-4 text-dark-900" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete`,
|
|
||||||
action: onDelete,
|
|
||||||
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default YouTubeDropdown;
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import { mergeAttributes, Node } from "@tiptap/core";
|
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
|
|
||||||
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
|
||||||
import YouTubeNodeView from "./YouTubeNodeView";
|
|
||||||
|
|
||||||
export interface YouTubeOptions {
|
|
||||||
inline: boolean;
|
|
||||||
HTMLAttributes: Record<string, undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
youTube: {
|
|
||||||
setYouTubeEmbed: (options: {
|
|
||||||
videoId: string;
|
|
||||||
url: string;
|
|
||||||
title: string;
|
|
||||||
thumbnailUrl?: string;
|
|
||||||
showEmbed?: boolean;
|
|
||||||
}) => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const YouTubeNode = Node.create<YouTubeOptions>({
|
|
||||||
name: "youtube",
|
|
||||||
group: "block",
|
|
||||||
atom: true,
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
inline: false,
|
|
||||||
HTMLAttributes: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
videoId: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-video-id"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.videoId) return {};
|
|
||||||
return { "data-video-id": attributes.videoId as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-url"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.url) return {};
|
|
||||||
return { "data-url": attributes.url as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
default: "YouTube Video",
|
|
||||||
parseHTML: (element) => element.getAttribute("data-title"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
return { "data-title": attributes.title as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
thumbnailUrl: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-thumbnail"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
if (!attributes.thumbnailUrl) return {};
|
|
||||||
return { "data-thumbnail": attributes.thumbnailUrl as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
showEmbed: {
|
|
||||||
default: true,
|
|
||||||
parseHTML: (element) => element.getAttribute("data-show-embed"),
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
return { "data-show-embed": attributes.showEmbed as string };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: "div[data-youtube]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
return [
|
|
||||||
"div",
|
|
||||||
mergeAttributes(
|
|
||||||
{ "data-youtube": "" },
|
|
||||||
this.options.HTMLAttributes,
|
|
||||||
HTMLAttributes,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
return ReactNodeViewRenderer(YouTubeNodeView);
|
|
||||||
},
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
const nodeType = this.type;
|
|
||||||
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
key: new PluginKey("youtubePaste"),
|
|
||||||
props: {
|
|
||||||
handlePaste: (view, event) => {
|
|
||||||
const text = event.clipboardData?.getData("text/plain");
|
|
||||||
if (!text) return false;
|
|
||||||
|
|
||||||
// Check if the pasted text is a YouTube URL
|
|
||||||
const youtubeRegex =
|
|
||||||
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
|
|
||||||
const match = youtubeRegex.exec(text);
|
|
||||||
|
|
||||||
if (!match || !isYouTubeUrl(text)) return false;
|
|
||||||
|
|
||||||
const videoId = extractVideoId(text);
|
|
||||||
if (!videoId) return false;
|
|
||||||
|
|
||||||
const { state, dispatch } = view;
|
|
||||||
const { tr } = state;
|
|
||||||
|
|
||||||
const node = nodeType.create({
|
|
||||||
videoId,
|
|
||||||
url: text,
|
|
||||||
title: "Loading...",
|
|
||||||
showEmbed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
tr.replaceSelectionWith(node);
|
|
||||||
dispatch(tr);
|
|
||||||
|
|
||||||
// Fetch metadata asynchronously and update the node
|
|
||||||
void fetchYouTubeMetadata(text).then((metadata) => {
|
|
||||||
const { state: newState, dispatch: newDispatch } = view;
|
|
||||||
const { tr: newTr } = newState;
|
|
||||||
|
|
||||||
newState.doc.descendants((n, pos) => {
|
|
||||||
if (
|
|
||||||
n.type.name === "youtube" &&
|
|
||||||
n.attrs.videoId === videoId &&
|
|
||||||
n.attrs.title === "Loading..."
|
|
||||||
) {
|
|
||||||
newTr.setNodeMarkup(pos, undefined, {
|
|
||||||
...n.attrs,
|
|
||||||
title: metadata?.title ?? "YouTube Video",
|
|
||||||
thumbnailUrl: metadata?.thumbnail_url ?? null,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newTr.docChanged) {
|
|
||||||
newDispatch(newTr);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
// commands for the YouTube embed node
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setYouTubeEmbed:
|
|
||||||
(options: {
|
|
||||||
videoId: string;
|
|
||||||
url: string;
|
|
||||||
title?: string;
|
|
||||||
thumbnailUrl?: string;
|
|
||||||
showEmbed?: boolean;
|
|
||||||
}) =>
|
|
||||||
({ editor }) => {
|
|
||||||
return editor.commands.insertContent({
|
|
||||||
type: this.name,
|
|
||||||
attrs: {
|
|
||||||
videoId: options.videoId,
|
|
||||||
url: options.url,
|
|
||||||
title: options.title ?? "YouTube Video",
|
|
||||||
thumbnailUrl: options.thumbnailUrl,
|
|
||||||
showEmbed: options.showEmbed ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { NodeViewProps } from "@tiptap/react";
|
|
||||||
import { NodeViewWrapper } from "@tiptap/react";
|
|
||||||
|
|
||||||
import { extractVideoId } from "./utils";
|
|
||||||
import YouTubeCard from "./YouTubeCard";
|
|
||||||
|
|
||||||
export default function YouTubeNodeView({
|
|
||||||
node,
|
|
||||||
editor,
|
|
||||||
getPos,
|
|
||||||
deleteNode,
|
|
||||||
updateAttributes,
|
|
||||||
}: NodeViewProps) {
|
|
||||||
const { videoId, title, url, showEmbed } = node.attrs as {
|
|
||||||
videoId: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
showEmbed: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConvertToLink = () => {
|
|
||||||
const pos = getPos();
|
|
||||||
if (typeof pos !== "number") return;
|
|
||||||
|
|
||||||
const linkContent = {
|
|
||||||
type: "paragraph",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: url,
|
|
||||||
marks: [
|
|
||||||
{
|
|
||||||
type: "link",
|
|
||||||
attrs: {
|
|
||||||
href: url,
|
|
||||||
target: "_blank",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
|
||||||
.insertContentAt(pos, linkContent)
|
|
||||||
.run();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
deleteNode();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = (newUrl: string, newTitle: string) => {
|
|
||||||
if (newUrl !== url) {
|
|
||||||
const newVideoId = extractVideoId(newUrl);
|
|
||||||
updateAttributes({
|
|
||||||
url: newUrl,
|
|
||||||
title: newTitle,
|
|
||||||
videoId: newVideoId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateAttributes({ title: newTitle });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper>
|
|
||||||
<YouTubeCard
|
|
||||||
videoId={videoId}
|
|
||||||
url={url}
|
|
||||||
title={title}
|
|
||||||
showEmbed={showEmbed}
|
|
||||||
onConvertToLink={handleConvertToLink}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
/>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
export function extractVideoId(url: string): string | null {
|
|
||||||
if (!url) return null;
|
|
||||||
url = url.trim();
|
|
||||||
|
|
||||||
// youtube.com/watch?v=VIDEO_ID
|
|
||||||
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
|
|
||||||
if (watchMatch) return watchMatch[1] ?? null;
|
|
||||||
|
|
||||||
// youtu.be/VIDEO_ID
|
|
||||||
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
|
||||||
if (shortMatch) return shortMatch[1] ?? null;
|
|
||||||
|
|
||||||
// youtube.com/embed/VIDEO_ID
|
|
||||||
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
|
||||||
if (embedMatch) return embedMatch[1] ?? null;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL is a valid YouTube link
|
|
||||||
*/
|
|
||||||
export function isYouTubeUrl(url: string): boolean {
|
|
||||||
if (!url) return false;
|
|
||||||
const videoId = extractVideoId(url);
|
|
||||||
return videoId !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch YouTube video metadata using oEmbed API
|
|
||||||
* Returns title, author, thumbnail URL, etc.
|
|
||||||
* No API key required
|
|
||||||
*/
|
|
||||||
export async function fetchYouTubeMetadata(url: string): Promise<{
|
|
||||||
title: string;
|
|
||||||
author_name: string;
|
|
||||||
thumbnail_url: string;
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
|
||||||
const response = await fetch(oembedUrl);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
|
||||||
title: string;
|
|
||||||
author_name: string;
|
|
||||||
thumbnail_url: string;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
title: data.title || "YouTube Video",
|
|
||||||
author_name: data.author_name || "",
|
|
||||||
thumbnail_url: data.thumbnail_url || "",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch YouTube metadata:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,28 +7,14 @@ interface Props {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
modalSize?: "sm" | "md" | "lg";
|
modalSize?: "sm" | "md" | "lg";
|
||||||
positionFromTop?: "sm" | "md" | "lg";
|
positionFromTop?: "sm" | "md" | "lg";
|
||||||
isVisible?: boolean;
|
|
||||||
closeOnClickOutside?: boolean;
|
|
||||||
centered?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<Props> = ({
|
const Modal: React.FC<Props> = ({
|
||||||
children,
|
children,
|
||||||
modalSize = "sm",
|
modalSize = "sm",
|
||||||
positionFromTop = "md",
|
positionFromTop = "md",
|
||||||
isVisible,
|
|
||||||
closeOnClickOutside,
|
|
||||||
centered = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const { isOpen, closeModal } = useModal();
|
||||||
isOpen,
|
|
||||||
closeModal,
|
|
||||||
closeOnClickOutside: modalCloseOnClickOutside,
|
|
||||||
} = useModal();
|
|
||||||
|
|
||||||
const shouldShow = isVisible ?? isOpen;
|
|
||||||
const shouldCloseOnClickOutside =
|
|
||||||
closeOnClickOutside ?? modalCloseOnClickOutside;
|
|
||||||
|
|
||||||
const modalSizeMap = {
|
const modalSizeMap = {
|
||||||
sm: "max-w-[400px]",
|
sm: "max-w-[400px]",
|
||||||
@@ -43,12 +29,8 @@ const Modal: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={shouldShow} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
<Dialog
|
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||||
as="div"
|
|
||||||
className="relative z-50"
|
|
||||||
onClose={shouldCloseOnClickOutside ? closeModal : () => null}
|
|
||||||
>
|
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
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" />
|
<div className="fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@@ -73,7 +55,7 @@ const Modal: React.FC<Props> = ({
|
|||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -14,18 +14,14 @@ export const env = createEnv({
|
|||||||
* This way you can ensure the app isn't built with invalid env vars.
|
* This way you can ensure the app isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
KAN_ADMIN_API_KEY: z.string().optional(),
|
|
||||||
BETTER_AUTH_SECRET: z.string(),
|
BETTER_AUTH_SECRET: z.string(),
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
.refine((s) =>
|
||||||
.refine(
|
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||||
(s) =>
|
|
||||||
!s ||
|
|
||||||
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
POSTGRES_URL: z.string().url().optional().or(z.literal("")),
|
POSTGRES_URL: z.string().url(),
|
||||||
TRELLO_APP_API_KEY: z.string().optional(),
|
TRELLO_APP_API_KEY: z.string().optional(),
|
||||||
TRELLO_APP_SECRET: z.string().optional(),
|
TRELLO_APP_SECRET: z.string().optional(),
|
||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
@@ -52,12 +48,6 @@ export const env = createEnv({
|
|||||||
VK_CLIENT_SECRET: z.string().optional(),
|
VK_CLIENT_SECRET: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||||
NOVU_API_KEY: z.string().optional(),
|
|
||||||
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
|
||||||
// Generic OIDC Provider
|
|
||||||
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_ID: z.string().optional(),
|
||||||
REDDIT_CLIENT_SECRET: z.string().optional(),
|
REDDIT_CLIENT_SECRET: z.string().optional(),
|
||||||
ROBLOX_CLIENT_ID: z.string().optional(),
|
ROBLOX_CLIENT_ID: z.string().optional(),
|
||||||
@@ -76,9 +66,7 @@ export const env = createEnv({
|
|||||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
S3_REGION: z.string().optional(),
|
S3_REGION: z.string().optional(),
|
||||||
S3_ENDPOINT: z.string().optional(),
|
S3_ENDPOINT: z.string().optional(),
|
||||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
|
||||||
EMAIL_FROM: z.string().optional(),
|
EMAIL_FROM: z.string().optional(),
|
||||||
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,42 +76,17 @@ export const env = createEnv({
|
|||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
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_STORAGE_URL: z.string().url().optional(),
|
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
|
||||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
|
|
||||||
.string()
|
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
|
||||||
.refine(
|
|
||||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||||
.refine(
|
|
||||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
|
||||||
)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP: z
|
NEXT_PUBLIC_DISABLE_SIGN_UP: z
|
||||||
.string()
|
.string()
|
||||||
.transform((s) => (s === "" ? undefined : s))
|
.refine((s) => s.toLowerCase() === "true" || s.toLowerCase() === "false")
|
||||||
.refine(
|
|
||||||
(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(),
|
.optional(),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -133,23 +96,12 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
|
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
|
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_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_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_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_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
|
|
||||||
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
|
||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
|
||||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
|
||||||
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
|
|
||||||
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
|
|
||||||
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
|
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation:
|
||||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface UseDragToScrollOptions {
|
|
||||||
/**
|
|
||||||
* Whether drag-to-scroll is enabled
|
|
||||||
*/
|
|
||||||
enabled?: boolean;
|
|
||||||
/**
|
|
||||||
* The direction to scroll
|
|
||||||
*/
|
|
||||||
direction?: "horizontal" | "vertical" | "both";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to enable drag-to-scroll functionality on a scrollable element
|
|
||||||
* @param options Configuration options
|
|
||||||
* @returns Ref to attach to the scrollable element and mouse event handlers
|
|
||||||
*/
|
|
||||||
export function useDragToScroll({
|
|
||||||
enabled = true,
|
|
||||||
direction = "horizontal",
|
|
||||||
}: UseDragToScrollOptions = {}) {
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const startPosRef = useRef({ x: 0, y: 0 });
|
|
||||||
const scrollStartRef = useRef({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || !isDragging) return;
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - startPosRef.current.x;
|
|
||||||
const deltaY = e.clientY - startPosRef.current.y;
|
|
||||||
|
|
||||||
if (direction === "horizontal" || direction === "both") {
|
|
||||||
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
|
|
||||||
}
|
|
||||||
if (direction === "vertical" || direction === "both") {
|
|
||||||
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
document.body.style.cursor = "grabbing";
|
|
||||||
document.body.style.userSelect = "none";
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
document.body.style.cursor = "";
|
|
||||||
document.body.style.userSelect = "";
|
|
||||||
};
|
|
||||||
}, [enabled, isDragging, direction]);
|
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const container = scrollRef.current;
|
|
||||||
|
|
||||||
// Check if the click is on an interactive or draggable element
|
|
||||||
// We need to be careful not to interfere with react-beautiful-dnd dragging
|
|
||||||
const isInteractiveElement =
|
|
||||||
target.closest("a") ||
|
|
||||||
target.closest("button") ||
|
|
||||||
target.closest("input") ||
|
|
||||||
target.closest("textarea") ||
|
|
||||||
target.closest("[role='button']") ||
|
|
||||||
target.closest("[draggable='true']") ||
|
|
||||||
target.closest(".react-beautiful-dnd-drag-handle") ||
|
|
||||||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
|
|
||||||
target.closest("[data-rbd-draggable-id]");
|
|
||||||
|
|
||||||
// Don't start dragging if clicking on interactive elements
|
|
||||||
if (isInteractiveElement) return;
|
|
||||||
|
|
||||||
// Enable drag-to-scroll for any non-interactive element within the container
|
|
||||||
if (container.contains(target)) {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
scrollStartRef.current = {
|
|
||||||
x: container.scrollLeft,
|
|
||||||
y: container.scrollTop,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
ref: scrollRef,
|
|
||||||
onMouseDown: handleMouseDown,
|
|
||||||
isDragging,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,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 };
|
|
||||||
@@ -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;
|
|
||||||
@@ -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, ru, ptBR} 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,
|
|
||||||
"pt-BR": 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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)");
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
6
apps/web/src/instrumentation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await require("pino");
|
||||||
|
await require("next-logger");
|
||||||
|
}
|
||||||
|
}
|
||||||