Compare commits
200 Commits
fix/docker
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d8a43d3c | ||
|
|
8ed719c485 | ||
|
|
f455bec6a7 | ||
|
|
aee5391f28 | ||
|
|
c8517ed4c2 | ||
|
|
ceac4c0d81 | ||
|
|
4c5856ec24 | ||
|
|
976373a65b | ||
|
|
78b9de869f | ||
|
|
81c03b51e2 | ||
|
|
0d3d9a358f | ||
|
|
2269d23c94 | ||
|
|
e437d075e3 | ||
|
|
b422907b53 | ||
|
|
672dfe6540 | ||
|
|
ef5bf87fdf | ||
|
|
ec37f5480a | ||
|
|
7f5a1ab513 | ||
|
|
5f2d409773 | ||
|
|
210e44db5c | ||
|
|
befe7ab7f4 | ||
|
|
53a33c68fc | ||
|
|
3bae03613d | ||
|
|
c42ee7cf2c | ||
|
|
07f997c3ad | ||
|
|
d084d323bc | ||
|
|
6729c2e228 | ||
|
|
b18ef10313 | ||
|
|
03bd2d771b | ||
|
|
ff7944a4a2 | ||
|
|
0ca9c1d0e6 | ||
|
|
927bf2fe69 | ||
|
|
ccd84385a2 | ||
|
|
58c5e92155 | ||
|
|
885f119404 | ||
|
|
b17a24455a | ||
|
|
0c9561467d | ||
|
|
47b6f06be4 | ||
|
|
89c8961176 | ||
|
|
a5432c60b7 | ||
|
|
2b8fe3d3a2 | ||
|
|
73c9b326a1 | ||
|
|
a55c8cd52b | ||
|
|
cca8a9d424 | ||
|
|
57186b6b4d | ||
|
|
3cb40d0f9a | ||
|
|
6ebab28606 | ||
|
|
5765c71ebf | ||
|
|
86ecdca7f1 | ||
|
|
bcd20b58c6 | ||
|
|
36675604f9 | ||
|
|
94760f1e4c | ||
|
|
38f477bb5a | ||
|
|
d208952d15 | ||
|
|
88f1aa0ec4 | ||
|
|
06ca6e38b3 | ||
|
|
915877b8e6 | ||
|
|
bb8f3de0ad | ||
|
|
d3c8cbd6c0 | ||
|
|
6c3dbaefd3 | ||
|
|
f938723d9a | ||
|
|
0928709173 | ||
|
|
ade9aad4bf | ||
|
|
5fbaf26707 | ||
|
|
b69835f7de | ||
|
|
58ff6ad041 | ||
|
|
adbc945ed6 | ||
|
|
c5d85cdb2d | ||
|
|
56e4c43cfa | ||
|
|
07fc41761b | ||
|
|
50382e9186 | ||
|
|
00be5fb058 | ||
|
|
fc65f53e0c | ||
|
|
64aace6e3b | ||
|
|
1651faae14 | ||
|
|
b472c5ce93 | ||
|
|
af6490896a | ||
|
|
c0a3c37d95 | ||
|
|
c30da618ec | ||
|
|
735db21227 | ||
|
|
e8d52de5e4 | ||
|
|
a93a8c5f43 | ||
|
|
8ffadd0181 | ||
|
|
5909b3ea87 | ||
|
|
81e9fe777c | ||
|
|
690c1394b1 | ||
|
|
0afaa5c159 | ||
|
|
86638026df | ||
|
|
4a3cf50c76 | ||
|
|
1e2326ac2e | ||
|
|
bb4784fd28 | ||
|
|
4ffc7e3696 | ||
|
|
9438620848 | ||
|
|
5540cdce42 | ||
|
|
e469426809 | ||
|
|
ffba2f5053 | ||
|
|
b2f3849716 | ||
|
|
756d93c067 | ||
|
|
c7288cbea8 | ||
|
|
5f9360c927 | ||
|
|
a7a27e7054 | ||
|
|
362381f516 | ||
|
|
fc692cb411 | ||
|
|
adbb25edd7 | ||
|
|
6059fa35a2 | ||
|
|
60355c762f | ||
|
|
b159b48c35 | ||
|
|
7ab30acfc4 | ||
|
|
fed24d1960 | ||
|
|
2a0220ce6f | ||
|
|
7ccd2ac1e0 | ||
|
|
cea5cf84c8 | ||
|
|
6738fddc5f | ||
|
|
dc1f78df55 | ||
|
|
87e02fdcb0 | ||
|
|
7073cd5931 | ||
|
|
3e21b23f0a | ||
|
|
793baa8325 | ||
|
|
63e639e337 | ||
|
|
c2ccb6b13b | ||
|
|
4ee1d6f2d5 | ||
|
|
2f88366418 | ||
|
|
4f7fa1a228 | ||
|
|
308cb22729 | ||
|
|
6c04a10b1f | ||
|
|
99459415d5 | ||
|
|
5a4835091f | ||
|
|
061a54dadd | ||
|
|
a532fa1ce2 | ||
|
|
2a355238a4 | ||
|
|
874353e8b8 | ||
|
|
18e60333ae | ||
|
|
d729785802 | ||
|
|
abbbb4b928 | ||
|
|
3cea9377e6 | ||
|
|
06c268bed1 | ||
|
|
e3d7ace8bd | ||
|
|
fecc6cf742 | ||
|
|
f7c2e7979e | ||
|
|
cd70526b93 | ||
|
|
0f92ac0d9b | ||
|
|
eb0ca9c738 | ||
|
|
3233c35836 | ||
|
|
ba2fd7f695 | ||
|
|
c7f7531b11 | ||
|
|
4f57fc45a7 | ||
|
|
ea92dcd2ed | ||
|
|
d51600aba8 | ||
|
|
c3ed2e5e7f | ||
|
|
13b10471ec | ||
|
|
80d1c5f805 | ||
|
|
8079345546 | ||
|
|
4a67afdd3a | ||
|
|
d555546782 | ||
|
|
e53ec868a5 | ||
|
|
872a5fad28 | ||
|
|
400b94a5f2 | ||
|
|
638b8b6b79 | ||
|
|
ca1dd92ab4 | ||
|
|
f8827da73a | ||
|
|
babaa9f1b1 | ||
|
|
0cefd17484 | ||
|
|
974a5ff0f3 | ||
|
|
0ec71b589d | ||
|
|
0cb69e2087 | ||
|
|
1305c55100 | ||
|
|
425c91865b | ||
|
|
a0096a9eb0 | ||
|
|
4058169079 | ||
|
|
ecc42f988f | ||
|
|
1ded560b75 | ||
|
|
f8dc062903 | ||
|
|
2169a379f2 | ||
|
|
03816b192f | ||
|
|
f4ef1217dc | ||
|
|
1c920e3983 | ||
|
|
9daf657db2 | ||
|
|
fdfd1f3d03 | ||
|
|
392be38f10 | ||
|
|
ec1b5d5acb | ||
|
|
316f148663 | ||
|
|
67050be27f | ||
|
|
ff4448ebf4 | ||
|
|
dd061c6d00 | ||
|
|
014fdbb7d8 | ||
|
|
500fe8af95 | ||
|
|
e7383ec670 | ||
|
|
5586c26c12 | ||
|
|
2d7097eb83 | ||
|
|
2aa898da00 | ||
|
|
a01a826bac | ||
|
|
a29ea76024 | ||
|
|
e1c6bfd6cd | ||
|
|
c272c38f6b | ||
|
|
02d3d300fb | ||
|
|
0bd28f9f95 | ||
|
|
d0c2314ebf | ||
|
|
268871c348 | ||
|
|
2e11199764 | ||
|
|
fc6bb22400 |
@@ -15,4 +15,4 @@ pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
# .git
|
||||
24
.env.example
@@ -1,8 +1,10 @@
|
||||
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
|
||||
|
||||
# Required environment variables
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | 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
|
||||
@@ -10,30 +12,47 @@ POSTGRES_PASSWORD=
|
||||
|
||||
# SMTP (optional)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=465 # Port 587 will not work
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||
SMTP_SECURE= # set to "false" to use port 587
|
||||
SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
||||
|
||||
# Switch email features off entirely (optional)
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||
|
||||
# Auth config (optional)
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
|
||||
# API configuration (optional)
|
||||
NEXT_API_BODY_SIZE_LIMIT= # e.g. 50mb (defaults to 1mb)
|
||||
|
||||
# Integration providers (optional)
|
||||
TRELLO_APP_API_KEY=
|
||||
TRELLO_APP_SECRET=
|
||||
|
||||
# Redis (optional - for rate limiting)
|
||||
# If not provided, rate limiting will use in-memory storage
|
||||
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
|
||||
|
||||
# OAuth providers (optional)
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
|
||||
BETTER_AUTH_ALLOWED_DOMAINS=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
DISCORD_CLIENT_ID=
|
||||
@@ -71,4 +90,3 @@ TWITCH_CLIENT_SECRET=
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_CLIENT_SECRET=
|
||||
APPLE_APP_BUNDLE_IDENTIFIER=
|
||||
|
||||
|
||||
1
.github/funding.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [kanbn]
|
||||
40
.github/workflows/docker-publish.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
@@ -74,6 +76,42 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
# Remove 'v' prefix if present
|
||||
VERSION="${VERSION#v}"
|
||||
else
|
||||
# Use git describe and simplify: v1.2.3-5-gabc1234 -> 1.2.3+abc1234
|
||||
GIT_DESCRIBE=$(git describe --tags --always --long 2>/dev/null || echo "")
|
||||
if [[ -n "$GIT_DESCRIBE" ]]; then
|
||||
# Match pattern: v1.2.3-5-gabc1234 (tag-commits-gcommit)
|
||||
if [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]+)$ ]]; then
|
||||
# Format as tag+commit (SemVer build metadata)
|
||||
TAG_VERSION="${BASH_REMATCH[1]}"
|
||||
COMMIT_HASH="${BASH_REMATCH[2]}"
|
||||
VERSION="${TAG_VERSION}+${COMMIT_HASH:0:7}"
|
||||
elif [[ "$GIT_DESCRIBE" =~ ^v?([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||
# Exactly on a tag
|
||||
VERSION="${BASH_REMATCH[1]}"
|
||||
else
|
||||
# Fallback: just commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
else
|
||||
# No tags exist, use commit hash
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
VERSION="${COMMIT_SHA:0:7}"
|
||||
fi
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
@@ -86,6 +124,8 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
7
.gitignore
vendored
@@ -34,6 +34,7 @@ yarn-error.log*
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -48,4 +49,8 @@ dist/
|
||||
docker-compose.override.yml
|
||||
|
||||
# runtime env
|
||||
__ENV.js
|
||||
__ENV.js
|
||||
i18n.cache
|
||||
|
||||
# pgdata
|
||||
/apps/web/pgdata
|
||||
|
||||
4
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"cwd": "${workspaceFolder}/apps/nextjs",
|
||||
"cwd": "${workspaceFolder}/apps/web",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"sourceMaps": true,
|
||||
"sourceMapPathOverrides": {
|
||||
@@ -14,4 +14,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
315
AGENTS.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
Kan is an open-source project management tool (Trello alternative) built with:
|
||||
|
||||
- **Frontend**: Next.js, React, TypeScript, Tailwind CSS
|
||||
- **Backend**: tRPC, Node.js
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **Monorepo**: pnpm workspaces with Turbo
|
||||
- **Auth**: Better Auth
|
||||
- **Internationalization**: Lingui
|
||||
|
||||
## Setup Commands
|
||||
|
||||
- Install deps: `pnpm install`
|
||||
- Start dev server: `pnpm dev`
|
||||
- Create migrations: `cd packages/db && pnpm drizzle-kit generate --name "AddFieldToTable"`
|
||||
- Run database migrations: `pnpm db:migrate`
|
||||
- Run linter: `pnpm lint`
|
||||
- Run type check: `pnpm typecheck`
|
||||
- Format code: `pnpm format:fix`
|
||||
- Extract i18n strings: `pnpm lingui:extract`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `apps/web/` - Next.js web application
|
||||
- `packages/api/` - tRPC API routers
|
||||
- `packages/db/` - Database schema, migrations, and repositories
|
||||
- `packages/auth/` - Authentication package
|
||||
- `packages/shared/` - Shared utilities
|
||||
- `packages/email/` - Email templates and sending
|
||||
- `packages/stripe/` - Stripe integration
|
||||
- `tooling/` - Shared tooling configs (ESLint, Prettier, TypeScript)
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use TypeScript strictly - avoid `any` types
|
||||
- Prefer explicit types over inference when it improves clarity
|
||||
- Use `as const` for literal types when appropriate
|
||||
- Follow existing patterns for type definitions
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: kebab-case for files (e.g., `card-repo.ts`)
|
||||
- **Components**: PascalCase for React components
|
||||
- **Functions**: camelCase for functions
|
||||
- **Constants**: UPPER_SNAKE_CASE for constants
|
||||
- **Types/Interfaces**: PascalCase
|
||||
|
||||
### Database Layer (`packages/db/`)
|
||||
|
||||
- **Schema**: Define schemas in `src/schema/` using Drizzle ORM
|
||||
- **Migrations**: Create migrations with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
||||
- **Repositories**: Put database queries in `src/repository/` files
|
||||
- **Soft Deletes**: Use `deletedAt` timestamp for soft deletion (not hard deletes)
|
||||
- **Index Management**: Cards have `index` fields that must be maintained sequentially per list
|
||||
- **Activity Logging**: Use `card_activity` table to track all card changes
|
||||
|
||||
### API Layer (`packages/api/`)
|
||||
|
||||
- **Routers**: Create tRPC routers in `src/routers/`
|
||||
- **Procedures**: Use `protectedProcedure` for authenticated endpoints, `publicProcedure` for public
|
||||
- **Validation**: Use Zod schemas for input validation
|
||||
- **Error Handling**: Use `TRPCError` with appropriate error codes
|
||||
- **OpenAPI**: Add OpenAPI metadata for all endpoints
|
||||
- **Authorization**: Always check workspace membership with `assertUserInWorkspace`
|
||||
|
||||
### Frontend (`apps/web/`)
|
||||
|
||||
- **Components**: React components in `src/components/`
|
||||
- **Views**: Page-level components in `src/views/`
|
||||
- **Hooks**: Custom hooks in `src/hooks/`
|
||||
- **i18n**: Use `t` template literal for translations (Lingui)
|
||||
- **Styling**: Use Tailwind CSS classes
|
||||
- **State Management**: Use tRPC React Query hooks for server state
|
||||
- **Modals**: Use `useModal` hook for modal management
|
||||
- **Popups**: Use `usePopup` hook for toast notifications
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Cards
|
||||
|
||||
- Cards are the main entity in Kan
|
||||
- Cards belong to Lists, which belong to Boards
|
||||
- Cards have: title, description, labels, members, checklists, comments, attachments, due dates
|
||||
- Cards use soft deletion (`deletedAt` field)
|
||||
- Cards have an `index` field that must be maintained sequentially per list
|
||||
- All card changes are tracked in `card_activity` table
|
||||
|
||||
### Activity Tracking
|
||||
|
||||
- Every significant card change creates an activity record
|
||||
- Activity types include: created, updated (various fields), etc.
|
||||
- Activities are displayed in card activity feeds
|
||||
|
||||
### Workspaces & Boards
|
||||
|
||||
- Users belong to Workspaces
|
||||
- Boards belong to Workspaces
|
||||
- Workspace members have different permission levels
|
||||
- Boards can be public or private
|
||||
|
||||
### Soft Deletion Pattern
|
||||
|
||||
- Entities use `deletedAt` timestamp for soft deletion
|
||||
- Queries filter with `isNull(table.deletedAt)` to exclude deleted items
|
||||
|
||||
## File Locations Reference
|
||||
|
||||
### Database
|
||||
|
||||
- Schema: `packages/db/src/schema/*.ts`
|
||||
- Repositories: `packages/db/src/repository/*.repo.ts`
|
||||
- Migrations: `packages/db/migrations/`
|
||||
|
||||
### API
|
||||
|
||||
- Routers: `packages/api/src/routers/*.ts`
|
||||
- Utils: `packages/api/src/utils/`
|
||||
- Types: `packages/api/src/types/`
|
||||
|
||||
### Frontend
|
||||
|
||||
- Components: `apps/web/src/components/`
|
||||
- Views: `apps/web/src/views/`
|
||||
- Pages: `apps/web/src/pages/`
|
||||
- Hooks: `apps/web/src/hooks/`
|
||||
- Utils: `apps/web/src/utils/`
|
||||
- Locales: `apps/web/src/locales/`
|
||||
|
||||
## Database Patterns
|
||||
|
||||
- **Soft Deletes**: Always filter with `isNull(table.deletedAt)` in queries
|
||||
- **Public IDs**: Use 12-character public IDs (`publicId`) for all user-facing entities
|
||||
- **Internal IDs**: Never expose internal database IDs (e.g., `id`, `cardId`, `listId`) in API responses or URLs - always use `publicId` externally
|
||||
- **Transactions**: Use database transactions for multi-step operations
|
||||
- **Index Management**: When deleting/moving cards, maintain sequential indices
|
||||
- **Activity Tracking**: Create activity records for all significant changes
|
||||
|
||||
## API Patterns
|
||||
|
||||
- **Input Validation**: Always validate inputs with Zod
|
||||
- **Error Messages**: Provide clear, user-friendly error messages
|
||||
- **Optimistic Updates**: Use tRPC's `onMutate` for optimistic UI updates
|
||||
- **Cache Invalidation**: Properly invalidate queries after mutations
|
||||
- **ID Exposure**: Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` for external communication
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### Card Index Management
|
||||
|
||||
When cards are created, moved, or deleted, their indices must be maintained:
|
||||
|
||||
- New cards: Append to end (max index + 1) or insert at position
|
||||
- Moving cards: Adjust indices of affected cards
|
||||
- Deleting cards: Decrement indices of cards after deleted one
|
||||
- Always use transactions for index updates
|
||||
|
||||
### Activity Logging
|
||||
|
||||
Create activity records for:
|
||||
|
||||
- Card creation
|
||||
- Card updates (title, description, list, etc.)
|
||||
- Label/member additions/removals
|
||||
- Comments
|
||||
- Checklists and items
|
||||
- Attachments
|
||||
- Due dates
|
||||
|
||||
### Authorization
|
||||
|
||||
Always check:
|
||||
|
||||
1. User is authenticated
|
||||
2. User has access to workspace
|
||||
3. User has permission for the operation
|
||||
|
||||
Use `assertUserInWorkspace` helper for workspace checks.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
||||
- Provide user-friendly error messages
|
||||
- Log errors appropriately
|
||||
- Show popup notifications for user-facing errors
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Card
|
||||
|
||||
1. Create card in repository with proper index management
|
||||
2. Create `card.created` activity
|
||||
3. Handle label/member relationships if provided
|
||||
4. Return the created card
|
||||
|
||||
### Updating a Card
|
||||
|
||||
1. Validate user has workspace access
|
||||
2. Update card fields
|
||||
3. Create appropriate activity records
|
||||
4. Invalidate relevant queries
|
||||
|
||||
### Querying Cards
|
||||
|
||||
- Always filter by `isNull(cards.deletedAt)` in queries
|
||||
- Include related data (labels, members, checklists) via Drizzle relations
|
||||
- Order by `index` for proper card ordering
|
||||
|
||||
## Adding a New Feature
|
||||
|
||||
1. **Database**: Update schema in `packages/db/src/schema/`
|
||||
2. **Migration**: Create migration with `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`, then run with `pnpm db:migrate`
|
||||
3. **Repository**: Add repository functions in `packages/db/src/repository/`
|
||||
4. **API**: Add tRPC router procedures in `packages/api/src/routers/`
|
||||
5. **Frontend**: Add UI components in `apps/web/src/`
|
||||
6. **i18n**: Add translations for new strings
|
||||
|
||||
## Database Changes
|
||||
|
||||
- Always create migrations (never modify existing migrations)
|
||||
- Create migrations with: `cd packages/db && pnpm drizzle-kit generate --name "MigrationName"`
|
||||
- Run migrations with: `pnpm db:migrate`
|
||||
- Update schema files in `packages/db/src/schema/`
|
||||
- Test migrations on development database first
|
||||
- Update TypeScript types after schema changes
|
||||
- Consider index management for card operations
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- Use tRPC procedures (not REST)
|
||||
- Add OpenAPI metadata for documentation
|
||||
- Validate inputs with Zod
|
||||
- Check workspace permissions
|
||||
- Create activity records for significant changes
|
||||
|
||||
## Frontend Components
|
||||
|
||||
- Use Tailwind for styling
|
||||
- Follow existing component patterns
|
||||
- Use tRPC hooks for data fetching
|
||||
- Implement optimistic updates where appropriate
|
||||
- Add proper loading and error states
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
- Test database operations in transactions that rollback
|
||||
- Test authorization checks
|
||||
- Test index management when moving/deleting cards
|
||||
- Test activity logging
|
||||
- Test UI interactions
|
||||
- Run `pnpm lint` and `pnpm typecheck` before committing
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Use database indexes appropriately
|
||||
- Batch operations when possible
|
||||
- Avoid N+1 queries
|
||||
- Use transactions for related operations
|
||||
- Implement optimistic updates in UI
|
||||
|
||||
## Security
|
||||
|
||||
- Always check workspace membership before operations
|
||||
- Validate all inputs
|
||||
- Never expose internal database IDs (`id`, `cardId`, `listId`, etc.) in API responses, URLs, or frontend code - always use `publicId` externally
|
||||
- Sanitize user input
|
||||
|
||||
## Internationalization
|
||||
|
||||
- All user-facing strings must use `t` template literal
|
||||
- Add translations to locale files in `apps/web/src/locales/`
|
||||
- Run `pnpm lingui:extract` to update translation files
|
||||
- Update locale files for all languages
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use workspace dependencies (`workspace:*`) for internal packages
|
||||
- Keep dependencies up to date
|
||||
- Use catalog for shared dependency versions
|
||||
|
||||
## When Implementing Features
|
||||
|
||||
### Adding New Card Fields
|
||||
|
||||
1. Update schema in `packages/db/src/schema/cards.ts`
|
||||
2. Create migration
|
||||
3. Update repository functions
|
||||
4. Add API endpoints
|
||||
5. Update frontend components
|
||||
6. Add activity tracking if needed
|
||||
|
||||
### Adding New Activity Types
|
||||
|
||||
1. Add to `activityTypes` array in schema
|
||||
2. Create migration to update enum
|
||||
3. Use in activity creation code
|
||||
4. Update activity display components if needed
|
||||
|
||||
## Git & Commits
|
||||
|
||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, etc.
|
||||
- Keep commits focused on single changes
|
||||
- Reference issue numbers when applicable
|
||||
|
||||
## PR Instructions
|
||||
|
||||
- Title format: `feat: description` or `fix: description`
|
||||
- Always run `pnpm lint` and `pnpm typecheck` before committing
|
||||
- Provide clear description of changes
|
||||
- Include screenshots for UI changes
|
||||
- Keep PRs focused on a single feature/fix
|
||||
121
CHANGELOG.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 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
|
||||
76
README.md
@@ -27,14 +27,14 @@
|
||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||
- 💬 **Comments**: Discuss and collaborate with your team
|
||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
||||
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
|
||||
- 🎨 **Templates** : Save time with reusable custom board templates
|
||||
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||
|
||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
## Screenshot 👁️
|
||||
|
||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
|
||||
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/8490104a-cd5d-49de-afc2-152fd8a93119" />
|
||||
|
||||
## Made With 🛠️
|
||||
|
||||
@@ -88,7 +88,7 @@ networks:
|
||||
kan-network:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
kan_postgres_data:
|
||||
```
|
||||
|
||||
2. Start the containers in detached mode:
|
||||
@@ -138,34 +138,48 @@ pnpm dev
|
||||
|
||||
## Environment Variables 🔐
|
||||
|
||||
| Variable | Description | Required | Example |
|
||||
| -------------------------------- | ----------------------------- | ------------------ | --------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | For Email | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | For Email | `re_xxxx` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||
| `REDIS_URL` | Redis connection URL | For rate limiting (optional) | `redis://localhost:6379` or `redis://redis:6379` (Docker) |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `NEXT_API_BODY_SIZE_LIMIT` | Maximum API request body size (defaults to 1mb) | No | `50mb` |
|
||||
| `BETTER_AUTH_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `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` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ mintlify dev
|
||||
|
||||
### Publishing Changes
|
||||
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||
|
||||
#BUMP
|
||||
|
||||
140
apps/docs/guides/self-hosting/introduction.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
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)
|
||||
397
apps/docs/guides/self-hosting/s3.mdx
Normal file
@@ -0,0 +1,397 @@
|
||||
---
|
||||
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: 400 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 433 KiB After Width: | Height: | Size: 133 KiB |
@@ -44,6 +44,18 @@
|
||||
"group": "Get Started",
|
||||
"pages": ["introduction"]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Self-Hosting",
|
||||
"pages": [
|
||||
"guides/self-hosting/introduction",
|
||||
"guides/self-hosting/s3"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Import",
|
||||
"pages": ["imports/trello"]
|
||||
|
||||
@@ -4,8 +4,9 @@ ARG PROJECT=@kan/web
|
||||
|
||||
# 1. Alpine image
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
RUN apk update && \
|
||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true
|
||||
|
||||
# Setup pnpm and turbo on the alpine base
|
||||
FROM alpine AS base
|
||||
@@ -18,48 +19,50 @@ RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
||||
# RUN export VERSION=$(npm run version)
|
||||
|
||||
ARG PROJECT
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# It might be the path to <ROOT> turborepo
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
|
||||
# Generate version from git (fallback if APP_VERSION not provided)
|
||||
RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null || true && \
|
||||
AUTO_VERSION=$(git describe --tags --always --long 2>/dev/null | \
|
||||
sed -E 's/^v?([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+-g([a-f0-9]{7}).*/\1+\2/' | \
|
||||
sed 's/^v//' || \
|
||||
git rev-parse --short HEAD 2>/dev/null | head -c 7 || \
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
|
||||
# Environment to skip .env validation on build
|
||||
ENV CI=true
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile and package.json's of isolated subworkspace
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
ENV CI=true
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
RUN pnpm build --filter=${PROJECT}
|
||||
|
||||
# Copy static files to standalone directory
|
||||
RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
mv apps/web/public apps/web/.next/standalone/ && \
|
||||
mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||
|
||||
# 4. Final image - runner stage to run the application
|
||||
FROM base AS runner
|
||||
@@ -81,5 +84,4 @@ ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["/bin/sh", "./entrypoint.sh"]
|
||||
|
||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||
|
||||
@@ -12,5 +12,12 @@ if [ $? -ne 0 ]; then
|
||||
fi
|
||||
|
||||
echo "\nStarting Next.js application..."
|
||||
# Start the Next.js application from the web app's directory
|
||||
exec node .next/standalone/apps/web/server.js
|
||||
|
||||
# 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
|
||||
13
apps/web/i18n.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
}
|
||||
692
apps/web/i18n.lock
Normal file
@@ -0,0 +1,692 @@
|
||||
version: 1
|
||||
checksums:
|
||||
8033ed03a24d7c4898c90a5365297c20:
|
||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20has%20been%20added%20to%20your%20favorites./singular": 0f752180183c80703b3f9f39dc97195f
|
||||
"%7B0%7D%20has%20been%20removed%20from%20your%20favorites./singular": 26e4b4997ad8a711d0b3e46337799454
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
|
||||
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
||||
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
|
||||
14%20day%20free%20trial/singular: b2f7a80b5184d79ddd56b78d57d28225
|
||||
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
Actions/singular: c46571856723b03262fd33f511116298
|
||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
||||
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
||||
Add%20an%20item.../singular: f8e7993980420698700b07f56b3a38af
|
||||
Add%20checklist/singular: 05d73200b56cfea75fb3a0f94c9bfb74
|
||||
Add%20description...%20(type%20'%2F'%20to%20open%20commands%20or%20'%40'%20to%20mention)/singular: c6eb35a796dd7ac0e3dbc66244b71248
|
||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||
Add%20to%20favorites/singular: 532155acb0a46f2b6c37e15afb3ad21e
|
||||
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
|
||||
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
||||
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
||||
added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2
|
||||
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
|
||||
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
|
||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||
Added%20to%20favorites/singular: 848046ec65d15913112b466c544722eb
|
||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
||||
Adjust%20the%20square%20crop%20to%20fit%20your%20avatar./singular: a4df26bbce6f14c6962fac1324db00a8
|
||||
Admin/singular: 90eb20f1400db82ab874744e47836dc6
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20member%20permission%20overrides%20have%20been%20reset%20to%20their%20role%20defaults./singular: e5c38724a283373506d53afd22b6d096
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Allow%20workspace%20members%20to%20see%20each%20other's%20email%20addresses/singular: 0077436d9f37bfd64f3ae076a5a05040
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
||||
Anyone%20with%20this%20link%20can%20join%20your%20workspace/singular: 2366ed295eb2c03c425559c24cb31606
|
||||
API/singular: 01d9819514e27056dcc69463194b63d2
|
||||
API%20key%20created/singular: 8dbb2b60a719b0d120e774d6666c8c45
|
||||
API%20key%20name/singular: 2d8aeb08b2cce3b750a584bbc5ce6d1d
|
||||
API%20key%20name%20cannot%20exceed%2030%20characters/singular: 3ea1c7d68e1074a75128017b097b727a
|
||||
API%20key%20name%20is%20required/singular: 870970743b14cb56e7fb7b410af0e933
|
||||
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
|
||||
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
|
||||
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
|
||||
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
||||
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20%7B0%7D%3F/singular: e980ce9469e329ddfed14c7da2cb5e52
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
|
||||
assigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
|
||||
Assignees/singular: 96fc0e28a783d80decfd4716a440ac08
|
||||
Attachment%20uploaded/singular: c0233f8c2560f95ac0834f35eb07b9e7
|
||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||
billed%20annually/singular: c782a8085ffee456bfc56c34ccc5e71c
|
||||
billed%20monthly/singular: 793f2728acf06e9f06d6cdc26dfcf0cd
|
||||
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
|
||||
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
|
||||
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
|
||||
Board/singular: 101ff39aab674c033c15ab978d0d8bac
|
||||
Board%20analytics/singular: 336a8c70a91adc49c15266167ee84cc0
|
||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
||||
Board%20sharing%20%26%20invites/singular: c9222fbcfac4e99dcaa859b46c163e55
|
||||
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
|
||||
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
||||
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
||||
Board%20URL%20must%20be%20at%20least%203%20characters%20long/singular: 105f5b4f3b606baf7b5302117022bd68
|
||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
Can%20add%20comments/singular: d7d70b75780156312701f4c5eed1b285
|
||||
Can%20create%20boards/singular: 8538236f8caafd4eaa751b4ec45f1699
|
||||
Can%20create%20cards/singular: dd1cce2d52e0fb261676750bfc46da22
|
||||
Can%20create%20lists/singular: 53c3c1343efff494640b1227e4444de2
|
||||
Can%20delete%20boards/singular: 818ff50f9f21d8ed3741a7933d66b794
|
||||
Can%20delete%20cards/singular: c62309a7e52958aa4bdb477c07d1855d
|
||||
Can%20delete%20comments/singular: 99884fcaf89c47aa33c6ddfd9f94ea5f
|
||||
Can%20delete%20lists/singular: 8d6ff6cd43c6b9fcbc9fc7954d19409e
|
||||
Can%20delete%20workspace/singular: 41bd5a6636500b1a6e04184e6f566198
|
||||
Can%20edit%20boards/singular: 8b77fabfd955d5507b1826dc4a5c1108
|
||||
Can%20edit%20cards/singular: 47e6c92c1056fd6e8b517ba456b3f607
|
||||
Can%20edit%20comments/singular: a0caeec2b2b64e9f371f07dabed5733d
|
||||
Can%20edit%20lists/singular: 1ce5feb15139c881b615edab065b3e12
|
||||
Can%20edit%20member%20roles%20and%20permissions/singular: c364e8e8866111a471f4081db0730049
|
||||
Can%20edit%20workspace/singular: 9768f990844e215c06910fed250da23a
|
||||
Can%20invite%20members/singular: e02e562bcb7f46008d9595e485951413
|
||||
Can%20manage%20workspace%20settings/singular: 27eb18d3be5b3d813996b7d5071e0317
|
||||
Can%20remove%20members/singular: 97a452b0fb4b661eaca7e5b3d5b49dcd
|
||||
Can%20view%20boards/singular: 14977bbbb566f72fc74e17d99bad09bb
|
||||
Can%20view%20cards/singular: 5e728083853948c9d618f2276732d5cd
|
||||
Can%20view%20comments/singular: 76dbbc9ae4bff589d391c67c84ac6470
|
||||
Can%20view%20lists/singular: e36331c4a49f376befc9f0aa42492b01
|
||||
Can%20view%20members/singular: 5b75a467257a1db29d466c0d9ccea3df
|
||||
Can%20view%20workspace/singular: 79a12c55fcd04ea69cbb85b906794e9b
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
|
||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
||||
changed%20the%20due%20date%20to%20%3C0%3E%7BformattedDate%7D%3C%2F0%3E/singular: 32189aa87452ab18f90f02742bfa9910
|
||||
Check%20out%20some%20of%20our%20favorite%20open%20source%20projects./singular: b0d0be4f63c16552b3aa795af29240d1
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
Clear%20all%20custom%20permissions%3F/singular: 31d0962985c83a29558c98a765bb1b16
|
||||
Clear%20any%20custom%20member%20permissions%20so%20that%20all%20members%20only%20inherit%20permissions%20from%20their%20role%20defaults./singular: e493291818059bfd51f2c87b938616c4
|
||||
Clear%20custom%20permissions/singular: 288ce8688fd09c5a01eda5a6ba4bc98a
|
||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
|
||||
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
||||
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
||||
Comments/singular: 502237cd1044491c48494aa60967a985
|
||||
Comments%20%26%20mentions/singular: 1f3a822f4f97a9b07db82d24c4204f75
|
||||
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||
Compare%20our%20features%20to%20see%20why%20Kan%20is%20the%20best%20choice./singular: c186ed37ff27cfc2a1ac11e66b2b30f1
|
||||
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
||||
Configure%20which%20actions%20are%20allowed%20for%20each%20workspace%20role.%20These%20permissions%20apply%20to%20all%20members%20with%20that%20role./singular: 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
|
||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
|
||||
Contact/singular: 9afa39bc47019ee6dec6c74b6273967c
|
||||
Contact%20us/singular: 694a45bc5c96f6f3a27873c1cf5a7707
|
||||
Content%20Creation/singular: 4e6be22ade0188f408471325fe363575
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
|
||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
|
||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
||||
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
|
||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||
Create%20new%20%7B0%7D/singular: 163dbd5cf86d512fadd4c3eaf9709a11
|
||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||
Create%20template/singular: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
|
||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20permissions/singular: 6f1748601979e2e4548877b292b43a20
|
||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||
Deactivate%20invite%20link/singular: 8cf4bf82e153e4dc82d026448cb4dd10
|
||||
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
|
||||
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
|
||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20template/singular: bdf3e8342f581d498b7238cdd4897a4b
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
||||
deleted%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 60f9107197e756487d3f95a1083846b5
|
||||
deleted%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 8b7e270f612f9e91bda7f5cfb853d843
|
||||
Design/singular: 991b75727b6784c1a063a7462b76186d
|
||||
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
|
||||
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
|
||||
Display%20name/singular: 725f331065e9d594d720d07b1ae51a90
|
||||
Display%20name%20cannot%20exceed%20280%20characters/singular: 426ddbcc87590e9d028bdca4e585c549
|
||||
Display%20name%20must%20be%20at%20least%203%20characters%20long/singular: 84178b79845a70937af5e26020403052
|
||||
Display%20name%20updated/singular: 4e27503ca24ef5f009713951ca9e5b97
|
||||
Do%20you%20offer%20a%20free%20plan%3F/singular: 3ef16b0994f0d9ae17067782ca9bed1b
|
||||
Do%20you%20want%20to%20unsubscribe%3F/singular: 8173b82c731ba885939d223eeef2e40b
|
||||
docs/singular: 307bff5678ada918da1f1ec3edc76cac
|
||||
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
|
||||
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
Download%20failed/singular: 142b391cca3e05f2af9617092f7358c5
|
||||
Due%20date/singular: b098b52aff38516c5838c2ac4419f958
|
||||
Due%20next%20month/singular: 223ab6f1211e1dfdf39ea68d25fe380b
|
||||
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
|
||||
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
|
||||
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
Edit%20permissions/singular: 244558dd716491b7ed72ba8ab73aa28f
|
||||
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
|
||||
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
|
||||
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Email%20visibility/singular: 81d41cf573a7109c376d30d905beb596
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
|
||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
||||
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
||||
Enter%20your%20new%20password/singular: c67251e3002b68bc20a7cf5de23e43ac
|
||||
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
||||
Error/singular: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
Error%20Changing%20Password/singular: ebecb5c1b72ba4b063117241f5ba4f2d
|
||||
Error%20creating%20invite%20link/singular: cbedc3f3213dfc4fdc8b7503ae1a5cd6
|
||||
Error%20deactivating%20invite%20link/singular: ccf42cd5aa8481692003e87e836de66c
|
||||
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
|
||||
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
|
||||
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
|
||||
Error%20inviting%20member/singular: 91553b606a6ebed3534cd2e5635ce004
|
||||
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
|
||||
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
|
||||
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
|
||||
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
|
||||
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
|
||||
Error%20upgrading%20subscription/singular: 57cc47581c7faaffb3b1721b6352b576
|
||||
Error%20upgrading%20to%20Pro/singular: 934c6867d9bdbe6099b2c0f5a3107f78
|
||||
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
|
||||
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
|
||||
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
|
||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
||||
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
||||
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||
Fast%20%26%20lightweight/singular: 4f2a80c555a5427b94eef5124aa10775
|
||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
Feature%20breakdown/singular: cae23c2a3fbb21735289571f2e1f60db
|
||||
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
||||
Features/singular: 341ff316a339b106a178f0b8d362951b
|
||||
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
||||
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
|
||||
Filter/singular: 626325a05e4c8800f7ede7012b0cadaf
|
||||
Find%20answers%20to%20common%20questions%20about%20Kan.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: b4c05c9174637f4da2a41ead0d65bc01
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 9fa1c942feb40d6350322b71561df789
|
||||
Free/singular: 0326365539c004f6088656f692602078
|
||||
Free%20Plan/singular: c995acccc1e3cba379786892a5b4d4e5
|
||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
General/singular: b891e8f15579fc5d97bcaf3637f5ae59
|
||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
||||
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
||||
Get%20started%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
|
||||
Get%20started%20on%20Cloud/singular: ed926f526266ad2063283c58e6f4284a
|
||||
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
|
||||
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
|
||||
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
||||
Go%20to%20boards/singular: dc1362de207d67fe84fb0caf60262e73
|
||||
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
|
||||
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
|
||||
Guest/singular: 2aec6d6ebe0d9a1db0a5c8cd5a98b8c3
|
||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
How%20do%20I%20get%20a%20custom%20URL%3F/singular: 5b28fb95969c0dd19e1b9c9ab8a5fb29
|
||||
How%20do%20I%20import%20my%20Trello%20boards%3F/singular: 44b30a727150cb93364af0a10346012a
|
||||
How%20do%20I%20invite%20team%20members%3F/singular: e620622060a06288e86ccda35395db87
|
||||
How%20do%20I%20self-host%3F/singular: ec0b6afc8ad2f581b512c0fd82926b75
|
||||
How%20is%20the%20project%20funded%3F/singular: ba7e936f7b1b5faa47df59a0ef990092
|
||||
I%20acknowledge%20that%20all%20of%20my%20account%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: 2ffc824d0b1f9e883c2e87ed4b8b40b6
|
||||
I%20acknowledge%20that%20all%20of%20the%20workspace%20data%20will%20be%20permanently%20deleted%20and%20want%20to%20proceed./singular: fe1183a7439299210d8ab7379b8f4d01
|
||||
Ideas/singular: 27c24b56895defecd10b0171b39bc93f
|
||||
Ideas%20to%20improve%20this%20page.../singular: 285decc2cc28403fae043d9b4de97a00
|
||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||
Import%20from%20Trello/singular: 9cce591a63763174952ffc11ae829f0c
|
||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
||||
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
|
||||
in/singular: 1f06ad6763244d5e9927853e65cc6274
|
||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||
Intelligent%20search/singular: da688e7cfb2775f719a12df6dd606f6e
|
||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
||||
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
|
||||
Invite%20link%20copied%20to%20clipboard/singular: 6fc055a0ea0ed1aa58c5e0c502efe17f
|
||||
Invite%20links/singular: 36ea61a3193245fa1b57d3392f342aaa
|
||||
Invite%20links%20require%20a%20Team%20or%20Pro%20subscription.%20Please%20upgrade%20your%20workspace./singular: 18db646fc55f99789572c8df0539ee69
|
||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
||||
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
|
||||
Join%20workspace%20%7C%20kan.bn/singular: 97855216a0e00214b2dcf917e93164f2
|
||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
||||
Kan/singular: dd1232a5a28bf47e939d3b8a199bc229
|
||||
Kan%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: d13f4467d3dc0a0786c3843a9b50785d
|
||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||
Keyboard%20Shortcuts/singular: ef00d7494b69def6841620bd6554d040
|
||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
Launch%20offer%3A%20Get%20unlimited%20members%20with%20Pro/singular: 8c1be80f5b2fcc112922282afbe30287
|
||||
Launch%20offer%3A%20unlimited%20seats%20for%20just%20%2429%2Fmonth%20with%20Pro/singular: 235d1cda21811c296afdc0dcc438cc24
|
||||
Learn%20more/singular: e598091d132f890c37a6d4ed94f6d794
|
||||
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
||||
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
||||
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
||||
Light/singular: bb78ea2c6edd8662676c81a8a13ecb93
|
||||
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
|
||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
|
||||
Loading%20permissions.../singular: a5665279d4e439186825057c32d4d976
|
||||
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
|
||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Loved%20by%20teams%20worldwide/singular: 41de5089fef56bb0bfcf7f8649a0cb93
|
||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||
Make%20template/singular: 7a1c63b75cea690b3fda6c3f203e31ff
|
||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
Member/singular: 1606dc30b369856b9dba1fe9aec425d2
|
||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||
monthly%20billing/singular: e228107df80015377112d41f4f155cb3
|
||||
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
|
||||
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
Navigation/singular: 0373afd8238db1c49f4be4fa6cdf5cd3
|
||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
||||
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
|
||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||
New%20label/singular: 890040da52a88c16c249a4ac78980f81
|
||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
||||
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
||||
New%20template/singular: 10dca64383235d1596a010e559b2e35a
|
||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
||||
No%20%7B0%7D/singular: a7a95e1dfdb4396da9e2be0e27f42323
|
||||
No%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
|
||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||
No%20dates/singular: bd0ec82b3b1f4a5fa89d362b71f8141d
|
||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
No%20lists%20have%20been%20created%20yet/singular: f18ee3d7230cc33b68bd17b429d1d442
|
||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||
No%20roles%20found%20for%20this%20workspace%20yet./singular: 2eb502333aaf6c58e8ab23d881e2e357
|
||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||
Open%20command%20menu/singular: cf00563ea1a994e7c36a761d56664acc
|
||||
Open%20keyboard%20shortcuts/singular: 638ae0999fd03d2c611064274859422e
|
||||
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
||||
Open%20Source%20Friends/singular: ffe3d75e5d03b8469c9dcb019dceb164
|
||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
|
||||
Overrides%20cleared/singular: f2e380efbae31a6113cc0cbf0eadf7b0
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
||||
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
|
||||
Password%20is%20required%20to%20sign%20up./singular: 3fa1abbf2e6ac8ac4d18c215191bdbcc
|
||||
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
|
||||
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
Paused/singular: edb1f7b7219e1c9b7aa67159090d6991
|
||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Permission/singular: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
Permissions/singular: 2160be68b1d6b6577e64634e9feba2ed
|
||||
Permissions%20reset/singular: dd4776f04aca858deb95887e807570fc
|
||||
Permissions%20updated/singular: 0df44570b783b8b284610da8627d333d
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||
Price%20per%20user%2Fmonth/singular: ae4b93ad0f9fad454b40f82010372fc2
|
||||
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||
Private/singular: d740d0660e68c61db1853489356030ae
|
||||
Pro%20Plan/singular: 76a09fd843b496610f8cf1dced417b62
|
||||
Pro%20Plan%20%E2%88%9E/singular: a898be9e4610706b9b8146a209e8ba16
|
||||
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
|
||||
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
|
||||
Public/singular: c834f11dab0881632aded3f01d4f8fa9
|
||||
Publishing/singular: d9c8862c0180d9b7bd1272d5dc0d3ac3
|
||||
Question/singular: 0576462ce60d4263d7c482463fcc9547
|
||||
Questions%3F/singular: c7c3a47f8b230ce7a43f3c090f21d683
|
||||
Quick%20Win/singular: a03ff4c0896f14b87455510bab203511
|
||||
Recruitment/singular: 78084ee4954aa22efe9ad7463d2a0c72
|
||||
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
|
||||
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
Remove%20from%20favorites/singular: 74d9cc2d2f3c7a7dc42a7e62c8ee06d5
|
||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
|
||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||
Removed%20from%20favorites/singular: 540609ab686ed67b1cfd7649467b8bc1
|
||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
||||
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
|
||||
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
||||
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Reset%20to%20role%20defaults/singular: fc9ff8ea0503e50da3e9b3e5d8bb75dd
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
Role%20updated/singular: 73606ae9c35101f1bb518961f68559d0
|
||||
Run%20on%20your%20own%20infrastructure/singular: eba804911562b8dbf9d69c3e27f1d708
|
||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
|
||||
Search%20boards%20and%20cards.../singular: 137017b2b3e885c4894ca8c899af5bc2
|
||||
See%20what%20our%20users%20are%20saying%20about%20Kan/singular: e3bf6f95419169f460fb6918a0afc512
|
||||
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
|
||||
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
|
||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Set%20due%20date/singular: 3cb81c4829857556f5d117c9a23f7bcc
|
||||
set%20the%20due%20date/singular: b5d9a1edd988f455041aee9fa7fb3d63
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
|
||||
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
|
||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||
Settings%20%7C%20Permissions/singular: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
|
||||
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
|
||||
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
|
||||
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
|
||||
Sign%20up%20with%20/singular: 103e5d75506e6835b471695d799ff580
|
||||
Simple%20pricing/singular: 5aa2bece93a71947b268498a58062937
|
||||
Simple%2C%20visual%20task%20management%20that%20just%20works.%20Drag%20and%20drop%20cards%2C%20collaborate%20with%20your%20team%2C%20and%20get%20more%20done./singular: f4f138f83017d43dab2882cc08e07bdb
|
||||
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
|
||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||
Start%2014%20day%20free%20trial/singular: 4be2811a8f3455618032ccb59e40658b
|
||||
Subscription%20Required/singular: 05f2b4abfc36def17756a6969983cf86
|
||||
Success/singular: c43827becada6750f7a25890905f38b9
|
||||
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
|
||||
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
|
||||
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
Template/singular: 87c2704ff2033e3c76bc97a45ae57d5c
|
||||
Template%20created/singular: 2c7a515a5d8a9afb26de6365cdaa0c92
|
||||
Template%20created%20successfully/singular: 2750600da57c5cd3c3c072810c8c9604
|
||||
Template%20name%20cannot%20exceed%20100%20characters/singular: 14527c9246e558a085ceee2e8d4e6560
|
||||
Template%20name%20is%20required/singular: 34d674d59c414c2051ef5bc0a62e3c00
|
||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
Testimonials/singular: e958aa3d3743031e2cbfbfd8f1e381f6
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
||||
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
||||
The%20member's%20permissions%20have%20been%20updated./singular: 6ae91be2c5c0504bf521335094c50fa4
|
||||
The%20member's%20role%20has%20been%20updated./singular: e30c30a2beaac7d046c35f628102f83b
|
||||
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
|
||||
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
|
||||
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
|
||||
This%20API%20key%20will%20only%20be%20shown%20once.%20Please%20save%20it%20in%20a%20secure%20location./singular: 7df18d2978d317375f780822f8321c4d
|
||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
||||
This%20member's%20permissions%20have%20been%20reset%20to%20their%20role%20defaults./singular: 730f33b8f1fc002c4f393ccd262b6664
|
||||
This%20will%20remove%20all%20custom%20member%20permissions%20in%20this%20workspace.%20Members%20will%20inherit%20permissions%20only%20from%20their%20roles./singular: 1c989752736fa41ecdd68ea890f16a15
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||
Title/singular: 344e64395eaff6822a57d18623853e1a
|
||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
|
||||
Trello/singular: b5131f6488b5a439d58db3e22d6de45b
|
||||
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
|
||||
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
|
||||
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
|
||||
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
|
||||
Unable%20to%20clear%20overrides/singular: dfeac280858332082ad34d2623a59863
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20template/singular: 7495f1434c9188849bffd015811d5337
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20attachment/singular: 99d0b17b78d44e15f13d876d155f0efc
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
||||
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
|
||||
Unable%20to%20reset%20permissions/singular: fb09d88ff4eb32afb852d733bafd515b
|
||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Unable%20to%20update%20board/singular: 62899897d7bffcb1df3af77d460f0881
|
||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
||||
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
|
||||
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
|
||||
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
||||
Unable%20to%20update%20due%20date/singular: af20d7482cb639a8c8264c681a18abed
|
||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||
Unable%20to%20update%20permissions/singular: 134f20c5f2463167509562b284ef1c61
|
||||
Unable%20to%20update%20role/singular: 4f2240aeff6f7275feb33ca3f608e48c
|
||||
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
|
||||
Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20No%20credit%20card%20required./singular: 8d9733137deb26664d76b55ad561c42c
|
||||
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
|
||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
||||
Unlimited%20workspaces/singular: b504d01997bf908af1d486d9942fed36
|
||||
Unsubscribe/singular: d1a0b07295687d1f08d4df1c411072dd
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||
updated%20the%20due%20date/singular: 7636c200d50fed58a4295ed1e9b5c1e0
|
||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
||||
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
||||
Upgrade%20to%20Pro%20(%2429%2Fmonth)/singular: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
Upload%20failed/singular: 5b7b755f05330164dbfb00096c500ee3
|
||||
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
|
||||
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
|
||||
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
|
||||
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||
User/singular: 61073457a5c3901084b557d065f876be
|
||||
User%20is%20already%20a%20member%20of%20this%20workspace/singular: bc246386f0975f750f2133222bb4735e
|
||||
Video/singular: 8050c90e4289b105a0780f0fdda6ff66
|
||||
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
|
||||
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
|
||||
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
|
||||
View%20only/singular: 21f5f7ba3b49050c4f406113479d6a7a
|
||||
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
|
||||
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
|
||||
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
|
||||
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
|
||||
We%20couldn't%20update%20your%20preferences.%20Please%20try%20again./singular: 69d1d2575f2e73b08ac622bde0a45b4d
|
||||
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
|
||||
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
|
||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
|
||||
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20the%20years%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 2c0790e6654e5460453ad2e523bbd377
|
||||
White%20label/singular: 2570386f73a010b07219e7658aaf9d9b
|
||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
||||
Workspace%20deleted/singular: bc5c267ce3f04de94ff1379cf2ca48a3
|
||||
Workspace%20description/singular: 7159d620b7ef5c5afef734989a5be08b
|
||||
Workspace%20description%20cannot%20exceed%20280%20characters/singular: d0e83b9a0304d7c36cf8c85d98783ce2
|
||||
Workspace%20description%20must%20be%20at%20least%203%20characters%20long/singular: 5ebac407715e6569adc381ebbb4f32d4
|
||||
Workspace%20description%20updated/singular: 19ddb6ee9ead26982a90dee426f3ee1f
|
||||
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
||||
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
||||
Workspace%20name%20cannot%20exceed%2064%20characters/singular: 681f86c9ca31699fc5a3ca3790e5972c
|
||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||
Workspace%20permissions/singular: 72c0202f30e543eb81bf930d85647096
|
||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
||||
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
|
||||
Yearly/singular: 87f43e016c19cb25860f456549a2f431
|
||||
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
|
||||
You%20are%20about%20to%20change%20your%20password./singular: df4783d593505766d78c5bc08d3d48ac
|
||||
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
|
||||
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
|
||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
You%20don't%20have%20permission/singular: 11d928b1993d95d54a95f85f8ae5016d
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
|
||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
||||
You%20need%20to%20be%20an%20admin%20to%20manage%20workspace%20permissions./singular: e2e816f2b7a13b1056d79e7f25088664
|
||||
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||
Your%20file%20has%20been%20uploaded%20successfully./singular: 19d84e934877efc0fef285ff3d6e7849
|
||||
Your%20password%20has%20been%20changed./singular: 8f730f68d52b7efa727aa0784282ebcb
|
||||
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
||||
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
||||
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
|
||||
Your%20unsubscribe%20link%20is%20missing%20a%20token.%20Please%20open%20the%20latest%20email%20and%20try%20again./singular: 3e4cce96bc1197a1caa2b7fb4d3c7e53
|
||||
Your%20workspace%20description%20has%20been%20updated./singular: 98de6f65c567c6654cc0adce3c9d5656
|
||||
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
|
||||
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
|
||||
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
|
||||
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148
|
||||
15
apps/web/lingui.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
path: "src/locales/{locale}/messages",
|
||||
include: ["src"],
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -10,7 +10,10 @@ configureRuntimeEnv();
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
output: "standalone",
|
||||
output:
|
||||
env("NEXT_PUBLIC_USE_STANDALONE_OUTPUT") === "true"
|
||||
? "standalone"
|
||||
: undefined,
|
||||
reactStrictMode: true,
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
@@ -23,28 +26,94 @@ const config = {
|
||||
],
|
||||
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
// temporarily ignore eslint errors during build until we fix all the errors sigh
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
const patterns = [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
const s3Endpoint = env("S3_ENDPOINT");
|
||||
if (s3Endpoint) {
|
||||
try {
|
||||
const url = new URL(s3Endpoint);
|
||||
const hostname = url.hostname;
|
||||
const protocol = url.protocol.replace(":", "");
|
||||
|
||||
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length >= 2) {
|
||||
const rootDomain = parts.slice(-2).join(".");
|
||||
patterns.push({
|
||||
protocol: protocol === "http" ? "http" : "https",
|
||||
hostname: `*.${rootDomain}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If S3_ENDPOINT is not a valid URL, ignore it
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
})(),
|
||||
},
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.svg": {
|
||||
loaders: ["@svgr/webpack"],
|
||||
as: "*.js",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
// instrumentationHook: true,
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/settings",
|
||||
destination: "/settings/account",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm next build",
|
||||
"build": "pnpm with-env next build --turbo",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev | pino-pretty",
|
||||
"dev": "pnpm with-env next dev --turbo",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
"with-env": "dotenv -e ../../.env --",
|
||||
"lingui:extract": "lingui extract",
|
||||
"lingui:compile": "lingui compile --typescript",
|
||||
"lingui:extract:watch": "lingui extract --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
@@ -22,9 +27,16 @@
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@lingui/babel-preset-react": "^2.9.2",
|
||||
"@lingui/conf": "^5.3.2",
|
||||
"@lingui/macro": "^5.3.2",
|
||||
"@lingui/react": "^5.3.2",
|
||||
"@novu/api": "^3.11.0",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-mention": "^3.0.9",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
@@ -34,25 +46,28 @@
|
||||
"@trpc/next": "^11.0.0-rc.660",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.26.2",
|
||||
"geist": "^1.3.1",
|
||||
"next": "^14.2.15",
|
||||
"next-logger": "^5.0.1",
|
||||
"jose": "^6.1.2",
|
||||
"next": "15.5.9",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-js": "^1.254.0",
|
||||
"react": "catalog:react18",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-dom": "catalog:react18",
|
||||
"react-hook-form": "^7.51.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-lottie-player": "^1.5.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -61,6 +76,10 @@
|
||||
"@kan/stripe": "workspace:*",
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/node": "^20.17.7",
|
||||
"@types/react": "catalog:react18",
|
||||
"@types/react-beautiful-dnd": "^13.1.7",
|
||||
@@ -68,10 +87,10 @@
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "catalog:",
|
||||
"jiti": "^1.21.6",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 433 KiB After Width: | Height: | Size: 423 KiB |
BIN
apps/web/public/testimonials/avatars/bobby_computers.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/web/public/testimonials/avatars/bravo68web.jpeg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/web/public/testimonials/avatars/ehb.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/web/public/testimonials/avatars/fox.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/web/public/testimonials/avatars/hanno_braun.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/testimonials/avatars/headless_dev.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
apps/web/public/testimonials/avatars/jan_stgmnn.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/web/public/testimonials/avatars/jrraphael.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/web/public/testimonials/avatars/tasneema_waquar.jpeg
Normal file
|
After Width: | Height: | Size: 18 KiB |
1
apps/web/src/assets/logos/airbus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
1
apps/web/src/assets/logos/couchbase.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
1
apps/web/src/assets/logos/deloitte.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
apps/web/src/assets/logos/fast_company.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
apps/web/src/assets/logos/lego.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
apps/web/src/assets/logos/linkedin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
apps/web/src/assets/logos/sana.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
apps/web/src/assets/logos/wakam.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,8 +1,11 @@
|
||||
import type { SocialProvider } from "better-auth/social-providers";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
FaApple,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
FaGoogle,
|
||||
FaLinkedin,
|
||||
FaMicrosoft,
|
||||
FaOpenid,
|
||||
FaReddit,
|
||||
FaSpotify,
|
||||
FaTiktok,
|
||||
@@ -31,6 +35,8 @@ import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
type AuthProvider = SocialProvider | "oidc";
|
||||
|
||||
interface FormValues {
|
||||
name?: string;
|
||||
email: string;
|
||||
@@ -139,16 +145,39 @@ const availableSocialProviders = {
|
||||
name: "Zoom",
|
||||
icon: SiZoom,
|
||||
},
|
||||
oidc: {
|
||||
id: "oidc",
|
||||
name: "OIDC",
|
||||
icon: FaOpenid,
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | SocialProvider>(null);
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
useState<null | AuthProvider>(null);
|
||||
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
|
||||
const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false);
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const { showPopup } = usePopup();
|
||||
const oidcProviderName = "OIDC";
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
const credentialsAllowed =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const emailSendingEnabled =
|
||||
env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true";
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
setIsCloudEnv(isCloudEnv);
|
||||
setIsEmailSendingEnabled(emailSendingEnabled);
|
||||
setIsCredentialsEnabled(credentialsAllowed);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -166,7 +195,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
|
||||
const handleLoginWithEmail = async (
|
||||
email: string,
|
||||
password?: string,
|
||||
password?: string | null,
|
||||
name?: string,
|
||||
) => {
|
||||
setIsLoginWithEmailPending(true);
|
||||
@@ -178,13 +207,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: "Success",
|
||||
message: "You have been signed up successfully.",
|
||||
header: t`Success`,
|
||||
message: t`You have been signed up successfully.`,
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
@@ -195,13 +224,13 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
showPopup({
|
||||
header: "Success",
|
||||
message: "You have been logged in successfully.",
|
||||
header: t`Success`,
|
||||
message: t`You have been logged in successfully.`,
|
||||
icon: "success",
|
||||
}),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
@@ -209,44 +238,103 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
// Only allow magic link if email sending is enabled and not in sign up mode
|
||||
if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Provide a clear error feedback when password omitted but magic link unavailable
|
||||
setLoginError(
|
||||
isSignUp
|
||||
? t`Password is required to sign up.`
|
||||
: t`Password is required to login.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
};
|
||||
|
||||
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||
const handleLoginWithProvider = async (provider: AuthProvider) => {
|
||||
setIsLoginWithProviderPending(provider);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
let error;
|
||||
if (provider === "oidc") {
|
||||
// Use oauth2 signin for OIDC provider
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId: "oidc",
|
||||
callbackURL,
|
||||
});
|
||||
error = result.error;
|
||||
} else {
|
||||
// Use social signin for traditional social providers
|
||||
const result = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL,
|
||||
});
|
||||
error = result.error;
|
||||
}
|
||||
|
||||
setIsLoginWithProviderPending(null);
|
||||
|
||||
if (error) {
|
||||
setLoginError(
|
||||
`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
||||
t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||
// Treat empty password string as undefined to trigger magic link path
|
||||
const sanitizedPassword = values.password?.trim()
|
||||
? values.password
|
||||
: undefined;
|
||||
await handleLoginWithEmail(values.email, sanitizedPassword, values.name);
|
||||
};
|
||||
|
||||
const password = watch("password");
|
||||
|
||||
const isMagicLinkAvailable = useMemo(() => {
|
||||
return isCloudEnv || (isEmailSendingEnabled && !isSignUp);
|
||||
}, [isCloudEnv, isEmailSendingEnabled, isSignUp]);
|
||||
|
||||
// Determine if we should operate in magic link mode for current form state (login only)
|
||||
const isMagicLinkMode = useMemo(() => {
|
||||
// Magic link only viable when email sending enabled AND not sign up.
|
||||
if (!isEmailSendingEnabled || isSignUp) return false;
|
||||
// If credentials disabled we always default to magic link.
|
||||
if (!isCredentialsEnabled) return true;
|
||||
// Credentials enabled: user chooses magic link by leaving password blank.
|
||||
return !password;
|
||||
}, [isEmailSendingEnabled, isSignUp, isCredentialsEnabled, password]);
|
||||
|
||||
// Auto-focus password field when an error indicates it's required
|
||||
useEffect(() => {
|
||||
if (!isCredentialsEnabled) return;
|
||||
// Focus when: sign up and missing password; login error requiring password; validation error on password.
|
||||
const pwdEmpty = (password ?? "").length === 0;
|
||||
let needsPassword = false;
|
||||
if (isSignUp && pwdEmpty) {
|
||||
needsPassword = true;
|
||||
} else if (loginError?.toLowerCase().includes("password")) {
|
||||
needsPassword = true;
|
||||
} else if (errors.password) {
|
||||
needsPassword = true;
|
||||
}
|
||||
if (needsPassword && passwordRef.current) {
|
||||
passwordRef.current.focus();
|
||||
}
|
||||
}, [isSignUp, password, loginError, errors.password, isCredentialsEnabled]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{socialProviders?.length !== 0 && (
|
||||
@@ -257,24 +345,37 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
key={key}
|
||||
onClick={() => handleLoginWithProvider(key as AuthProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with {provider.name}
|
||||
<Trans>
|
||||
Continue with{" "}
|
||||
{key === "oidc" ? oidcProviderName : provider.name}
|
||||
</Trans>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{socialProviders?.length !== 0 && (
|
||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||
{t`No authentication methods are currently available`}
|
||||
</span>
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
or
|
||||
{t`or`}
|
||||
</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
@@ -284,57 +385,63 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
<div>
|
||||
<Input
|
||||
{...register("name", { required: true })}
|
||||
placeholder="Enter your name"
|
||||
placeholder={t`Enter your name`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid name
|
||||
{t`Please enter a valid name`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid password
|
||||
</p>
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<>
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
fullWidth
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ const Avatar = ({
|
||||
icon?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const initials = name
|
||||
const initials = name?.trim()
|
||||
? getInitialsFromName(name)
|
||||
: inferInitialsFromEmail(email);
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
isLoading?: boolean;
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
href?: string;
|
||||
fullWidth?: boolean;
|
||||
openInNewTab?: boolean;
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
@@ -24,13 +25,24 @@ const Button = ({
|
||||
href,
|
||||
fullWidth,
|
||||
openInNewTab,
|
||||
iconOnly,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const classes = twMerge(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
|
||||
size === "xs" && "text-xs px-2 py-1",
|
||||
size === "sm" && "text-xs",
|
||||
size === "lg" && "py-[0.65rem]",
|
||||
fullWidth && "w-full",
|
||||
iconOnly && "p-0",
|
||||
iconOnly &&
|
||||
(size === "xs"
|
||||
? "h-6 w-6"
|
||||
: size === "sm"
|
||||
? "h-8 w-8"
|
||||
: size === "lg"
|
||||
? "h-10 w-10"
|
||||
: "h-9 w-9"),
|
||||
variant === "primary" &&
|
||||
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
||||
variant === "secondary" &&
|
||||
@@ -46,19 +58,60 @@ const Button = ({
|
||||
<span className="relative flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<span className="absolute">
|
||||
<LoadingSpinner size={size} />
|
||||
<LoadingSpinner size={size === "xs" ? "sm" : size} />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{iconLeft && <span className="mr-2">{iconLeft}</span>}
|
||||
{children}
|
||||
{iconRight && <span className="ml-1">{iconRight}</span>}
|
||||
</div>
|
||||
{iconOnly ? (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{iconLeft ?? iconRight}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
fullWidth
|
||||
? "grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2"
|
||||
: "flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{fullWidth && !iconLeft && iconRight && (
|
||||
<span className="col-start-1 opacity-0">{iconRight}</span>
|
||||
)}
|
||||
{iconLeft && (
|
||||
<span
|
||||
className={twMerge(
|
||||
fullWidth ? "col-start-1 justify-self-start" : "mr-2",
|
||||
)}
|
||||
>
|
||||
{iconLeft}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={twMerge(
|
||||
fullWidth ? "col-start-2 justify-self-center text-center" : "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{iconRight && (
|
||||
<span
|
||||
className={twMerge(
|
||||
fullWidth ? "col-start-3 justify-self-end" : "ml-1",
|
||||
)}
|
||||
>
|
||||
{iconRight}
|
||||
</span>
|
||||
)}
|
||||
{fullWidth && !iconRight && iconLeft && (
|
||||
<span className="col-start-3 opacity-0">{iconLeft}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface CheckboxDropdownProps {
|
||||
handleEdit?: (key: string) => void;
|
||||
handleCreate?: () => void;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function CheckboxDropdown({
|
||||
@@ -44,6 +45,7 @@ export default function CheckboxDropdown({
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
asChild = true,
|
||||
disabled = false,
|
||||
}: CheckboxDropdownProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
|
||||
@@ -55,53 +57,67 @@ export default function CheckboxDropdown({
|
||||
|
||||
const renderMenuItems = (items: Item[], groupKey: string | null) => (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() =>
|
||||
handleSelect(groupKey, { key: item.key, value: item.value })
|
||||
}
|
||||
checked={item.selected}
|
||||
/>
|
||||
{item.leftIcon && (
|
||||
<span className="ml-3 flex items-center">{item.leftIcon}</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-[12px] text-dark-900"
|
||||
>
|
||||
{item.value}
|
||||
</label>
|
||||
{handleEdit && (
|
||||
<button
|
||||
className="invisible ml-auto group-hover:visible"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEdit(item.key);
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() =>
|
||||
handleSelect(groupKey, { key: item.key, value: item.value })
|
||||
}
|
||||
checked={item.selected}
|
||||
/>
|
||||
{item.leftIcon && (
|
||||
<span className="ml-3 flex items-center">{item.leftIcon}</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-[12px] text-dark-900"
|
||||
>
|
||||
{item.value}
|
||||
</label>
|
||||
{handleEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="invisible ml-auto group-hover:visible"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleEdit(item.key);
|
||||
}}
|
||||
>
|
||||
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))
|
||||
) : (
|
||||
!handleCreate && (
|
||||
<div className="flex items-center p-2 text-[12px] text-dark-900">
|
||||
No items
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
{handleCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={() => handleCreate()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
>
|
||||
<HiMiniPlus size={20} className="pr-1.5" />
|
||||
{createNewItemLabel}
|
||||
@@ -118,7 +134,8 @@ export default function CheckboxDropdown({
|
||||
<>
|
||||
<Menu.Button
|
||||
as={asChild ? "div" : undefined}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||
disabled={disabled}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none disabled:cursor-not-allowed"
|
||||
>
|
||||
{children}
|
||||
</Menu.Button>
|
||||
|
||||
58
apps/web/src/components/CircularProgress.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
193
apps/web/src/components/CommandPallette.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
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={() => {
|
||||
console.log("clicked", url);
|
||||
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,11 +1,108 @@
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
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 FeedbackButton from "./FeedbackButton";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = api.user.getUser.useQuery();
|
||||
interface DashboardProps {
|
||||
children: React.ReactNode;
|
||||
rightPanel?: React.ReactNode;
|
||||
hasRightPanel?: boolean;
|
||||
}
|
||||
|
||||
export function getDashboardLayout(
|
||||
page: React.ReactElement,
|
||||
rightPanel?: React.ReactNode,
|
||||
hasRightPanel = false,
|
||||
) {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Dashboard rightPanel={rightPanel} hasRightPanel={hasRightPanel}>
|
||||
{page}
|
||||
</Dashboard>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
children,
|
||||
rightPanel,
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!session?.user,
|
||||
},
|
||||
);
|
||||
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
|
||||
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) {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -13,26 +110,90 @@ export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
html {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 320px;
|
||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#1c1c1c"};
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
||||
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
|
||||
<div className="my-auto flex w-full items-center justify-between">
|
||||
<Link href="/">
|
||||
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<FeedbackButton />
|
||||
</div>
|
||||
<div className="relative flex h-screen flex-col bg-light-50 dark:bg-dark-50 md:bg-light-100 md:p-3 md:dark:bg-dark-100">
|
||||
{/* Mobile Header */}
|
||||
<div className="flex h-12 items-center justify-between border-b border-light-300 bg-light-50 px-3 dark:border-dark-300 dark:bg-dark-50 md:hidden">
|
||||
<button
|
||||
ref={sideNavButtonRef}
|
||||
onClick={toggleSideNav}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{isSideNavOpen ? (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasRightPanel && (
|
||||
<button
|
||||
ref={rightPanelButtonRef}
|
||||
onClick={toggleRightPanel}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{isRightPanelOpen ? (
|
||||
<TbLayoutSidebarRightCollapse
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarRightExpand
|
||||
size={20}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full">
|
||||
<SideNavigation
|
||||
user={{ email: data?.email, image: data?.image }}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="w-full overflow-hidden">{props.children}</div>
|
||||
<div className="flex h-[calc(100dvh-4.5rem)] min-h-0 w-full md:h-[calc(100dvh-1.5rem)]">
|
||||
<div
|
||||
ref={sideNavRef}
|
||||
className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `}
|
||||
>
|
||||
<SideNavigation
|
||||
user={{
|
||||
displayName: user?.name ?? session?.user.name,
|
||||
email: user?.email ?? session?.user.email ?? "",
|
||||
image: user?.image ?? undefined,
|
||||
}}
|
||||
isLoading={sessionLoading || userLoading}
|
||||
onCloseSideNav={closeSideNav}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full min-h-0 w-full overflow-hidden md:rounded-lg md:border md:border-light-300 md:bg-light-50 md:dark:border-dark-300 md:dark:bg-dark-50">
|
||||
<div className="relative flex h-full min-h-0 w-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-y-auto">{children}</div>
|
||||
|
||||
{/* Mobile Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className={`fixed right-0 top-12 z-40 h-[calc(100dvh-3rem)] w-80 transform border-l border-light-300 bg-light-200 transition-transform duration-300 ease-in-out dark:border-dark-300 dark:bg-dark-100 md:hidden ${
|
||||
isRightPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full">{rightPanel}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop Right Panel */}
|
||||
{hasRightPanel && rightPanel && (
|
||||
<div className="hidden md:block">{rightPanel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
136
apps/web/src/components/DateSelector.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
addMonths,
|
||||
eachDayOfInterval,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameDay,
|
||||
isToday,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subMonths,
|
||||
} from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface DateSelectorProps {
|
||||
selectedDate?: Date | null;
|
||||
onDateSelect?: (date: Date | undefined) => void;
|
||||
}
|
||||
|
||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||
});
|
||||
|
||||
const monthName = format(currentMonth, "MMMM");
|
||||
const year = format(currentMonth, "yyyy");
|
||||
|
||||
const dayHeaders = useMemo(() => {
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
||||
return eachDayOfInterval({
|
||||
start: weekStart,
|
||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||
}, []);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
||||
|
||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||
(date) => {
|
||||
const dateString = format(date, "yyyy-MM-dd");
|
||||
return {
|
||||
date: dateString,
|
||||
isToday: isToday(date),
|
||||
isSelected: selectedDate ? isSameDay(date, selectedDate) : false,
|
||||
isCurrentMonth: date >= monthStart && date <= monthEnd,
|
||||
dateObj: date,
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [currentMonth, selectedDate]);
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setCurrentMonth(subMonths(currentMonth, 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentMonth(addMonths(currentMonth, 1));
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// If clicking the same date that's already selected, unselect it
|
||||
if (selectedDate && isSameDay(date, selectedDate)) {
|
||||
onDateSelect?.(undefined);
|
||||
} else {
|
||||
onDateSelect?.(date);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[250px] p-4">
|
||||
<div className="flex items-center text-light-1000 dark:text-dark-1000">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreviousMonth}
|
||||
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
||||
>
|
||||
<span className="sr-only">Previous month</span>
|
||||
<HiChevronLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1 text-center text-sm font-semibold">
|
||||
{monthName} {year}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextMonth}
|
||||
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
||||
>
|
||||
<span className="sr-only">Next month</span>
|
||||
<HiChevronRight aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-7 text-center text-xs/6 text-light-950 dark:text-dark-950">
|
||||
{dayHeaders.map((day, index) => (
|
||||
<div key={index}>{day}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="isolate mt-2 grid grid-cols-7 text-sm">
|
||||
{days.map((day) => (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={(e) => handleDateClick(day.dateObj, e)}
|
||||
className={twMerge(
|
||||
"flex aspect-square items-center justify-center rounded-lg focus:z-10",
|
||||
day.isSelected
|
||||
? "bg-light-1000 hover:bg-light-1000 dark:bg-dark-1000 dark:hover:bg-dark-1000"
|
||||
: "bg-transparent hover:bg-light-200 dark:bg-transparent dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
<time
|
||||
dateTime={day.date}
|
||||
className={twMerge(
|
||||
"mx-auto flex size-7 items-center justify-center rounded-full text-light-900 dark:text-dark-900",
|
||||
day.isCurrentMonth
|
||||
? "text-light-900 dark:text-dark-900"
|
||||
: "text-light-700 dark:text-dark-600",
|
||||
day.isSelected && "text-light-50 dark:text-dark-50",
|
||||
)}
|
||||
>
|
||||
{day.date.split("-").pop()?.replace(/^0/, "")}
|
||||
</time>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateSelector;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -10,21 +12,23 @@ export function DeleteLabelConfirmation({
|
||||
labelPublicId: string;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { closeModal, closeModals } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const deleteLabelMutation = api.label.delete.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
closeModals(2);
|
||||
},
|
||||
onError: () =>
|
||||
showPopup({
|
||||
header: "Error deleting label",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Error deleting label`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const handleDeleteLabel = () => {
|
||||
closeModal();
|
||||
deleteLabelMutation.mutate({
|
||||
labelPublicId,
|
||||
});
|
||||
@@ -34,17 +38,17 @@ export function DeleteLabelConfirmation({
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
Are you sure you want to delete this label?
|
||||
{t`Are you sure you want to delete this label?`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"This action can't be undone."}
|
||||
{t`This action can't be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteLabel}>Delete</Button>
|
||||
<Button onClick={handleDeleteLabel}>{t`Delete`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Dropdown({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||
items: { label: string; action?: () => void; icon?: React.ReactNode; disabled?: boolean }[];
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -15,7 +15,7 @@ export default function Dropdown({
|
||||
<div>
|
||||
<Menu.Button
|
||||
disabled={disabled}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 focus:outline-none dark:hover:bg-dark-200"
|
||||
>
|
||||
{children}
|
||||
</Menu.Button>
|
||||
@@ -30,13 +30,14 @@ export default function Dropdown({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-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">
|
||||
<Menu.Items className="absolute right-0 z-[100] isolate mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.label}>
|
||||
<Menu.Item key={item.label} disabled={item.disabled}>
|
||||
<button
|
||||
onClick={item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
disabled={item.disabled ?? !item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Range as TiptapRange } from "@tiptap/core";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
@@ -5,6 +6,9 @@ import type {
|
||||
} from "@tiptap/suggestion";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import {
|
||||
BubbleMenu,
|
||||
@@ -15,7 +19,13 @@ import {
|
||||
} from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
HiH1,
|
||||
HiH2,
|
||||
@@ -31,6 +41,11 @@ import {
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import tippy from "tippy.js";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Avatar from "./Avatar";
|
||||
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -43,7 +58,7 @@ declare module "@tiptap/core" {
|
||||
export interface SlashCommandItem {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
command?: (props: { editor: TiptapEditor; range: Range }) => void;
|
||||
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -66,6 +81,16 @@ export interface RenderSuggestionsProps {
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceMember {
|
||||
publicId: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const CommandsList = forwardRef<
|
||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||
{
|
||||
@@ -179,6 +204,131 @@ const RenderSuggestions = () => {
|
||||
};
|
||||
};
|
||||
|
||||
interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
const MentionList = forwardRef<
|
||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||
{
|
||||
items: MentionItem[];
|
||||
command: (item: MentionItem) => void;
|
||||
}
|
||||
>(({ items, command }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const item = items[selectedIndex];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => command(item)}
|
||||
className={twMerge(
|
||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={item.label}
|
||||
imageUrl={item.image ? getAvatarUrl(item.image) : undefined}
|
||||
email={item.label}
|
||||
/>
|
||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-start p-2">
|
||||
<span className="text-[12px] text-dark-900 dark:text-dark-1000">
|
||||
No results
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MentionList.displayName = "MentionList";
|
||||
|
||||
const renderMentionSuggestions = () => {
|
||||
let reactRenderer: ReactRenderer;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props: any) => {
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate(props: any) {
|
||||
reactRenderer.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
||||
},
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(
|
||||
reactRenderer.ref as {
|
||||
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
|
||||
}
|
||||
).onKeyDown?.(props) ?? false
|
||||
);
|
||||
},
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
reactRenderer.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
||||
name: "slash-commands",
|
||||
addOptions() {
|
||||
@@ -282,11 +432,15 @@ export default function Editor({
|
||||
onChange,
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -294,10 +448,21 @@ export default function Editor({
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: "Add description... (type '/' to open commands)",
|
||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: CommandItems,
|
||||
@@ -308,6 +473,48 @@ export default function Editor({
|
||||
char: "/",
|
||||
},
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const all: MentionItem[] = workspaceMembers.map(
|
||||
(member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}),
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(u) =>
|
||||
u.label &&
|
||||
typeof u.label === "string" &&
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
},
|
||||
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}`;
|
||||
},
|
||||
}),
|
||||
...(enableYouTubeEmbed ? [YouTubeNode] : []),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||
@@ -331,14 +538,23 @@ export default function Editor({
|
||||
editable: !readOnly,
|
||||
injectCSS: false,
|
||||
},
|
||||
[content],
|
||||
[], // creating the editor only once
|
||||
);
|
||||
|
||||
// this will sync external content changes without re-creating the editor instance
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const currentHTML = editor.getHTML();
|
||||
const safeContent = content ?? "";
|
||||
if (safeContent !== currentHTML) {
|
||||
editor.commands.setContent(safeContent, false);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<style jsx global>{`
|
||||
.tiptap p.is-empty::before {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
@@ -347,11 +563,22 @@ export default function Editor({
|
||||
.tiptap p {
|
||||
margin: 0 0 1rem 0 !important;
|
||||
}
|
||||
.tiptap .mention {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
color: rgb(59, 130, 246);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tiptap [data-youtube] {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
`}</style>
|
||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-dark-800 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_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-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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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;
|
||||
122
apps/web/src/components/FeedbackModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="relative flex">
|
||||
{prefix && (
|
||||
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base 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-sm dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
|
||||
@@ -31,7 +32,7 @@ export function LabelForm({
|
||||
refetch: () => void;
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
const { closeModal, entityId, openModal } = useModal();
|
||||
const { closeModal, entityId, openModal, setModalState } = useModal();
|
||||
|
||||
const label = api.label.byPublicId.useQuery(
|
||||
{
|
||||
@@ -56,18 +57,23 @@ export function LabelForm({
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
|
||||
const createLabel = api.label.create.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (newLabel) => {
|
||||
const currentColourIndex = colours.findIndex(
|
||||
(c) => c.code === watch("colour").code,
|
||||
);
|
||||
try {
|
||||
refetch();
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
colour: colours[(currentColourIndex + 1) % colours.length],
|
||||
isCreateAnotherEnabled,
|
||||
});
|
||||
setModalState("NEW_LABEL_CREATED", newLabel.publicId);
|
||||
if (!isCreateAnotherEnabled) {
|
||||
closeModal();
|
||||
} else {
|
||||
const newFormState = {
|
||||
name: "",
|
||||
colour: colours[(currentColourIndex + 1) % colours.length],
|
||||
isCreateAnotherEnabled,
|
||||
};
|
||||
reset(newFormState);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
@@ -78,10 +84,6 @@ export function LabelForm({
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
colour: colours[0],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -114,7 +116,7 @@ export function LabelForm({
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-medium">
|
||||
{isEdit ? "Edit label" : "New label"}
|
||||
{isEdit ? t`Edit label` : t`New label`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -130,7 +132,7 @@ export function LabelForm({
|
||||
|
||||
<Input
|
||||
id="label-name"
|
||||
placeholder="Name"
|
||||
placeholder={t`Name`}
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -208,7 +210,7 @@ export function LabelForm({
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isEdit && (
|
||||
<Toggle
|
||||
label="Create another"
|
||||
label={t`Create another`}
|
||||
isChecked={!!isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||
@@ -219,17 +221,21 @@ export function LabelForm({
|
||||
<div className="space-x-2">
|
||||
{isEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_LABEL", entityId)}
|
||||
>
|
||||
Delete
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||
disabled={
|
||||
!watch("name")
|
||||
}
|
||||
>
|
||||
{isEdit ? "Update label" : "Create label"}
|
||||
{isEdit ? t`Update label` : t`Create label`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
26
apps/web/src/components/LanguageSelector.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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 });
|
||||
|
||||
type IconProps = {
|
||||
interface IconProps {
|
||||
isPlaying: boolean;
|
||||
index: number;
|
||||
json: object;
|
||||
};
|
||||
}
|
||||
|
||||
const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||
return (
|
||||
@@ -15,7 +15,7 @@ const Icon: React.FC<IconProps> = ({ isPlaying, index, json }) => {
|
||||
animationData={json}
|
||||
play={isPlaying}
|
||||
loop={false}
|
||||
style={{ width: 20, height: 20, fill: "white" }}
|
||||
style={{ width: 18, height: 18, fill: "white" }}
|
||||
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,161 @@
|
||||
import { useEffect } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import {
|
||||
HiBolt,
|
||||
HiCheck,
|
||||
HiCheckBadge,
|
||||
HiInformationCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
}
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Workspace name is required` })
|
||||
.max(64, { message: t`Workspace name cannot exceed 64 characters` }),
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(24, { message: t`URL cannot exceed 24 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() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { switchWorkspace } = useWorkspace();
|
||||
const { register, handleSubmit } = useForm<FormValues>();
|
||||
const { switchWorkspace, availableWorkspaces } = useWorkspace();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
trigger,
|
||||
clearErrors,
|
||||
setValue,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
},
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
||||
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
|
||||
const slug = watch("slug");
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const isTyping = slug !== debouncedSlug;
|
||||
|
||||
// Pro toggle state management
|
||||
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
|
||||
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
|
||||
|
||||
// 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({
|
||||
onSuccess: (values) => {
|
||||
onSuccess: async (values, variables) => {
|
||||
if (values.publicId && values.name) {
|
||||
void utils.workspace.all.invalidate();
|
||||
switchWorkspace({
|
||||
publicId: values.publicId,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/stripe/create_checkout_session",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: slug || undefined,
|
||||
workspacePublicId: values.publicId,
|
||||
cancelUrl: "/settings/workspace?upgrade=pro",
|
||||
successUrl: "/boards",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const url = (data as { url: string }).url;
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return; // Don't close modal if redirecting to checkout
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
showPopup({
|
||||
header: t`Error upgrading to Pro`,
|
||||
message: t`Workspace created successfully. You can upgrade later in settings.`,
|
||||
icon: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to create workspace",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Unable to create workspace`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
@@ -47,22 +167,87 @@ export function NewWorkspaceForm() {
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
|
||||
|
||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudEnv &&
|
||||
!checkWorkspaceSlugAvailability.isPending &&
|
||||
isValidSlug
|
||||
) {
|
||||
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
|
||||
setShouldShowBenefits(isAvailable);
|
||||
|
||||
// Automatically enable Pro toggle when slug is available
|
||||
if (isAvailable) {
|
||||
setIsProToggleEnabled(true);
|
||||
setLastAvailableSlug(slug);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isValidSlug,
|
||||
isWorkspaceSlugAvailable?.isAvailable,
|
||||
checkWorkspaceSlugAvailability.isPending,
|
||||
slug,
|
||||
isCloudEnv,
|
||||
]);
|
||||
|
||||
// Reset benefits when slug becomes invalid
|
||||
useEffect(() => {
|
||||
if (!isValidSlug) {
|
||||
setShouldShowBenefits(false);
|
||||
}
|
||||
}, [isValidSlug]);
|
||||
|
||||
// Handle Pro toggle changes
|
||||
const handleProToggleChange = () => {
|
||||
if (isProToggleEnabled) {
|
||||
// Turning off: clear the slug and disable Pro
|
||||
setValue("slug", "");
|
||||
setIsProToggleEnabled(false);
|
||||
} else {
|
||||
// Turning on: restore the last available slug if we have one
|
||||
if (lastAvailableSlug) {
|
||||
setValue("slug", lastAvailableSlug);
|
||||
}
|
||||
setIsProToggleEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const showProBenefits = isProToggleEnabled;
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
// Don't submit if slug is provided but not available
|
||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
createWorkspace.mutate({
|
||||
name: values.name,
|
||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const isSlugAvailable =
|
||||
isValidSlug &&
|
||||
isWorkspaceSlugAvailable?.isAvailable &&
|
||||
!isWorkspaceSlugAvailable?.isReserved;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
New workspace
|
||||
{t`New workspace`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
className={twMerge(
|
||||
"rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300",
|
||||
!hasAvailableWorkspaces && "invisible",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
@@ -74,8 +259,9 @@ export function NewWorkspaceForm() {
|
||||
|
||||
<Input
|
||||
id="workspace-name"
|
||||
placeholder="Workspace name"
|
||||
placeholder={t`Workspace name`}
|
||||
{...register("name")}
|
||||
errorMessage={errors.name?.message}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -83,11 +269,122 @@ 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_KAN_ENV") === "cloud"
|
||||
? "kan.bn/"
|
||||
: `${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)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{slug && slug.length >= 3 && shouldShowBenefits && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<HiInformationCircle className="h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Custom URLs require upgrading to a Pro plan`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProBenefits && (
|
||||
<div className="mt-6">
|
||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Unlimited members`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom workspace URL`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Board analytics`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
||||
{t`Coming soon`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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={twMerge(
|
||||
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
|
||||
!showProBenefits && "mt-12",
|
||||
)}
|
||||
>
|
||||
{/* Pro Toggle - only show in cloud environment */}
|
||||
{isCloudEnv && (
|
||||
<Toggle
|
||||
isChecked={isProToggleEnabled}
|
||||
onChange={handleProToggleChange}
|
||||
label={t`Upgrade to Pro ($29/month)`}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Button type="submit" isLoading={createWorkspace.isPending}>
|
||||
Create workspace
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createWorkspace.isPending}
|
||||
disabled={
|
||||
createWorkspace.isPending ||
|
||||
(!!slug &&
|
||||
(checkWorkspaceSlugAvailability.isPending ||
|
||||
isWorkspaceSlugAvailable?.isAvailable === false ||
|
||||
isTyping))
|
||||
}
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,10 @@ export const PageHead = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ const PatternedBackground = () => (
|
||||
<svg className="h-full w-full">
|
||||
<pattern
|
||||
id="pattern"
|
||||
x="0.034759358288862785"
|
||||
y="3.335370511841166"
|
||||
x="10"
|
||||
y="10"
|
||||
width="14.423223834988539"
|
||||
height="14.423223834988539"
|
||||
patternUnits="userSpaceOnUse"
|
||||
|
||||
@@ -25,7 +25,7 @@ const Popup: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end m-3"
|
||||
>
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<Transition
|
||||
@@ -37,43 +37,43 @@ const Popup: React.FC = () => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4">
|
||||
<div className="pointer-events-auto w-full max-w-[350px] overflow-hidden rounded-xl border border-light-400 bg-light-50 shadow-lg ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-100 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4 relative">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{popupIcon === "success" && (
|
||||
<HiOutlineCheckCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-green-400"
|
||||
className="h-5 w-5 text-green-400"
|
||||
/>
|
||||
)}
|
||||
{popupIcon === "error" && (
|
||||
<HiOutlineExclamationCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-red-400"
|
||||
className="h-5 w-5 text-red-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
<p className="text-[12px] font-bold text-neutral-900 dark:text-dark-950">
|
||||
{popupHeader}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
||||
<p className="mt-1 text-[12px] text-neutral-500 dark:text-dark-900">
|
||||
{popupMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-shrink-0">
|
||||
<div className="ml-4 flex flex-shrink-0 absolute right-3 top-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
||||
className="inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-200"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<HiXMark
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-dark-900"
|
||||
className="h-4 w-4 text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
|
||||
const Button: React.FC<{
|
||||
href: string;
|
||||
current: boolean;
|
||||
name: string;
|
||||
json: object;
|
||||
}> = ({ href, current, name, json }) => {
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
keyboardShortcut: KeyboardShortcut;
|
||||
}> = ({
|
||||
href,
|
||||
current,
|
||||
name,
|
||||
json,
|
||||
isCollapsed = false,
|
||||
keyboardShortcut,
|
||||
onCloseSideNav,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const isMobile = useIsMobile();
|
||||
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
setIndex((index) => index + 1);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className={classNames(
|
||||
current ? "bg-light-200 dark:bg-dark-200" : "dark:bg-dark-50",
|
||||
"group flex items-center gap-x-3 rounded-md p-1.5 text-sm font-normal leading-6 text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
onClick={handleClick}
|
||||
className={twMerge(
|
||||
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||
isCollapsed ? "md:justify-center" : "justify-between",
|
||||
current
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||
)}
|
||||
title={isCollapsed ? name : undefined}
|
||||
>
|
||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||
{name}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isCollapsed
|
||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
||||
: "gap-x-3",
|
||||
)}
|
||||
>
|
||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
161
apps/web/src/components/SettingsLayout.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
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,
|
||||
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: "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,22 +1,45 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiBolt } from "react-icons/hi2";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import boardsIconDark from "~/assets/boards-dark.json";
|
||||
import boardsIconLight from "~/assets/boards-light.json";
|
||||
import membersIconDark from "~/assets/members-dark.json";
|
||||
import membersIconLight from "~/assets/members-light.json";
|
||||
import settingsIconDark from "~/assets/settings-dark.json";
|
||||
import settingsIconLight from "~/assets/settings-light.json";
|
||||
import templatesIconDark from "~/assets/templates-dark.json";
|
||||
import templatesIconLight from "~/assets/templates-light.json";
|
||||
import ButtonComponent from "~/components/Button";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
isLoading: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
interface UserType {
|
||||
displayName?: string | null | undefined;
|
||||
email?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
}
|
||||
@@ -24,38 +47,148 @@ interface UserType {
|
||||
export default function SideNavigation({
|
||||
user,
|
||||
isLoading,
|
||||
onCloseSideNav,
|
||||
}: SideNavigationProps) {
|
||||
const router = useRouter();
|
||||
const { workspace } = useWorkspace();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { 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 { activeTheme } = useTheme();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const isDarkMode = activeTheme === "dark";
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
|
||||
const navigation = [
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
const navigation: {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: object;
|
||||
keyboardShortcut: KeyboardShortcut;
|
||||
}[] = [
|
||||
{
|
||||
name: "Boards",
|
||||
name: t`Boards`,
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "B" }],
|
||||
action: () => router.push("/boards"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to boards`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Members",
|
||||
name: t`Templates`,
|
||||
href: "/templates",
|
||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "T" }],
|
||||
action: () => router.push("/templates"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to templates`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t`Members`,
|
||||
href: "/members",
|
||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "M" }],
|
||||
action: () => router.push("/members"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to members`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
name: t`Settings`,
|
||||
href: "/settings",
|
||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||
keyboardShortcut: {
|
||||
type: "SEQUENCE",
|
||||
strokes: [{ key: "G" }, { key: "S" }],
|
||||
action: () => router.push("/settings"),
|
||||
group: "NAVIGATION",
|
||||
description: t`Go to settings`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="flex w-72 flex-col justify-between border-r border-light-600 px-3 pb-3 pt-5 dark:border-dark-400">
|
||||
<nav
|
||||
className={twMerge(
|
||||
"flex h-full w-64 flex-col justify-between border-r border-light-300 bg-light-100 p-3 dark:border-dark-300 dark:bg-dark-100 md:border-r-0 md:py-0 md:pl-0",
|
||||
isCollapsed && "md:w-auto",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<WorkspaceMenu />
|
||||
<div className="hidden h-[45px] items-center justify-between pb-3 md:flex">
|
||||
{!isCollapsed && (
|
||||
<Link href="/" className="block">
|
||||
<h1 className="pl-2 text-[16px] font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
onClick={toggleCollapse}
|
||||
className={twMerge(
|
||||
"flex h-8 items-center justify-center rounded-md hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed ? "w-full" : "w-8",
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<TbLayoutSidebarLeftExpand
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
) : (
|
||||
<TbLayoutSidebarLeftCollapse
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mx-1 mb-4 hidden w-auto border-b border-light-300 dark:border-dark-400 md:block" />
|
||||
|
||||
<WorkspaceMenu isCollapsed={isCollapsed} />
|
||||
<ul role="list" className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
@@ -64,16 +197,52 @@ export default function SideNavigation({
|
||||
current={pathname.includes(item.href)}
|
||||
name={item.name}
|
||||
json={item.icon}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
keyboardShortcut={item.keyboardShortcut}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<UserMenu
|
||||
displayName={user.displayName ?? undefined}
|
||||
email={user.email ?? "Email not provided?"}
|
||||
imageUrl={user.image ?? undefined}
|
||||
isLoading={isLoading}
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
{isCloudEnv &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasActiveSubscription(subscriptions, "team") && (
|
||||
<div className={twMerge(isCollapsed && "flex justify-center")}>
|
||||
{isCollapsed ? (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
variant="secondary"
|
||||
href="/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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { CgDarkMode } from "react-icons/cg";
|
||||
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const { themePreference, switchTheme } = useTheme();
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
switchTheme(themePreference === "light" ? "dark" : "light");
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
aria-label={`Switch to ${themePreference === "light" ? "dark" : "light"} theme`}
|
||||
aria-label={`Switch to ${resolvedTheme === "light" ? "dark" : "light"} theme`}
|
||||
>
|
||||
<CgDarkMode
|
||||
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
|
||||
themePreference === "dark" ? "rotate-180" : "rotate-0"
|
||||
resolvedTheme === "dark" ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -5,18 +5,25 @@ const Toggle = ({
|
||||
isChecked,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
showLabel = true,
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={twMerge(
|
||||
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
|
||||
isChecked && "bg-indigo-600 dark:bg-indigo-600",
|
||||
|
||||
54
apps/web/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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,49 +1,84 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Fragment } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { env } from "~/env";
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
imageUrl: string | undefined;
|
||||
displayName: string | undefined;
|
||||
email: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
isCollapsed?: boolean;
|
||||
onCloseSideNav?: () => void;
|
||||
}
|
||||
|
||||
export default function UserMenu({
|
||||
imageUrl,
|
||||
email,
|
||||
displayName,
|
||||
isLoading,
|
||||
isCollapsed = false,
|
||||
onCloseSideNav,
|
||||
}: UserMenuProps) {
|
||||
const router = useRouter();
|
||||
const { themePreference, switchTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { openLegend } = useKeyboardShortcuts();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
await authClient.signOut();
|
||||
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalOpen = (modalType: string) => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
openModal(modalType);
|
||||
};
|
||||
|
||||
const avatarUrl = imageUrl ? getAvatarUrl(imageUrl) : null;
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block w-full text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex">
|
||||
<div className={twMerge(!isCollapsed && "flex")}>
|
||||
<div className="h-[30px] w-[30px] animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
|
||||
<div className="mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"mx-2 h-[30px] w-[175px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<Menu.Button
|
||||
className="flex w-full items-center rounded-md p-1.5 text-neutral-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200 dark:hover:text-dark-1000"
|
||||
title={isCollapsed ? (displayName || email) : undefined}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
@@ -63,7 +98,14 @@ export default function UserMenu({
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<span className="mx-2 truncate text-sm">{email}</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"mx-2 truncate text-sm",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{displayName || email}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -77,52 +119,102 @@ export default function UserMenu({
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<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">
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute bottom-[40px] z-10 mt-2 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "left-0 w-48" : "left-0 w-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-neutral-900 dark:text-dark-1000">
|
||||
<div className="p-1">
|
||||
<div className="flex w-full items-center px-3 py-2 text-left text-xs">
|
||||
<span>Theme</span>
|
||||
<span>{t`Theme`}</span>
|
||||
</div>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchTheme("system")}
|
||||
onClick={() => setTheme("system")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
themePreference === "system" ? "visible" : "invisible",
|
||||
className={twMerge(
|
||||
theme === "system" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
System
|
||||
{t`System`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchTheme("dark")}
|
||||
onClick={() => setTheme("dark")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
themePreference === "dark" ? "visible" : "invisible",
|
||||
className={twMerge(
|
||||
theme === "dark" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
Dark
|
||||
{t`Dark`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchTheme("light")}
|
||||
onClick={() => setTheme("light")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
themePreference === "light" ? "visible" : "invisible",
|
||||
className={twMerge(
|
||||
theme === "light" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
Light
|
||||
{t`Light`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onCloseSideNav && isMobile) {
|
||||
onCloseSideNav();
|
||||
}
|
||||
openLegend();
|
||||
}}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Shortcuts`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Support`}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Documentation`}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => handleModalOpen("NEW_FEEDBACK")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Feedback`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
@@ -132,10 +224,29 @@ export default function UserMenu({
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
Logout
|
||||
{t`Logout`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_APP_VERSION && (
|
||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||
<Menu.Item>
|
||||
<Link
|
||||
href={
|
||||
env.NEXT_PUBLIC_APP_VERSION.includes("+")
|
||||
? `https://github.com/kanbn/kan/commit/${env.NEXT_PUBLIC_APP_VERSION.split("+")[1]}`
|
||||
: `https://github.com/kanbn/kan/releases/tag/v${env.NEXT_PUBLIC_APP_VERSION}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex w-full items-center justify-center rounded-[5px] px-3 py-2 text-center text-xs text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-400"
|
||||
>
|
||||
Version: {env.NEXT_PUBLIC_APP_VERSION}
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
|
||||
@@ -1,92 +1,162 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
import { HiCheck } from "react-icons/hi2";
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
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 { 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 } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
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 (
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="mb-1 flex p-1.5">
|
||||
<div className="h-6 w-6 animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||
<div className="ml-2 h-6 w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-200" />
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="mb-1 flex w-full items-center rounded-[5px] p-1.5 hover:bg-light-200 dark:hover:bg-dark-200">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span className="ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute left-0 z-10 w-full origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300">
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div>
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.name === availableWorkspace.name && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
<>
|
||||
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-start gap-1",
|
||||
isCollapsed && "md:flex-col-reverse md:items-center",
|
||||
)}
|
||||
>
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] min-w-0 flex-1 items-center justify-start rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed &&
|
||||
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
Create workspace
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
189
apps/web/src/components/YouTubeEmbed/EditYouTubeModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||
|
||||
interface EditYouTubeFormInput {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EditYouTubeModalState {
|
||||
url: string;
|
||||
title: string;
|
||||
onSave: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
export function EditYouTubeModal() {
|
||||
const { closeModal, getModalState } = useModal();
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
|
||||
// Get initial values and callback from modal state
|
||||
const modalState = getModalState("EDIT_YOUTUBE") as
|
||||
| EditYouTubeModalState
|
||||
| undefined;
|
||||
const initialUrl = modalState?.url ?? "";
|
||||
const initialTitle = modalState?.title ?? "";
|
||||
const onSave = modalState?.onSave;
|
||||
|
||||
const { register, handleSubmit, watch, reset } =
|
||||
useForm<EditYouTubeFormInput>({
|
||||
defaultValues: {
|
||||
url: initialUrl,
|
||||
title: initialTitle,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when modal state changes (when modal opens with new values)
|
||||
useEffect(() => {
|
||||
if (modalState) {
|
||||
reset({
|
||||
url: modalState.url,
|
||||
title: modalState.title,
|
||||
});
|
||||
}
|
||||
}, [modalState, reset]);
|
||||
|
||||
const currentUrl = watch("url");
|
||||
|
||||
const onSubmit = async (values: EditYouTubeFormInput) => {
|
||||
// Validate URL
|
||||
if (!isYouTubeUrl(values.url)) {
|
||||
setUrlError(t`Please enter a valid YouTube URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlError(null);
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
// If title is empty and URL changed, fetch new title
|
||||
let finalTitle = values.title;
|
||||
if (!finalTitle.trim() && values.url !== initialUrl) {
|
||||
const metadata = await fetchYouTubeMetadata(values.url);
|
||||
finalTitle = metadata?.title ?? "YouTube Video";
|
||||
} else if (!finalTitle.trim()) {
|
||||
// Keep existing title if no new title provided and URL unchanged
|
||||
finalTitle = initialTitle || "YouTube Video";
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(values.url, finalTitle);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUrlError(t`Failed to fetch video information`);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-focus on title input (more useful for editing)
|
||||
useEffect(() => {
|
||||
const titleElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#youtube-title");
|
||||
if (titleElement) titleElement.focus();
|
||||
}, []);
|
||||
|
||||
// Validate URL on change
|
||||
useEffect(() => {
|
||||
if (currentUrl && !isYouTubeUrl(currentUrl)) {
|
||||
setUrlError(t`Please enter a valid YouTube URL`);
|
||||
} else {
|
||||
setUrlError(null);
|
||||
}
|
||||
}, [currentUrl]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="youtube-title"
|
||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Title`}
|
||||
</label>
|
||||
<Input
|
||||
id="youtube-title"
|
||||
placeholder={t`Enter a custom title`}
|
||||
{...register("title")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="youtube-url"
|
||||
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`YouTube URL`}
|
||||
</label>
|
||||
<Input
|
||||
id="youtube-url"
|
||||
placeholder={t`https://www.youtube.com/watch?v=...`}
|
||||
{...register("url", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{urlError && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{urlError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => closeModal()}
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isValidating}
|
||||
disabled={!watch("url") || !!urlError}
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import YouTubeDropdown from "./YouTubeDropdown";
|
||||
|
||||
interface YouTubeCardProps {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
showEmbed?: boolean;
|
||||
onConvertToLink: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
const YouTubeCard = ({
|
||||
videoId,
|
||||
url,
|
||||
title,
|
||||
showEmbed = true,
|
||||
onConvertToLink,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: YouTubeCardProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
|
||||
<div className="flex items-center justify-between gap-6 p-0 px-6">
|
||||
<h3 className="truncate text-sm font-medium">{title}</h3>
|
||||
<div className="mt-3">
|
||||
<YouTubeDropdown
|
||||
url={url}
|
||||
title={title}
|
||||
onConvertToLink={onConvertToLink}
|
||||
onDelete={onDelete}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEmbed && videoId && (
|
||||
<div className="p-6 pt-1">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
|
||||
title={title}
|
||||
className="aspect-video w-full rounded-lg"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeCard;
|
||||
63
apps/web/src/components/YouTubeEmbed/YouTubeDropdown.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiPencil,
|
||||
HiTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import Dropdown from "../Dropdown";
|
||||
|
||||
interface YouTubeDropdownProps {
|
||||
url: string;
|
||||
title: string;
|
||||
onConvertToLink: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (url: string, title: string) => void;
|
||||
}
|
||||
|
||||
const YouTubeDropdown = ({
|
||||
url,
|
||||
title,
|
||||
onConvertToLink,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: YouTubeDropdownProps) => {
|
||||
const { openModal, setModalState } = useModal();
|
||||
|
||||
const handleEdit = () => {
|
||||
setModalState("EDIT_YOUTUBE", {
|
||||
url,
|
||||
title,
|
||||
onSave: onUpdate,
|
||||
});
|
||||
openModal("EDIT_YOUTUBE");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit`,
|
||||
action: handleEdit,
|
||||
icon: <HiPencil className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Convert to link`,
|
||||
action: onConvertToLink,
|
||||
icon: <HiLink className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Delete`,
|
||||
action: onDelete,
|
||||
icon: <HiTrash className="h-4 w-4 text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeDropdown;
|
||||
196
apps/web/src/components/YouTubeEmbed/YouTubeNode.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { extractVideoId, fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
|
||||
import YouTubeNodeView from "./YouTubeNodeView";
|
||||
|
||||
export interface YouTubeOptions {
|
||||
inline: boolean;
|
||||
HTMLAttributes: Record<string, undefined>;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
youTube: {
|
||||
setYouTubeEmbed: (options: {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
thumbnailUrl?: string;
|
||||
showEmbed?: boolean;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const YouTubeNode = Node.create<YouTubeOptions>({
|
||||
name: "youtube",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addOptions() {
|
||||
return {
|
||||
inline: false,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
videoId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-video-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.videoId) return {};
|
||||
return { "data-video-id": attributes.videoId as string };
|
||||
},
|
||||
},
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url as string };
|
||||
},
|
||||
},
|
||||
title: {
|
||||
default: "YouTube Video",
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes) => {
|
||||
return { "data-title": attributes.title as string };
|
||||
},
|
||||
},
|
||||
thumbnailUrl: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-thumbnail"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.thumbnailUrl) return {};
|
||||
return { "data-thumbnail": attributes.thumbnailUrl as string };
|
||||
},
|
||||
},
|
||||
showEmbed: {
|
||||
default: true,
|
||||
parseHTML: (element) => element.getAttribute("data-show-embed"),
|
||||
renderHTML: (attributes) => {
|
||||
return { "data-show-embed": attributes.showEmbed as string };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "div[data-youtube]",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-youtube": "" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(YouTubeNodeView);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const nodeType = this.type;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("youtubePaste"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
// Check if the pasted text is a YouTube URL
|
||||
const youtubeRegex =
|
||||
/(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/;
|
||||
const match = youtubeRegex.exec(text);
|
||||
|
||||
if (!match || !isYouTubeUrl(text)) return false;
|
||||
|
||||
const videoId = extractVideoId(text);
|
||||
if (!videoId) return false;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
const { tr } = state;
|
||||
|
||||
const node = nodeType.create({
|
||||
videoId,
|
||||
url: text,
|
||||
title: "Loading...",
|
||||
showEmbed: true,
|
||||
});
|
||||
|
||||
tr.replaceSelectionWith(node);
|
||||
dispatch(tr);
|
||||
|
||||
// Fetch metadata asynchronously and update the node
|
||||
void fetchYouTubeMetadata(text).then((metadata) => {
|
||||
const { state: newState, dispatch: newDispatch } = view;
|
||||
const { tr: newTr } = newState;
|
||||
|
||||
newState.doc.descendants((n, pos) => {
|
||||
if (
|
||||
n.type.name === "youtube" &&
|
||||
n.attrs.videoId === videoId &&
|
||||
n.attrs.title === "Loading..."
|
||||
) {
|
||||
newTr.setNodeMarkup(pos, undefined, {
|
||||
...n.attrs,
|
||||
title: metadata?.title ?? "YouTube Video",
|
||||
thumbnailUrl: metadata?.thumbnail_url ?? null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newTr.docChanged) {
|
||||
newDispatch(newTr);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
// commands for the YouTube embed node
|
||||
addCommands() {
|
||||
return {
|
||||
setYouTubeEmbed:
|
||||
(options: {
|
||||
videoId: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
showEmbed?: boolean;
|
||||
}) =>
|
||||
({ editor }) => {
|
||||
return editor.commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
videoId: options.videoId,
|
||||
url: options.url,
|
||||
title: options.title ?? "YouTube Video",
|
||||
thumbnailUrl: options.thumbnailUrl,
|
||||
showEmbed: options.showEmbed ?? true,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
82
apps/web/src/components/YouTubeEmbed/YouTubeNodeView.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
|
||||
import { extractVideoId } from "./utils";
|
||||
import YouTubeCard from "./YouTubeCard";
|
||||
|
||||
export default function YouTubeNodeView({
|
||||
node,
|
||||
editor,
|
||||
getPos,
|
||||
deleteNode,
|
||||
updateAttributes,
|
||||
}: NodeViewProps) {
|
||||
const { videoId, title, url, showEmbed } = node.attrs as {
|
||||
videoId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
showEmbed: boolean;
|
||||
};
|
||||
|
||||
const handleConvertToLink = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== "number") return;
|
||||
|
||||
const linkContent = {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: url,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: url,
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
||||
.insertContentAt(pos, linkContent)
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteNode();
|
||||
};
|
||||
|
||||
const handleUpdate = (newUrl: string, newTitle: string) => {
|
||||
if (newUrl !== url) {
|
||||
const newVideoId = extractVideoId(newUrl);
|
||||
updateAttributes({
|
||||
url: newUrl,
|
||||
title: newTitle,
|
||||
videoId: newVideoId,
|
||||
});
|
||||
} else {
|
||||
updateAttributes({ title: newTitle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<YouTubeCard
|
||||
videoId={videoId}
|
||||
url={url}
|
||||
title={title}
|
||||
showEmbed={showEmbed}
|
||||
onConvertToLink={handleConvertToLink}
|
||||
onDelete={handleDelete}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/components/YouTubeEmbed/utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export function extractVideoId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
url = url.trim();
|
||||
|
||||
// youtube.com/watch?v=VIDEO_ID
|
||||
const watchMatch = /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (watchMatch) return watchMatch[1] ?? null;
|
||||
|
||||
// youtu.be/VIDEO_ID
|
||||
const shortMatch = /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (shortMatch) return shortMatch[1] ?? null;
|
||||
|
||||
// youtube.com/embed/VIDEO_ID
|
||||
const embedMatch = /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (embedMatch) return embedMatch[1] ?? null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid YouTube link
|
||||
*/
|
||||
export function isYouTubeUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
const videoId = extractVideoId(url);
|
||||
return videoId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch YouTube video metadata using oEmbed API
|
||||
* Returns title, author, thumbnail URL, etc.
|
||||
* No API key required
|
||||
*/
|
||||
export async function fetchYouTubeMetadata(url: string): Promise<{
|
||||
title: string;
|
||||
author_name: string;
|
||||
thumbnail_url: string;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||
const response = await fetch(oembedUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
title: string;
|
||||
author_name: string;
|
||||
thumbnail_url: string;
|
||||
};
|
||||
return {
|
||||
title: data.title || "YouTube Video",
|
||||
author_name: data.author_name || "",
|
||||
thumbnail_url: data.thumbnail_url || "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch YouTube metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,28 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
modalSize?: "sm" | "md" | "lg";
|
||||
positionFromTop?: "sm" | "md" | "lg";
|
||||
isVisible?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const Modal: React.FC<Props> = ({
|
||||
children,
|
||||
modalSize = "sm",
|
||||
positionFromTop = "md",
|
||||
isVisible,
|
||||
closeOnClickOutside,
|
||||
centered = false,
|
||||
}) => {
|
||||
const { isOpen, closeModal } = useModal();
|
||||
const {
|
||||
isOpen,
|
||||
closeModal,
|
||||
closeOnClickOutside: modalCloseOnClickOutside,
|
||||
} = useModal();
|
||||
|
||||
const shouldShow = isVisible ?? isOpen;
|
||||
const shouldCloseOnClickOutside =
|
||||
closeOnClickOutside ?? modalCloseOnClickOutside;
|
||||
|
||||
const modalSizeMap = {
|
||||
sm: "max-w-[400px]",
|
||||
@@ -29,8 +43,12 @@ const Modal: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Root show={shouldShow} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={shouldCloseOnClickOutside ? closeModal : () => null}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -43,8 +61,8 @@ const Modal: React.FC<Props> = ({
|
||||
<div className="fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className={`flex min-h-full justify-center p-4 text-center sm:p-0 ${centered ? "items-center" : "items-start sm:items-start"}`}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -55,7 +73,7 @@ const Modal: React.FC<Props> = ({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
className={`relative ${centered ? "" : positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -14,6 +14,7 @@ export const env = createEnv({
|
||||
* This way you can ensure the app isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
KAN_ADMIN_API_KEY: z.string().optional(),
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: z
|
||||
.string()
|
||||
@@ -24,7 +25,7 @@ export const env = createEnv({
|
||||
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||
)
|
||||
.optional(),
|
||||
POSTGRES_URL: z.string().url(),
|
||||
POSTGRES_URL: z.string().url().optional().or(z.literal("")),
|
||||
TRELLO_APP_API_KEY: z.string().optional(),
|
||||
TRELLO_APP_SECRET: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
@@ -51,6 +52,12 @@ export const env = createEnv({
|
||||
VK_CLIENT_SECRET: z.string().optional(),
|
||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||
NOVU_API_KEY: z.string().optional(),
|
||||
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
|
||||
// Generic OIDC Provider
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
OIDC_DISCOVERY_URL: z.string().optional(),
|
||||
REDDIT_CLIENT_ID: z.string().optional(),
|
||||
REDDIT_CLIENT_SECRET: z.string().optional(),
|
||||
ROBLOX_CLIENT_ID: z.string().optional(),
|
||||
@@ -69,7 +76,9 @@ export const env = createEnv({
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.string().optional(),
|
||||
EMAIL_FROM: z.string().optional(),
|
||||
REDIS_URL: z.string().url().optional().or(z.literal("")),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -79,10 +88,22 @@ export const env = createEnv({
|
||||
client: {
|
||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
@@ -97,6 +118,13 @@ export const env = createEnv({
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
@@ -105,12 +133,23 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
|
||||
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS:
|
||||
process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS,
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
|
||||
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
|
||||
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||
|
||||
59
apps/web/src/hooks/useClickOutside.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
36
apps/web/src/hooks/useClipboard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 };
|
||||
}
|
||||
101
apps/web/src/hooks/useDragToScroll.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UseDragToScrollOptions {
|
||||
/**
|
||||
* Whether drag-to-scroll is enabled
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* The direction to scroll
|
||||
*/
|
||||
direction?: "horizontal" | "vertical" | "both";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable drag-to-scroll functionality on a scrollable element
|
||||
* @param options Configuration options
|
||||
* @returns Ref to attach to the scrollable element and mouse event handlers
|
||||
*/
|
||||
export function useDragToScroll({
|
||||
enabled = true,
|
||||
direction = "horizontal",
|
||||
}: UseDragToScrollOptions = {}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startPosRef = useRef({ x: 0, y: 0 });
|
||||
const scrollStartRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const deltaX = e.clientX - startPosRef.current.x;
|
||||
const deltaY = e.clientY - startPosRef.current.y;
|
||||
|
||||
if (direction === "horizontal" || direction === "both") {
|
||||
scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX;
|
||||
}
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "grabbing";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [enabled, isDragging, direction]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const container = scrollRef.current;
|
||||
|
||||
// Check if the click is on an interactive or draggable element
|
||||
// We need to be careful not to interfere with react-beautiful-dnd dragging
|
||||
const isInteractiveElement =
|
||||
target.closest("a") ||
|
||||
target.closest("button") ||
|
||||
target.closest("input") ||
|
||||
target.closest("textarea") ||
|
||||
target.closest("[role='button']") ||
|
||||
target.closest("[draggable='true']") ||
|
||||
target.closest(".react-beautiful-dnd-drag-handle") ||
|
||||
target.closest("[data-rbd-drag-handle-draggable-id]") ||
|
||||
target.closest("[data-rbd-draggable-id]");
|
||||
|
||||
// Don't start dragging if clicking on interactive elements
|
||||
if (isInteractiveElement) return;
|
||||
|
||||
// Enable drag-to-scroll for any non-interactive element within the container
|
||||
if (container.contains(target)) {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
scrollStartRef.current = {
|
||||
x: container.scrollLeft,
|
||||
y: container.scrollTop,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ref: scrollRef,
|
||||
onMouseDown: handleMouseDown,
|
||||
isDragging,
|
||||
};
|
||||
}
|
||||
120
apps/web/src/hooks/useEventListener.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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 };
|
||||
15
apps/web/src/hooks/useIsomorphicLayoutEffect.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
39
apps/web/src/hooks/useLocalisation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
23
apps/web/src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)");
|
||||
}
|
||||
48
apps/web/src/hooks/useModalFormState.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
109
apps/web/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Permission } from "@kan/shared";
|
||||
import { useContext } from "react";
|
||||
|
||||
import { WorkspaceContext } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface UsePermissionsResult {
|
||||
permissions: Permission[];
|
||||
role: string | null;
|
||||
isLoading: boolean;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
canViewCard: boolean;
|
||||
canCreateCard: boolean;
|
||||
canEditCard: boolean;
|
||||
canDeleteCard: boolean;
|
||||
canCreateList: boolean;
|
||||
canEditList: boolean;
|
||||
canDeleteList: boolean;
|
||||
canCreateBoard: boolean;
|
||||
canEditBoard: boolean;
|
||||
canDeleteBoard: boolean;
|
||||
canViewComment: boolean;
|
||||
canCreateComment: boolean;
|
||||
canEditComment: boolean;
|
||||
canDeleteComment: boolean;
|
||||
canInviteMember: boolean;
|
||||
canEditMember: boolean;
|
||||
canRemoveMember: boolean;
|
||||
canViewWorkspace: boolean;
|
||||
canEditWorkspace: boolean;
|
||||
}
|
||||
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||
const workspaceContext = useContext(WorkspaceContext);
|
||||
|
||||
// If WorkspaceProvider is not available, return safe defaults
|
||||
if (!workspaceContext) {
|
||||
const emptyPermissions: UsePermissionsResult = {
|
||||
permissions: [],
|
||||
role: null,
|
||||
isLoading: false,
|
||||
hasPermission: () => false,
|
||||
canViewCard: false,
|
||||
canCreateCard: false,
|
||||
canEditCard: false,
|
||||
canDeleteCard: false,
|
||||
canCreateList: false,
|
||||
canEditList: false,
|
||||
canDeleteList: false,
|
||||
canCreateBoard: false,
|
||||
canEditBoard: false,
|
||||
canDeleteBoard: false,
|
||||
canViewComment: false,
|
||||
canCreateComment: false,
|
||||
canEditComment: false,
|
||||
canDeleteComment: false,
|
||||
canInviteMember: false,
|
||||
canEditMember: false,
|
||||
canRemoveMember: false,
|
||||
canViewWorkspace: false,
|
||||
canEditWorkspace: false,
|
||||
};
|
||||
return emptyPermissions;
|
||||
}
|
||||
|
||||
const { workspace } = workspaceContext;
|
||||
|
||||
const { data, isLoading } = api.permission.getMyPermissions.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{
|
||||
enabled: !!workspace.publicId,
|
||||
},
|
||||
);
|
||||
|
||||
const permissions = (data?.permissions ?? []) as Permission[];
|
||||
const role = data?.role ?? null;
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission);
|
||||
};
|
||||
|
||||
return {
|
||||
permissions,
|
||||
role,
|
||||
isLoading,
|
||||
hasPermission,
|
||||
canViewCard: hasPermission("card:view"),
|
||||
canCreateCard: hasPermission("card:create"),
|
||||
canEditCard: hasPermission("card:edit"),
|
||||
canDeleteCard: hasPermission("card:delete"),
|
||||
canCreateList: hasPermission("list:create"),
|
||||
canEditList: hasPermission("list:edit"),
|
||||
canDeleteList: hasPermission("list:delete"),
|
||||
canCreateBoard: hasPermission("board:create"),
|
||||
canEditBoard: hasPermission("board:edit"),
|
||||
canDeleteBoard: hasPermission("board:delete"),
|
||||
canViewComment: hasPermission("comment:view"),
|
||||
canCreateComment: hasPermission("comment:create"),
|
||||
canEditComment: hasPermission("comment:edit"),
|
||||
canDeleteComment: hasPermission("comment:delete"),
|
||||
canInviteMember: hasPermission("member:invite"),
|
||||
canEditMember: hasPermission("member:edit"),
|
||||
canRemoveMember: hasPermission("member:remove"),
|
||||
canViewWorkspace: hasPermission("workspace:view"),
|
||||
canEditWorkspace: hasPermission("workspace:edit"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await require("pino");
|
||||
await require("next-logger");
|
||||
}
|
||||
}
|
||||
3090
apps/web/src/locales/de/messages.po
Normal file
1
apps/web/src/locales/de/messages.ts
Normal file
3275
apps/web/src/locales/en/messages.po
Normal file
1
apps/web/src/locales/en/messages.ts
Normal file
3090
apps/web/src/locales/es/messages.po
Normal file
1
apps/web/src/locales/es/messages.ts
Normal file
3090
apps/web/src/locales/fr/messages.po
Normal file
1
apps/web/src/locales/fr/messages.ts
Normal file
27
apps/web/src/locales/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const locales = [
|
||||
"en",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"it",
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"pt-BR"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "en";
|
||||
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
en: "English",
|
||||
fr: "Français",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
it: "Italiano",
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
"pt-BR": "Português",
|
||||
};
|
||||