Compare commits

..

5 Commits

Author SHA1 Message Date
Morfixx
fed24d1960 fix: bulkCreate to stop imports with duplicate indices (#190)
* fix(db): normalize/compact indices to prevent import duplicates

- bulkCreate now assigns sequential indices per board (preserves order; ignores incoming indices).
- Auto-compact indices after create/insert if duplicates exist, keeping active lists unique and gap-free.
- Prevents reorder errors caused by duplicate indices from Trello imports.

* feat(db): normalize/compact indices with fail-safe duplicate recheck
2025-09-20 22:17:41 +01:00
Morfixx
2a0220ce6f feat: added docs to website (#186)
* feat(docs): self-hosting s3 minio guide

* feat(docs): self-hosting introduction guide

* fix: build error

* refactor: use kan over kan.bn

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-09-19 21:08:59 +01:00
Morfixx
7ccd2ac1e0 feat(web): added image cropping to avatar uploads (#185)
* feat(web): added image cropping to avatar uploads

* chore: add translations

* refactor: move react crop css import to avatar component

* refactor: remove duplicate interface

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-09-19 20:44:20 +01:00
Henry
cea5cf84c8 feat(cloud): pause/reactivate members (#184)
* feat: add paused member status

* feat: add paused badge to member table

* chore: add translations

* feat: unpause all members on pro activation
2025-09-18 22:09:42 +01:00
Henry
6738fddc5f feat(localisation): add russian language support (#183) 2025-09-18 20:50:01 +01:00
27 changed files with 3193 additions and 93 deletions

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
"version": 0,
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl"]
"targets": ["fr", "de", "es", "it", "nl", "ru"]
},
"buckets": {
"po": {

View File

@@ -30,6 +30,7 @@ checksums:
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
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%20roles/singular: 32a5d78073b9bb9a246773afba8831df
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
@@ -119,6 +120,7 @@ checksums:
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
Critical/singular: eb327cd411b50aee954f8d1d215d003a
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea

View File

@@ -1,7 +1,7 @@
import type { LinguiConfig } from "@lingui/conf";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl"],
locales: ["en", "fr", "de", "es", "it", "nl", "ru"],
sourceLocale: "en",
catalogs: [
{

View File

@@ -59,6 +59,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",

View File

@@ -140,6 +140,10 @@ msgstr "hat Label <0>{0}</0> hinzugefügt"
msgid "Adding a new member will cost an additional {price} ({billingType}) per seat."
msgstr "Das Hinzufügen eines neuen Mitglieds kostet zusätzlich {price} ({billingType}) pro Platz."
#: src/views/settings/components/Avatar.tsx:278
msgid "Adjust the square crop to fit your avatar."
msgstr "Passe den quadratischen Zuschnitt an deinen Avatar an."
#: src/views/home/components/Pricing.tsx:55
msgid "Admin roles"
msgstr "Administratorrollen"
@@ -338,6 +342,7 @@ msgstr "Fehlerbericht"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
#: src/views/settings/components/Avatar.tsx:309
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
#: src/views/settings/components/DeleteAccountConfirmation.tsx:88
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:98
@@ -527,6 +532,10 @@ msgstr "hat die Karte erstellt"
msgid "Critical"
msgstr "Kritisch"
#: src/views/settings/components/Avatar.tsx:275
msgid "Crop your avatar"
msgstr "Schneide deinen Avatar zu"
#: src/views/settings/components/ChangePasswordConfirmation.tsx:19
msgid "Current password is required"
msgstr "Aktuelles Passwort ist erforderlich"
@@ -749,7 +758,7 @@ msgstr "Fehler beim Einladen des Mitglieds"
msgid "Error updating display name"
msgstr "Fehler beim Aktualisieren des Anzeigenamens"
#: src/views/settings/components/Avatar.tsx:39
#: src/views/settings/components/Avatar.tsx:80
msgid "Error updating profile image"
msgstr "Fehler beim Aktualisieren des Profilbilds"
@@ -774,8 +783,8 @@ msgstr "Fehler beim Upgrade des Abonnements"
msgid "Error upgrading to Pro"
msgstr "Fehler beim Upgrade auf Pro"
#: src/views/settings/components/Avatar.tsx:56
#: src/views/settings/components/Avatar.tsx:97
#: src/views/settings/components/Avatar.tsx:94
#: src/views/settings/components/Avatar.tsx:221
msgid "Error uploading profile image"
msgstr "Fehler beim Hochladen des Profilbilds"
@@ -1321,7 +1330,7 @@ msgstr "Bitte gib einen gültigen Namen ein"
msgid "Please enter a valid password"
msgstr "Bitte gib ein gültiges Passwort ein"
#: src/views/settings/components/Avatar.tsx:57
#: src/views/settings/components/Avatar.tsx:95
msgid "Please select a file to upload."
msgstr "Bitte wähle eine Datei zum Hochladen aus."
@@ -1352,8 +1361,8 @@ msgstr "Bitte wähle eine Datei zum Hochladen aus."
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
#: src/views/members/components/InviteMemberForm.tsx:78
#: src/views/members/components/InviteMemberForm.tsx:131
#: src/views/settings/components/Avatar.tsx:40
#: src/views/settings/components/Avatar.tsx:98
#: src/views/settings/components/Avatar.tsx:81
#: src/views/settings/components/Avatar.tsx:222
#: src/views/settings/components/DeleteAccountConfirmation.tsx:40
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:46
#: src/views/settings/components/UpdateDisplayNameForm.tsx:55
@@ -1391,7 +1400,7 @@ msgstr "Pro-Plan"
msgid "Pro Plan ∞"
msgstr "Pro-Plan ∞"
#: src/views/settings/components/Avatar.tsx:26
#: src/views/settings/components/Avatar.tsx:67
msgid "Profile image updated"
msgstr "Profilbild aktualisiert"
@@ -1492,6 +1501,7 @@ msgid "Run on your own infrastructure"
msgstr "Auf eigener Infrastruktur betreiben"
#: src/views/card/components/Comment.tsx:165
#: src/views/settings/components/Avatar.tsx:312
msgid "Save"
msgstr "Speichern"
@@ -2118,7 +2128,7 @@ msgstr "Dein Anzeigename wurde aktualisiert."
msgid "Your password has been changed."
msgstr "Ihr Passwort wurde geändert."
#: src/views/settings/components/Avatar.tsx:27
#: src/views/settings/components/Avatar.tsx:68
msgid "Your profile image has been updated."
msgstr "Dein Profilbild wurde aktualisiert."

File diff suppressed because one or more lines are too long

View File

@@ -160,6 +160,10 @@ msgstr "added label <0>{0}</0>"
msgid "Adding a new member will cost an additional {price} ({billingType}) per seat."
msgstr "Adding a new member will cost an additional {price} ({billingType}) per seat."
#: src/views/settings/components/Avatar.tsx:278
msgid "Adjust the square crop to fit your avatar."
msgstr "Adjust the square crop to fit your avatar."
#: src/views/home/components/Pricing.tsx:55
msgid "Admin roles"
msgstr "Admin roles"
@@ -387,6 +391,7 @@ msgstr "Bug Report"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
#: src/views/settings/components/Avatar.tsx:309
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
#: src/views/settings/components/DeleteAccountConfirmation.tsx:88
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:98
@@ -592,6 +597,10 @@ msgstr "created the card"
msgid "Critical"
msgstr "Critical"
#: src/views/settings/components/Avatar.tsx:275
msgid "Crop your avatar"
msgstr "Crop your avatar"
#: src/views/settings/components/ChangePasswordConfirmation.tsx:19
msgid "Current password is required"
msgstr "Current password is required"
@@ -822,7 +831,7 @@ msgstr "Error inviting member"
msgid "Error updating display name"
msgstr "Error updating display name"
#: src/views/settings/components/Avatar.tsx:39
#: src/views/settings/components/Avatar.tsx:80
msgid "Error updating profile image"
msgstr "Error updating profile image"
@@ -847,8 +856,8 @@ msgstr "Error upgrading subscription"
msgid "Error upgrading to Pro"
msgstr "Error upgrading to Pro"
#: src/views/settings/components/Avatar.tsx:56
#: src/views/settings/components/Avatar.tsx:97
#: src/views/settings/components/Avatar.tsx:94
#: src/views/settings/components/Avatar.tsx:221
msgid "Error uploading profile image"
msgstr "Error uploading profile image"
@@ -1411,7 +1420,7 @@ msgstr "Please enter a valid name"
msgid "Please enter a valid password"
msgstr "Please enter a valid password"
#: src/views/settings/components/Avatar.tsx:57
#: src/views/settings/components/Avatar.tsx:95
msgid "Please select a file to upload."
msgstr "Please select a file to upload."
@@ -1442,8 +1451,8 @@ msgstr "Please select a file to upload."
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
#: src/views/members/components/InviteMemberForm.tsx:78
#: src/views/members/components/InviteMemberForm.tsx:131
#: src/views/settings/components/Avatar.tsx:40
#: src/views/settings/components/Avatar.tsx:98
#: src/views/settings/components/Avatar.tsx:81
#: src/views/settings/components/Avatar.tsx:222
#: src/views/settings/components/DeleteAccountConfirmation.tsx:40
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:46
#: src/views/settings/components/UpdateDisplayNameForm.tsx:55
@@ -1481,7 +1490,7 @@ msgstr "Pro Plan"
msgid "Pro Plan ∞"
msgstr "Pro Plan ∞"
#: src/views/settings/components/Avatar.tsx:26
#: src/views/settings/components/Avatar.tsx:67
msgid "Profile image updated"
msgstr "Profile image updated"
@@ -1590,6 +1599,7 @@ msgid "Run on your own infrastructure"
msgstr "Run on your own infrastructure"
#: src/views/card/components/Comment.tsx:165
#: src/views/settings/components/Avatar.tsx:312
msgid "Save"
msgstr "Save"
@@ -2240,7 +2250,7 @@ msgstr "Your display name has been updated."
msgid "Your password has been changed."
msgstr "Your password has been changed."
#: src/views/settings/components/Avatar.tsx:27
#: src/views/settings/components/Avatar.tsx:68
msgid "Your profile image has been updated."
msgstr "Your profile image has been updated."

File diff suppressed because one or more lines are too long

View File

@@ -140,6 +140,10 @@ msgstr "añadió la etiqueta <0>{0}</0>"
msgid "Adding a new member will cost an additional {price} ({billingType}) per seat."
msgstr "Añadir un nuevo miembro costará {price} adicionales ({billingType}) por asiento."
#: src/views/settings/components/Avatar.tsx:278
msgid "Adjust the square crop to fit your avatar."
msgstr "Ajusta el recorte cuadrado para que se adapte a tu avatar."
#: src/views/home/components/Pricing.tsx:55
msgid "Admin roles"
msgstr "Roles de administrador"
@@ -338,6 +342,7 @@ msgstr "Informe de error"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
#: src/views/settings/components/Avatar.tsx:309
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
#: src/views/settings/components/DeleteAccountConfirmation.tsx:88
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:98
@@ -527,6 +532,10 @@ msgstr "creó la tarjeta"
msgid "Critical"
msgstr "Crítico"
#: src/views/settings/components/Avatar.tsx:275
msgid "Crop your avatar"
msgstr "Recorta tu avatar"
#: src/views/settings/components/ChangePasswordConfirmation.tsx:19
msgid "Current password is required"
msgstr "Se requiere la contraseña actual"
@@ -749,7 +758,7 @@ msgstr "Error al invitar al miembro"
msgid "Error updating display name"
msgstr "Error al actualizar el nombre visible"
#: src/views/settings/components/Avatar.tsx:39
#: src/views/settings/components/Avatar.tsx:80
msgid "Error updating profile image"
msgstr "Error al actualizar la imagen de perfil"
@@ -774,8 +783,8 @@ msgstr "Error al actualizar la suscripción"
msgid "Error upgrading to Pro"
msgstr "Error al actualizar a Pro"
#: src/views/settings/components/Avatar.tsx:56
#: src/views/settings/components/Avatar.tsx:97
#: src/views/settings/components/Avatar.tsx:94
#: src/views/settings/components/Avatar.tsx:221
msgid "Error uploading profile image"
msgstr "Error al subir la imagen de perfil"
@@ -1321,7 +1330,7 @@ msgstr "Por favor, introduce un nombre válido"
msgid "Please enter a valid password"
msgstr "Por favor, introduce una contraseña válida"
#: src/views/settings/components/Avatar.tsx:57
#: src/views/settings/components/Avatar.tsx:95
msgid "Please select a file to upload."
msgstr "Por favor selecciona un archivo para subir."
@@ -1352,8 +1361,8 @@ msgstr "Por favor selecciona un archivo para subir."
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
#: src/views/members/components/InviteMemberForm.tsx:78
#: src/views/members/components/InviteMemberForm.tsx:131
#: src/views/settings/components/Avatar.tsx:40
#: src/views/settings/components/Avatar.tsx:98
#: src/views/settings/components/Avatar.tsx:81
#: src/views/settings/components/Avatar.tsx:222
#: src/views/settings/components/DeleteAccountConfirmation.tsx:40
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:46
#: src/views/settings/components/UpdateDisplayNameForm.tsx:55
@@ -1391,7 +1400,7 @@ msgstr "Plan Pro"
msgid "Pro Plan ∞"
msgstr "Plan Pro ∞"
#: src/views/settings/components/Avatar.tsx:26
#: src/views/settings/components/Avatar.tsx:67
msgid "Profile image updated"
msgstr "Imagen de perfil actualizada"
@@ -1492,6 +1501,7 @@ msgid "Run on your own infrastructure"
msgstr "Ejecuta en tu propia infraestructura"
#: src/views/card/components/Comment.tsx:165
#: src/views/settings/components/Avatar.tsx:312
msgid "Save"
msgstr "Guardar"
@@ -2118,7 +2128,7 @@ msgstr "Tu nombre de visualización ha sido actualizado."
msgid "Your password has been changed."
msgstr "Tu contraseña ha sido cambiada."
#: src/views/settings/components/Avatar.tsx:27
#: src/views/settings/components/Avatar.tsx:68
msgid "Your profile image has been updated."
msgstr "Tu imagen de perfil ha sido actualizada."

File diff suppressed because one or more lines are too long

View File

@@ -140,6 +140,10 @@ msgstr "a ajouté l'étiquette <0>{0}</0>"
msgid "Adding a new member will cost an additional {price} ({billingType}) per seat."
msgstr "L'ajout d'un nouveau membre coûtera {price} supplémentaires ({billingType}) par siège."
#: src/views/settings/components/Avatar.tsx:278
msgid "Adjust the square crop to fit your avatar."
msgstr "Ajustez le recadrage carré pour adapter votre avatar."
#: src/views/home/components/Pricing.tsx:55
msgid "Admin roles"
msgstr "Rôles d'administrateur"
@@ -338,6 +342,7 @@ msgstr "Rapport de bug"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
#: src/views/settings/components/Avatar.tsx:309
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
#: src/views/settings/components/DeleteAccountConfirmation.tsx:88
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:98
@@ -527,6 +532,10 @@ msgstr "a créé la carte"
msgid "Critical"
msgstr "Critique"
#: src/views/settings/components/Avatar.tsx:275
msgid "Crop your avatar"
msgstr "Recadrez votre avatar"
#: src/views/settings/components/ChangePasswordConfirmation.tsx:19
msgid "Current password is required"
msgstr "Le mot de passe actuel est requis"
@@ -749,7 +758,7 @@ msgstr "Erreur lors de l'invitation du membre"
msgid "Error updating display name"
msgstr "Erreur lors de la mise à jour du nom d'affichage"
#: src/views/settings/components/Avatar.tsx:39
#: src/views/settings/components/Avatar.tsx:80
msgid "Error updating profile image"
msgstr "Erreur lors de la mise à jour de l'image de profil"
@@ -774,8 +783,8 @@ msgstr "Erreur lors de la mise à niveau de l'abonnement"
msgid "Error upgrading to Pro"
msgstr "Erreur lors de la mise à niveau vers Pro"
#: src/views/settings/components/Avatar.tsx:56
#: src/views/settings/components/Avatar.tsx:97
#: src/views/settings/components/Avatar.tsx:94
#: src/views/settings/components/Avatar.tsx:221
msgid "Error uploading profile image"
msgstr "Erreur lors du téléchargement de l'image de profil"
@@ -1321,7 +1330,7 @@ msgstr "Veuillez saisir un nom valide"
msgid "Please enter a valid password"
msgstr "Veuillez saisir un mot de passe valide"
#: src/views/settings/components/Avatar.tsx:57
#: src/views/settings/components/Avatar.tsx:95
msgid "Please select a file to upload."
msgstr "Veuillez sélectionner un fichier à télécharger."
@@ -1352,8 +1361,8 @@ msgstr "Veuillez sélectionner un fichier à télécharger."
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
#: src/views/members/components/InviteMemberForm.tsx:78
#: src/views/members/components/InviteMemberForm.tsx:131
#: src/views/settings/components/Avatar.tsx:40
#: src/views/settings/components/Avatar.tsx:98
#: src/views/settings/components/Avatar.tsx:81
#: src/views/settings/components/Avatar.tsx:222
#: src/views/settings/components/DeleteAccountConfirmation.tsx:40
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:46
#: src/views/settings/components/UpdateDisplayNameForm.tsx:55
@@ -1391,7 +1400,7 @@ msgstr "Plan Pro"
msgid "Pro Plan ∞"
msgstr "Plan Pro ∞"
#: src/views/settings/components/Avatar.tsx:26
#: src/views/settings/components/Avatar.tsx:67
msgid "Profile image updated"
msgstr "Image de profil mise à jour"
@@ -1492,6 +1501,7 @@ msgid "Run on your own infrastructure"
msgstr "Exécutez sur votre propre infrastructure"
#: src/views/card/components/Comment.tsx:165
#: src/views/settings/components/Avatar.tsx:312
msgid "Save"
msgstr "Enregistrer"
@@ -2118,7 +2128,7 @@ msgstr "Votre nom d'affichage a été mis à jour."
msgid "Your password has been changed."
msgstr "Votre mot de passe a été modifié."
#: src/views/settings/components/Avatar.tsx:27
#: src/views/settings/components/Avatar.tsx:68
msgid "Your profile image has been updated."
msgstr "Votre image de profil a été mise à jour."

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
export const locales = ["en", "fr", "de", "es", "it", "nl"] as const;
export const locales = ["en", "fr", "de", "es", "it", "nl", "ru"] as const;
export type Locale = (typeof locales)[number];
@@ -11,4 +11,5 @@ export const localeNames: Record<Locale, string> = {
es: "Español",
it: "Italiano",
nl: "Nederlands",
ru: "Русский",
};

View File

@@ -140,6 +140,10 @@ msgstr "ha aggiunto l'etichetta <0>{0}</0>"
msgid "Adding a new member will cost an additional {price} ({billingType}) per seat."
msgstr "L'aggiunta di un nuovo membro costerà un supplemento di {price} ({billingType}) per posto."
#: src/views/settings/components/Avatar.tsx:278
msgid "Adjust the square crop to fit your avatar."
msgstr "Regola il ritaglio quadrato per adattarlo al tuo avatar."
#: src/views/home/components/Pricing.tsx:55
msgid "Admin roles"
msgstr "Ruoli amministratore"
@@ -338,6 +342,7 @@ msgstr "Segnalazione bug"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
#: src/views/settings/components/Avatar.tsx:309
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
#: src/views/settings/components/DeleteAccountConfirmation.tsx:88
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:98
@@ -527,6 +532,10 @@ msgstr "ha creato la carta"
msgid "Critical"
msgstr "Critico"
#: src/views/settings/components/Avatar.tsx:275
msgid "Crop your avatar"
msgstr "Ritaglia il tuo avatar"
#: src/views/settings/components/ChangePasswordConfirmation.tsx:19
msgid "Current password is required"
msgstr "La password attuale è obbligatoria"
@@ -749,7 +758,7 @@ msgstr "Errore durante l'invito del membro"
msgid "Error updating display name"
msgstr "Errore durante l'aggiornamento del nome visualizzato"
#: src/views/settings/components/Avatar.tsx:39
#: src/views/settings/components/Avatar.tsx:80
msgid "Error updating profile image"
msgstr "Errore durante l'aggiornamento dell'immagine del profilo"
@@ -774,8 +783,8 @@ msgstr "Errore nell'aggiornamento dell'abbonamento"
msgid "Error upgrading to Pro"
msgstr "Errore durante l'aggiornamento a Pro"
#: src/views/settings/components/Avatar.tsx:56
#: src/views/settings/components/Avatar.tsx:97
#: src/views/settings/components/Avatar.tsx:94
#: src/views/settings/components/Avatar.tsx:221
msgid "Error uploading profile image"
msgstr "Errore durante il caricamento dell'immagine del profilo"
@@ -1321,7 +1330,7 @@ msgstr "Inserisci un nome valido"
msgid "Please enter a valid password"
msgstr "Inserisci una password valida"
#: src/views/settings/components/Avatar.tsx:57
#: src/views/settings/components/Avatar.tsx:95
msgid "Please select a file to upload."
msgstr "Seleziona un file da caricare."
@@ -1352,8 +1361,8 @@ msgstr "Seleziona un file da caricare."
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
#: src/views/members/components/InviteMemberForm.tsx:78
#: src/views/members/components/InviteMemberForm.tsx:131
#: src/views/settings/components/Avatar.tsx:40
#: src/views/settings/components/Avatar.tsx:98
#: src/views/settings/components/Avatar.tsx:81
#: src/views/settings/components/Avatar.tsx:222
#: src/views/settings/components/DeleteAccountConfirmation.tsx:40
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:46
#: src/views/settings/components/UpdateDisplayNameForm.tsx:55
@@ -1391,7 +1400,7 @@ msgstr "Piano Pro"
msgid "Pro Plan ∞"
msgstr "Piano Pro ∞"
#: src/views/settings/components/Avatar.tsx:26
#: src/views/settings/components/Avatar.tsx:67
msgid "Profile image updated"
msgstr "Immagine del profilo aggiornata"
@@ -1492,6 +1501,7 @@ msgid "Run on your own infrastructure"
msgstr "Esegui sulla tua infrastruttura"
#: src/views/card/components/Comment.tsx:165
#: src/views/settings/components/Avatar.tsx:312
msgid "Save"
msgstr "Salva"
@@ -2118,7 +2128,7 @@ msgstr "Il tuo nome visualizzato è stato aggiornato."
msgid "Your password has been changed."
msgstr "La tua password è stata modificata."
#: src/views/settings/components/Avatar.tsx:27
#: src/views/settings/components/Avatar.tsx:68
msgid "Your profile image has been updated."
msgstr "La tua immagine del profilo è stata aggiornata."

File diff suppressed because one or more lines are too long

View File

@@ -140,6 +140,10 @@ msgstr "heeft label <0>{0}</0> toegevoegd"
msgid "Adding a new member will cost an additional {price} ({billingType}) per seat."
msgstr "Het toevoegen van een nieuw lid kost een extra {price} ({billingType}) per plaats."
#: src/views/settings/components/Avatar.tsx:278
msgid "Adjust the square crop to fit your avatar."
msgstr "Pas de vierkante uitsnede aan zodat je avatar goed past."
#: src/views/home/components/Pricing.tsx:55
msgid "Admin roles"
msgstr "Beheerdersrollen"
@@ -338,6 +342,7 @@ msgstr "Bugrapport"
#: src/views/card/components/DeleteChecklistConfirmation.tsx:63
#: src/views/card/components/DeleteCommentConfirmation.tsx:76
#: src/views/members/components/DeleteMemberConfirmation.tsx:55
#: src/views/settings/components/Avatar.tsx:309
#: src/views/settings/components/ChangePasswordConfirmation.tsx:168
#: src/views/settings/components/DeleteAccountConfirmation.tsx:88
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:98
@@ -527,6 +532,10 @@ msgstr "heeft de kaart aangemaakt"
msgid "Critical"
msgstr "Kritiek"
#: src/views/settings/components/Avatar.tsx:275
msgid "Crop your avatar"
msgstr "Snijd je avatar bij"
#: src/views/settings/components/ChangePasswordConfirmation.tsx:19
msgid "Current password is required"
msgstr "Huidig wachtwoord is vereist"
@@ -749,7 +758,7 @@ msgstr "Fout bij het uitnodigen van lid"
msgid "Error updating display name"
msgstr "Fout bij bijwerken weergavenaam"
#: src/views/settings/components/Avatar.tsx:39
#: src/views/settings/components/Avatar.tsx:80
msgid "Error updating profile image"
msgstr "Fout bij het bijwerken van profielafbeelding"
@@ -774,8 +783,8 @@ msgstr "Fout bij het upgraden van abonnement"
msgid "Error upgrading to Pro"
msgstr "Fout bij upgraden naar Pro"
#: src/views/settings/components/Avatar.tsx:56
#: src/views/settings/components/Avatar.tsx:97
#: src/views/settings/components/Avatar.tsx:94
#: src/views/settings/components/Avatar.tsx:221
msgid "Error uploading profile image"
msgstr "Fout bij het uploaden van profielafbeelding"
@@ -1321,7 +1330,7 @@ msgstr "Voer een geldige naam in"
msgid "Please enter a valid password"
msgstr "Voer een geldig wachtwoord in"
#: src/views/settings/components/Avatar.tsx:57
#: src/views/settings/components/Avatar.tsx:95
msgid "Please select a file to upload."
msgstr "Selecteer een bestand om te uploaden."
@@ -1352,8 +1361,8 @@ msgstr "Selecteer een bestand om te uploaden."
#: src/views/members/components/DeleteMemberConfirmation.tsx:28
#: src/views/members/components/InviteMemberForm.tsx:78
#: src/views/members/components/InviteMemberForm.tsx:131
#: src/views/settings/components/Avatar.tsx:40
#: src/views/settings/components/Avatar.tsx:98
#: src/views/settings/components/Avatar.tsx:81
#: src/views/settings/components/Avatar.tsx:222
#: src/views/settings/components/DeleteAccountConfirmation.tsx:40
#: src/views/settings/components/DeleteWorkspaceConfirmation.tsx:46
#: src/views/settings/components/UpdateDisplayNameForm.tsx:55
@@ -1391,7 +1400,7 @@ msgstr "Pro Plan"
msgid "Pro Plan ∞"
msgstr "Pro Plan ∞"
#: src/views/settings/components/Avatar.tsx:26
#: src/views/settings/components/Avatar.tsx:67
msgid "Profile image updated"
msgstr "Profielafbeelding bijgewerkt"
@@ -1492,6 +1501,7 @@ msgid "Run on your own infrastructure"
msgstr "Draai op je eigen infrastructuur"
#: src/views/card/components/Comment.tsx:165
#: src/views/settings/components/Avatar.tsx:312
msgid "Save"
msgstr "Opslaan"
@@ -2118,7 +2128,7 @@ msgstr "Je weergavenaam is bijgewerkt."
msgid "Your password has been changed."
msgstr "Je wachtwoord is gewijzigd."
#: src/views/settings/components/Avatar.tsx:27
#: src/views/settings/components/Avatar.tsx:68
msgid "Your profile image has been updated."
msgstr "Je profielafbeelding is bijgewerkt."

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ import type { ReactElement, ReactNode } from "react";
import { Plus_Jakarta_Sans } from "next/font/google";
import Script from "next/script";
import { env } from "next-runtime-env";
import { ThemeProvider } from "next-themes";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
@@ -14,7 +15,6 @@ import { useEffect } from "react";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
import { ThemeProvider } from "next-themes";
import { api } from "~/utils/api";
const jakarta = Plus_Jakarta_Sans({
@@ -82,8 +82,8 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
<main className="font-sans">
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
@@ -91,8 +91,8 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</main>

View File

@@ -18,6 +18,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/it/messages")).messages;
case "nl":
return (await import("~/locales/nl/messages")).messages;
case "ru":
return (await import("~/locales/ru/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,14 +1,45 @@
import Image from "next/image";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import ReactCrop from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { generateUID } from "@kan/shared/utils";
import Button from "~/components/Button";
import Modal from "~/components/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { getAvatarUrl } from "~/utils/helpers";
interface PercentCrop {
unit: "%";
x: number;
y: number;
width: number;
height: number;
}
interface LocalPixelCrop {
x: number;
y: number;
width: number;
height: number;
}
interface ReactCropProps {
crop: PercentCrop | undefined;
onChange: (crop: LocalPixelCrop, percentCrop: PercentCrop) => void;
aspect?: number;
className?: string;
circularCrop?: boolean;
children: React.ReactNode;
}
const AnyReactCrop = ReactCrop as unknown as React.FC<ReactCropProps>;
export default function Avatar({
userId,
userImage,
@@ -19,6 +50,13 @@ export default function Avatar({
const utils = api.useUtils();
const { showPopup } = usePopup();
const [uploading, setUploading] = useState(false);
const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedPreviewUrl, setSelectedPreviewUrl] = useState<string | null>(
null,
);
const [crop, setCrop] = useState<PercentCrop>();
const imgRef = useRef<HTMLImageElement | null>(null);
const updateUser = api.user.update.useMutation({
onSuccess: async () => {
@@ -45,24 +83,109 @@ export default function Avatar({
const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
const uploadAvatar = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
event.preventDefault();
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
const file = event.target.files?.[0] ?? null;
if (!file || !userId) {
return showPopup({
header: t`Error uploading profile image`,
message: t`Please select a file to upload.`,
icon: "error",
});
}
// Open crop dialog with preview
setSelectedFile(file);
const objUrl = URL.createObjectURL(file);
setSelectedPreviewUrl(objUrl);
setCropDialogOpen(true);
};
const file = event.target.files?.[0];
if (!file || !userId) {
return showPopup({
header: t`Error uploading profile image`,
message: t`Please select a file to upload.`,
icon: "error",
});
const onImageLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth, naturalHeight } = e.currentTarget;
// Create a centered square crop at ~90% of the smaller dimension
// Compute width% so that the square fits within the image
let widthPercent: number;
let heightPercent: number;
if (naturalWidth >= naturalHeight) {
// landscape: height is limiting
heightPercent = 90;
widthPercent = (naturalHeight / naturalWidth) * heightPercent;
} else {
// portrait: width is limiting
widthPercent = 90;
heightPercent = (naturalWidth / naturalHeight) * widthPercent;
}
const x = (100 - widthPercent) / 2;
const y = (100 - heightPercent) / 2;
setCrop({ unit: "%", x, y, width: widthPercent, height: heightPercent });
},
[],
);
const fileExt = file.name.split(".").pop();
const fileName = `${userId}/avatar-${generateUID()}.${fileExt}`;
const getCroppedBlob = useCallback(async (): Promise<Blob> => {
if (!imgRef.current || !crop) throw new Error("No crop to save");
const image = imgRef.current;
const canvas = document.createElement("canvas");
const cropXpx = (crop.x / 100) * image.naturalWidth;
const cropYpx = (crop.y / 100) * image.naturalHeight;
const cropWpx = (crop.width / 100) * image.naturalWidth;
const cropHpx = (crop.height / 100) * image.naturalHeight;
canvas.width = Math.max(1, Math.floor(cropWpx));
canvas.height = Math.max(1, Math.floor(cropHpx));
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not supported");
// For better quality on HiDPI screens
const pixelRatio = window.devicePixelRatio || 1;
canvas.width = canvas.width * pixelRatio;
canvas.height = canvas.height * pixelRatio;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.imageSmoothingQuality = "high";
ctx.drawImage(
image,
cropXpx,
cropYpx,
cropWpx,
cropHpx,
0,
0,
canvas.width / pixelRatio,
canvas.height / pixelRatio,
);
const mime = selectedFile?.type ?? "image/jpeg";
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("toBlob failed"))),
mime,
);
});
return blob;
}, [crop, selectedFile]);
const resetCropState = useCallback(() => {
setCrop(undefined);
setSelectedFile(null);
if (selectedPreviewUrl) URL.revokeObjectURL(selectedPreviewUrl);
setSelectedPreviewUrl(null);
}, [selectedPreviewUrl]);
const handleCancelCrop = useCallback(() => {
setCropDialogOpen(false);
resetCropState();
}, [resetCropState]);
const handleSaveCrop = useCallback(async () => {
try {
if (!userId || !selectedFile) return;
setUploading(true);
const blob = await getCroppedBlob();
const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
const response = await fetch(
env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
@@ -71,26 +194,24 @@ export default function Avatar({
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ filename: fileName, contentType: file.type }),
body: JSON.stringify({ filename: fileName, contentType: blob.type }),
},
);
if (!response.ok) throw new Error("Failed to get pre-signed URL");
const { url } = (await response.json()) as {
url: string;
};
const { url } = (await response.json()) as { url: string };
const uploadResponse = await fetch(url, {
method: "PUT",
body: file,
body: blob,
});
if (!uploadResponse.ok) throw new Error("Failed to upload profile image");
updateUser.mutate({
image: fileName,
});
updateUser.mutate({ image: fileName });
setCropDialogOpen(false);
resetCropState();
} catch (error) {
console.error(error);
showPopup({
@@ -101,7 +222,14 @@ export default function Avatar({
} finally {
setUploading(false);
}
};
}, [
getCroppedBlob,
resetCropState,
selectedFile,
showPopup,
updateUser,
userId,
]);
return (
<div>
@@ -111,7 +239,7 @@ export default function Avatar({
type="file"
id="single"
accept="image/*"
onChange={uploadAvatar}
onChange={onFileChange}
disabled={uploading}
/>
{avatarUrl ? (
@@ -134,6 +262,56 @@ export default function Avatar({
</span>
)}
</div>
{/* Crop Dialog */}
{cropDialogOpen && (
<Modal modalSize="md" positionFromTop="sm" isVisible>
<div className="p-4 sm:p-6">
<div className="mb-4">
<h3 className="text-base font-semibold text-light-1000 dark:text-dark-1000">
{t`Crop your avatar`}
</h3>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`Adjust the square crop to fit your avatar.`}
</p>
</div>
<div className="max-h-[80vh]">
<div className="rounded-md border border-light-600 p-2 dark:border-dark-600">
<AnyReactCrop
crop={crop}
onChange={(_crop: LocalPixelCrop, percentCrop: PercentCrop) =>
setCrop(percentCrop)
}
aspect={1}
circularCrop
className="w-full"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={selectedPreviewUrl ?? undefined}
alt="Avatar to crop"
onLoad={onImageLoad}
className="h-auto max-h-[50vh] w-full object-contain"
/>
</AnyReactCrop>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button
variant="ghost"
onClick={handleCancelCrop}
disabled={uploading}
>
{t`Cancel`}
</Button>
<Button onClick={handleSaveCrop} isLoading={uploading}>
{t`Save`}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
}

View File

@@ -59,9 +59,32 @@ export const create = async (
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
throw new Error(
`Duplicate indices found after reordering in board ${result.boardId}`,
);
// Compact indices to sequential values (0..n-1) to resolve duplicates while preserving order
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "list"
WHERE "boardId" = ${result.boardId} AND "deletedAt" IS NULL
)
UPDATE "list" l
SET "index" = o.new_index
FROM ordered o
WHERE l.id = o.id;
`);
// Last resort: verify fix; if duplicates persist (e.g., due to race conditions), rollback
const postFixDupes = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, result.boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate indices remain after compaction in board ${result.boardId}`,
);
}
}
return result;
@@ -79,7 +102,98 @@ export const bulkCreate = async (
importId?: number;
}[],
) => {
return db.insert(lists).values(listInput).returning();
if (listInput.length === 0) return [];
return db.transaction(async (tx) => {
// Group incoming rows by board to compute safe, sequential indices per board
const byBoard = new Map<number, typeof listInput>();
for (const item of listInput) {
const arr = byBoard.get(item.boardId) ?? [];
arr.push(item);
byBoard.set(item.boardId, arr);
}
const allValuesToInsert: {
publicId: string;
name: string;
createdBy: string;
boardId: number;
index: number;
importId?: number;
}[] = [];
// For each board, append incoming lists after the current max index, preserving their relative order
for (const [boardId, items] of byBoard.entries()) {
// Find current max index for non-deleted lists in this board
const last = await tx.query.lists.findFirst({
columns: { index: true },
where: and(eq(lists.boardId, boardId), isNull(lists.deletedAt)),
orderBy: [desc(lists.index)],
});
let nextIndex = last ? last.index + 1 : 0;
// Sort incoming by their provided index to preserve Trello order, then reassign sequential indices
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const it of sorted) {
allValuesToInsert.push({
publicId: it.publicId,
name: it.name,
createdBy: it.createdBy,
boardId: it.boardId,
index: nextIndex++,
importId: it.importId,
});
}
}
// Insert all rows in one go
const inserted = await tx
.insert(lists)
.values(allValuesToInsert)
.returning();
// Post-insert check: if duplicates exist, compact indices per board instead of failing
const countExpr = sql<number>`COUNT(*)`.mapWith(Number);
for (const boardId of byBoard.keys()) {
const duplicateIndices = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "list"
WHERE "boardId" = ${boardId} AND "deletedAt" IS NULL
)
UPDATE "list" l
SET "index" = o.new_index
FROM ordered o
WHERE l.id = o.id;
`);
// Last resort: verify fix; if duplicates persist (e.g., due to race conditions), rollback
const postFixDupes = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate indices remain after compaction in board ${boardId}`,
);
}
}
}
return inserted;
});
};
export const getByPublicId = async (db: dbClient, listPublicId: string) => {
@@ -180,9 +294,32 @@ export const reorder = async (
.having(gt(countExpr, 1));
if (duplicateIndices.length > 0) {
throw new Error(
`Duplicate indices found after reordering in board ${list.boardId}`,
);
// Attempt to auto-heal by compacting indices to sequential values (0..n-1) while preserving order
await tx.execute(sql`
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
FROM "list"
WHERE "boardId" = ${list.boardId} AND "deletedAt" IS NULL
)
UPDATE "list" l
SET "index" = o.new_index
FROM ordered o
WHERE l.id = o.id;
`);
// Last resort verification: if duplicates persist, rollback
const postFixDupes = await tx
.select({ index: lists.index, count: countExpr })
.from(lists)
.where(and(eq(lists.boardId, list.boardId), isNull(lists.deletedAt)))
.groupBy(lists.index)
.having(gt(countExpr, 1));
if (postFixDupes.length > 0) {
throw new Error(
`Invariant violation: duplicate indices remain after compaction in board ${list.boardId}`,
);
}
}
const updatedList = await tx.query.lists.findFirst({

12
pnpm-lock.yaml generated
View File

@@ -205,6 +205,9 @@ importers:
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@18.3.1)
react-image-crop:
specifier: ^11.0.10
version: 11.0.10(react@18.3.1)
react-lottie-player:
specifier: ^1.5.5
version: 1.5.6(react@18.3.1)
@@ -6116,6 +6119,11 @@ packages:
peerDependencies:
react: '*'
react-image-crop@11.0.10:
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
peerDependencies:
react: '>=16.13.1'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -13696,6 +13704,10 @@ snapshots:
dependencies:
react: 18.3.1
react-image-crop@11.0.10(react@18.3.1):
dependencies:
react: 18.3.1
react-is@16.13.1: {}
react-is@17.0.2: {}