Compare commits

..

2 Commits

Author SHA1 Message Date
Maksim Eltyshev
cec09b2db2 chore: Update version 2025-09-04 13:53:03 +02:00
Maksim Eltyshev
94410ee994 fix: Patch react-photoswipe-gallery to prevent XSS in captions 2025-09-04 13:50:54 +02:00
849 changed files with 11796 additions and 53366 deletions

View File

@@ -1,23 +1,33 @@
**/.DS_Store
*/README.md
*/.gitignore
*/node_modules
server/swagger.json
**/.DS_Store
server/**/.gitkeep
server/.env
server/dist
server/logs
server/.editorconfig
server/.eslintignore
server/.npmrc
server/test
server/.tmp
server/logs
server/.venv
server/.tmp
server/views/index.html
server/views/*
server/data/*
!server/data/.gitkeep
server/public/*
!server/public/preloaded-favicons
!server/public/favicons
server/public/favicons/*
!server/public/user-avatars
server/public/user-avatars/*
!server/public/background-images
server/public/background-images/*
server/terms/*
!server/terms/_template
!server/terms/cloud
server/private/*
!server/private/attachments
server/private/attachments/*
client/dist

View File

@@ -5,12 +5,11 @@ body:
- type: dropdown
id: idea-type
attributes:
label: Which part of the project does this feature apply to?
label: Is this a feature for the backend or frontend?
multiple: true
options:
- Backend
- Frontend
- Chart
validations:
required: true
- type: textarea

View File

@@ -15,20 +15,12 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '18'
cache: 'npm'
- name: Update npm
run: npm install npm --global
- name: Install server dependencies
run: npm install --omit=prod --ignore-scripts
working-directory: ./server
- name: Build server
run: npm run build
working-directory: ./server
- name: Install client dependencies
run: npm install --omit=dev
working-directory: ./client
@@ -37,25 +29,24 @@ jobs:
run: DISABLE_ESLINT_PLUGIN=true npm run build
working-directory: ./client
- name: Include licenses into dist
run: |
mv LICENSE.md server/dist
mv "LICENSES/PLANKA Community License DE.md" server/dist/LICENSE_DE.md
- name: Include server into dist
run: mv server dist
- name: Include built client into dist
run: |
mv ../../client/dist/* public
cp public/index.html views
working-directory: ./server/dist
mv dist/* ../dist/public
cp ../dist/public/index.html ../dist/views
working-directory: ./client
- name: Include LICENSE.md, README.md, SECURITY.md into dist
run: mv LICENSE.md README.md SECURITY.md dist
- name: Create release package
run: |
mv dist planka
zip -r planka-prebuild.zip planka
working-directory: ./server
- name: Publish release package
run: gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
working-directory: ./server

View File

@@ -12,30 +12,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install client dependencies
run: npm install --omit=dev
working-directory: ./client
- name: Build client
run: |
DISABLE_ESLINT_PLUGIN=true npm run build
mv dist build
working-directory: ./client
- name: Update Dockerfile to use prebuilt client
run: |
sed -i '/^FROM node:22 AS client/,/^ && DISABLE_ESLINT_PLUGIN=true npm run build$/c\
FROM node:22 AS client\n\
WORKDIR /app\n\
COPY client/build /app/dist' Dockerfile
cat Dockerfile
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -64,10 +40,6 @@ jobs:
name=ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ steps.set-version.outputs.result }}
type=raw,value=latest
labels: |
org.opencontainers.image.licenses=Fair Use License
org.opencontainers.image.url=https://planka.app
- name: Build and push Docker image
uses: docker/build-push-action@v4

View File

@@ -17,38 +17,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Update version with build number
run: |
npm version "$(node -p "require('./package.json').version")-nightly.$(git rev-list --count HEAD)" --no-git-tag-version
npx --yes genversion --source . --template server/version-template.ejs server/version.js
npx --yes genversion --source . --template client/version-template.ejs client/src/version.js
- name: Install client dependencies
run: npm install --omit=dev
working-directory: ./client
- name: Build client
run: |
DISABLE_ESLINT_PLUGIN=true npm run build
mv dist build
working-directory: ./client
- name: Update Dockerfile to use prebuilt client
run: |
sed -i '/^FROM node:22 AS client/,/^ && DISABLE_ESLINT_PLUGIN=true npm run build$/c\
FROM node:22 AS client\n\
WORKDIR /app\n\
COPY client/build /app/dist' Dockerfile
cat Dockerfile
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -71,9 +39,6 @@ jobs:
name=ghcr.io/${{ github.repository }}
tags: |
type=raw,value=nightly
labels: |
org.opencontainers.image.licenses=Fair Use License
org.opencontainers.image.url=https://planka.app
- name: Build and push Docker image
uses: docker/build-push-action@v4

View File

@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
env:
POSTGRES_USERNAME: planka
POSTGRES_PASSWORD: planka
POSTGRES_DATABASE: planka
POSTGRES_DB: planka
POSTGRES_USER: user
POSTGRES_PASSWORD: password
steps:
- name: Checkout repository
@@ -24,15 +24,15 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '18'
cache: 'npm'
- name: Set up PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v5
with:
username: ${{ env.POSTGRES_USERNAME }}
database: ${{ env.POSTGRES_DB }}
username: ${{ env.POSTGRES_USER }}
password: ${{ env.POSTGRES_PASSWORD }}
database: ${{ env.POSTGRES_DATABASE }}
- name: Cache Node.js modules
uses: actions/cache@v3
@@ -58,7 +58,7 @@ jobs:
client/tests/setup-symlinks.sh
cd server
cp .env.sample .env
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DATABASE}|" .env
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DB}|" .env
npm run db:init
npm start --prod &
@@ -67,12 +67,6 @@ jobs:
sudo apt-get install wait-for-it -y
wait-for-it -h localhost -p 1337 -t 10
- name: Seed database with terms signature
run: |
TERMS_SIGNATURE=$(sha256sum terms/_template/en-US.md | awk '{print $1}')
PGPASSWORD=$POSTGRES_PASSWORD psql -h localhost -U $POSTGRES_USERNAME -d $POSTGRES_DATABASE -c "UPDATE user_account SET terms_signature = '$TERMS_SIGNATURE';"
working-directory: ./server
- name: Run UI tests
run: |
npx playwright install chromium

View File

@@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '18'
cache: 'npm'
- name: Cache Node.js modules

4
.gitignore vendored
View File

@@ -1,9 +1,9 @@
node_modules
docker-compose.override.yml
.idea
.DS_Store
node_modules
# Prevent another lockfile than package-lock.json (npm) from being created
# If some case you are using pnpm or yarn, don't forget to generate npm lockfile
# before commiting your code by running:

View File

@@ -1,55 +1,52 @@
# Stage 1: Server build
FROM node:22-alpine AS server
FROM node:18-alpine AS server-dependencies
RUN apk -U upgrade \
&& apk add build-base python3 --no-cache
WORKDIR /app
COPY server .
COPY server/package.json server/package-lock.json server/requirements.txt ./
RUN npm install npm --global \
&& npm install \
&& npm run build \
&& npm prune --production
&& npm install --omit=dev
# Stage 2: Client build
FROM node:22 AS client
FROM node:lts AS client
WORKDIR /app
COPY client .
RUN npm install npm --global \
&& npm install --omit=dev \
&& DISABLE_ESLINT_PLUGIN=true npm run build
&& npm install --omit=dev
# Stage 3: Final image
FROM node:22-alpine
RUN DISABLE_ESLINT_PLUGIN=true npm run build
FROM node:18-alpine
RUN apk -U upgrade \
&& apk add bash python3 squid --no-cache \
&& apk add bash python3 --no-cache \
&& npm install npm --global
USER node
WORKDIR /app
COPY --chown=node:node LICENSE.md .
COPY --chown=node:node ["LICENSES/PLANKA Community License DE.md", "LICENSE_DE.md"]
COPY --from=server --chown=node:node /app/node_modules node_modules
COPY --from=server --chown=node:node /app/dist .
COPY --from=client --chown=node:node /app/dist public
COPY --from=client --chown=node:node /app/dist/index.html views
COPY --chown=node:node server .
RUN python3 -m venv .venv \
&& .venv/bin/pip3 install --upgrade pip \
&& .venv/bin/pip3 install -r requirements.txt --no-cache-dir \
&& mv .env.sample .env \
&& npm config set update-notifier false
VOLUME /app/data
COPY --from=server-dependencies --chown=node:node /app/node_modules node_modules
COPY --from=client --chown=node:node /app/dist public
COPY --from=client --chown=node:node /app/dist/index.html views
VOLUME /app/public/favicons
VOLUME /app/public/user-avatars
VOLUME /app/public/background-images
VOLUME /app/private/attachments
EXPOSE 1337
HEALTHCHECK --interval=10s --timeout=2s --start-period=15s \

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:lts-alpine
RUN apk -U upgrade \
&& apk add bash build-base python3 xdg-utils --no-cache \

View File

@@ -48,7 +48,7 @@ You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (
### Restricted Use
Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for third parties for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license.

View File

@@ -1,6 +1,6 @@
**PLANKA Commercial License**
Version 1.2 - Zuletzt aktualisiert: 28. Nov 2025
Version 1.1 - Zuletzt aktualisiert: 20. Mai 2025
Zugehörige Dateien in Englisch:
@@ -20,7 +20,7 @@ Zugehörige Dateien in Deutsch:
Copyright (c) 2025 bis heute von PLANKA Software GmbH.
Unsere Software und zugehörige Dokumentationsdateien (die "Software") dürfen nur dann produktiv genutzt werden, wenn Sie (und jede juristische Person, die Sie vertreten) eine gültige "PLANKA Pro/Enterprise-Lizenz" besitzen, die Ihrer Nutzung entspricht. Sie stimmen zu, dass die PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen und/oder Patches behalten, und alle solche Modifikationen und/oder Patches dürfen nur mit einer gültigen "PLANKA Pro/Enterprise-Lizenz" für die entsprechende Nutzung verwendet, kopiert, modifiziert, angezeigt, verteilt oder anderweitig genutzt werden. Ungeachtet des Vorstehenden dürfen Sie die Software für Entwicklungs- und Testzwecke ohne Abonnement kopieren und modifizieren. Sie stimmen zu, dass PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen behalten. Es werden Ihnen keine anderen Rechte gewährt als die, die hier ausdrücklich genannt sind. Vorbehaltlich des Vorstehenden ist es verboten, die Software zu kopieren, zusammenzuführen, zu veröffentlichen, zu verteilen, zu unterlizenzieren und/oder zu verkaufen.
Unsere Software und zugehörige Dokumentationsdateien (die "Software") dürfen nur dann produktiv genutzt werden, wenn Sie (und jede juristische Person, die Sie vertreten) eine gültige "PLANKA Pro/Enterprise-Lizenz" besitzen, die Ihrer Nutzung entspricht. Vorbehaltlich des vorstehenden Satzes steht es Ihnen frei, unsere Software zu modifizieren und Patches dafür zu veröffentlichen. Sie stimmen zu, dass die PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen und/oder Patches behalten, und alle solche Modifikationen und/oder Patches dürfen nur mit einer gültigen "PLANKA Pro/Enterprise-Lizenz" für die entsprechende Nutzung verwendet, kopiert, modifiziert, angezeigt, verteilt oder anderweitig genutzt werden. Ungeachtet des Vorstehenden dürfen Sie die Software für Entwicklungs- und Testzwecke ohne Abonnement kopieren und modifizieren. Sie stimmen zu, dass PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen behalten. Es werden Ihnen keine anderen Rechte gewährt als die, die hier ausdrücklich genannt sind. Vorbehaltlich des Vorstehenden ist es verboten, die Software zu kopieren, zusammenzuführen, zu veröffentlichen, zu verteilen, zu unterlizenzieren und/oder zu verkaufen.
#### Komponenten von Drittanbietern
@@ -28,11 +28,7 @@ Für alle Komponenten von Drittanbietern, die in unsere Software integriert sind
## PLANKA Pro/Enterprise Repositories
Nach dem Erwerb einer "PLANKA Pro/Enterprise-Lizenz" erhalten Sie Zugang zu unseren "PLANKA Pro/Enterprise"-Repositories. Dort finden Sie unsere neuesten stabilen Builds, die umfangreiche Tests durchlaufen haben und als produktionsreif gelten.
Wichtiger Hinweis zum Zugriffsumfang: Der Standardzugang umfasst ausschließlich die vorkompilierten Versionen unserer Software. Zugang zum Quellcode wird nur in Ausnahmefällen und nach gesonderter Vereinbarung mit PLANKA Software GmbH gewährt.
Unabhängig vom Zugriffsumfang gilt: Die Weitergabe von Dateien, Quellcode oder Teilen davon aus unseren "PLANKA Pro/Enterprise"-Repositories an Dritte, die nicht zugriffsberechtigt sind, ist ohne vorherige schriftliche Genehmigung von PLANKA Software GmbH untersagt.
Nach dem Kauf unserer "PLANKA Pro/Enterprise-Lizenz" erhalten Sie Zugang zu unseren "PLANKA Pro/Enterprise"-Repositories. Hier finden Sie unsere neuesten stabilen Builds, die umfangreiche, eingehende Tests bestanden haben und als kampferprobt gelten. Unter keinen Umständen dürfen Sie Dateien, Quellcode oder Teile aus unseren "PLANKA Pro/Enterprise"-Repositories ohne vorherige Genehmigung der PLANKA Software GmbH an Personen weitergeben, die nicht zugangsberechtigt sind.
## Eingeschränkte Garantie

View File

@@ -1,6 +1,6 @@
**PLANKA Commercial License**
Version 1.2 - Last updated: Nov 28, 2025
Version 1.1 - Last updated: May 20, 2025
Related files in English:
@@ -20,7 +20,7 @@ Related files in German:
Copyright (c) 2025 to present by PLANKA Software GmbH.
Our software and associated documentation files (the "Software") may only be used in production if you (and any entity that you represent) hold a valid "PLANKA Pro/Enterprise License" corresponding to your usage. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid "PLANKA Pro/Enterprise License" for the corresponding usage. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes without requiring a subscription. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software.
Our software and associated documentation files (the "Software") may only be used in production if you (and any entity that you represent) hold a valid "PLANKA Pro/Enterprise License" corresponding to your usage. Subject to the foregoing sentence, you are free to modify our Software and publish patches for it. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid "PLANKA Pro/Enterprise License" for the corresponding usage. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes without requiring a subscription. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software.
#### Third-Party Components
@@ -28,11 +28,7 @@ For all third-party components incorporated into our Software, those components
## PLANKA Pro/Enterprise Repositories
After purchasing a "PLANKA Pro/Enterprise License", you will receive access to our "PLANKA Pro/Enterprise" repositories. There you will find our latest stable builds, which have undergone extensive testing and are considered production-ready.
Important note on access scope: Standard access includes only the precompiled versions of our software. Access to the source code is granted only in exceptional cases and requires a separate agreement with PLANKA Software GmbH.
Regardless of access scope: The distribution of files, source code, or any parts thereof from our "PLANKA Pro/Enterprise" repositories to third parties who are not authorized for access is prohibited without prior written permission from PLANKA Software GmbH.
After purchasing our "PLANKA Pro/Enterprise License", you get access to our "PLANKA Pro/Enterprise" repositories. Here you find our latest stable builds, which have passed extensive in-depth tests and are considered battle-proof. Under no circumstances are you allowed to pass files, source code, or any part of it from our "PLANKA Pro/Enterprise" repositories to anyone not eligible for access without prior permission from PLANKA Software GmbH.
## Limited Warranty

View File

@@ -48,7 +48,7 @@ Sie dürfen PLANKA nutzen oder modifizieren (a) für persönliche, Hobby- oder B
### Eingeschränkte Nutzung
Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für Dritte zu jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil.
Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil.
Für alle anderen PLANKA-basierten Hosting-Dienste oder die gemeinsame Nutzung von PLANKA-Konten zwischen Organisationen müssen Sie eine kommerzielle PLANKA-Lizenz erwerben.

View File

@@ -48,7 +48,7 @@ You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (
### Restricted Use
Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for third parties for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license.

View File

@@ -52,7 +52,7 @@ Die Lizenz gewährt Ihnen das kostenlose Recht, die Software zu nutzen, zu modif
- Sie dürfen PLANKA nutzen oder modifizieren (a) für persönliche, Hobby- oder Bildungszwecke, (b) intern innerhalb Ihrer eigenen Organisation, (c) für privates Hosting für eine typische Anzahl von Freunden, Familie oder persönlichen Projekten, (d) um gemeinnützigen Organisationen (wie von den jeweiligen Steuerbehörden anerkannt) kostenlosen Zugang zu gewähren, oder wenn Sie selbst eine anerkannte gemeinnützige Organisation sind, die externe Nutzer in Ihre Mission einbezieht, und (e) öffentliche Bildungseinrichtungen ausschließlich für akademische/Forschungszwecke.
- Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für Dritte zu jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil.
- Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil.
- Sie dürfen keine vom Lizenzgeber bereitgestellten Lizenz-, Urheber- oder anderen Hinweise in der Software verändern, entfernen oder verschleiern. Jede Nutzung der Marken des Lizenzgebers unterliegt dem geltenden Recht.

View File

@@ -52,7 +52,7 @@ The license allows you the free right to use, modify, create derivative works, a
- You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (b) internally within your own organization, (c) for private hosting for a typical number of friends, family, or personal projects, (d) to provide free access to non-profit organizations (as recognized by applicable tax authorities), or if you are a recognized non-profit organization yourself involving external users into your mission, and (e) public educational institutions for academic/research purposes only.
- Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for third parties for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
- Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
- You may not alter, remove, or obscure any licensing, copyright, or other notices from the software provided by the licensor. Any use of the licensor's trademarks is subject to applicable law.

View File

@@ -1,27 +1,23 @@
<div align="center">
# PLANKA
![Logo](https://raw.githubusercontent.com/plankanban/planka/master/assets/logo.png)
**Project mastering driven by fun**
# PLANKA
![Version](https://img.shields.io/github/package-json/v/plankanban/planka?style=flat-square) [![Docker Pulls](https://img.shields.io/badge/docker_pulls-6M%2B-%23066da5?style=flat-square&color=red)](https://github.com/plankanban/planka/pkgs/container/planka) [![Contributors](https://img.shields.io/github/contributors/plankanban/planka?style=flat-square&color=blue)](https://github.com/plankanban/planka/graphs/contributors) [![Chat](https://img.shields.io/discord/1041440072953765979?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/WqqYNd7Jvt)
_Project mastering driven by fun_
![Demo](https://raw.githubusercontent.com/plankanban/planka/master/assets/demo.gif)
![Version](https://img.shields.io/github/package-json/v/plankanban/planka?style=flat-square) [![Docker Pulls](https://img.shields.io/badge/docker_pulls-8M%2B-%23066da5?style=flat-square&color=red)](https://github.com/plankanban/planka/pkgs/container/planka) [![Contributors](https://img.shields.io/github/contributors/plankanban/planka?style=flat-square&color=blue)](https://github.com/plankanban/planka/graphs/contributors) [![Chat](https://img.shields.io/discord/1041440072953765979?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/WqqYNd7Jvt)
[**Client demo**](https://plankanban.github.io/planka) (without server features).
[Install](https://docs.planka.cloud/docs/installation/docker/production-version/) · [Demo](https://planka.app) · [Docs](https://docs.planka.cloud/docs/welcome/) · [API](https://plankanban.github.io/planka/swagger-ui/) · [Cloud](https://planka.app/pricing) · [Pro version](https://planka.app/pro)
![Demo](https://raw.githubusercontent.com/plankanban/planka/master/assets/demo.gif)
</div>
> ⚠️ The demo GIF and client demo are based on **v1** and will be updated soon.
## Key Features
- **Collaborative Kanban Boards:** Create projects, boards, lists, cards, and manage tasks with an intuitive drag-and-drop interface
- **Real-Time Updates:** Instant syncing across all users, no refresh needed
- **Rich Markdown Support:** Write beautifully formatted card descriptions with a powerful markdown editor
- **Flexible Notifications:** Get alerts through 100+ providers, fully customizable to your workflow
- **Seamless Authentication:** Single sign-on with OpenID Connect integration
- **Multilingual & Easy to Translate:** Full internationalization support for a global audience
- **Collaborative Kanban Boards**: Create projects, boards, lists, cards, and manage tasks with an intuitive drag-and-drop interface
- **Real-Time Updates**: Instant syncing across all users, no refresh needed
- **Rich Markdown Support**: Write beautifully formatted card descriptions with a powerful markdown editor
- **Flexible Notifications**: Get alerts through 100+ providers, fully customizable to your workflow
- **Seamless Authentication**: Single sign-on with OpenID Connect integration
- **Multilingual & Easy to Translate**: Full internationalization support for a global audience
## How to Deploy
@@ -29,17 +25,10 @@ PLANKA is easy to install using multiple methods - learn more in the [installati
For configuration and environment settings, see the [configuration section](https://docs.planka.cloud/docs/category/configuration/).
Interested in a hosted or [Pro version](https://planka.app/pro) of PLANKA? Check out the pricing on our [website](https://planka.app/pricing).
## Notes App
A testing version of the Notes app is now available on multiple platforms:
- **iOS:** Join the [TestFlight](https://testflight.apple.com/join/5eJqTaJW) to try the app
- **Windows & Android:** Download the app [here](https://planka-notes.hillerdaniel.de)
## Contact
Interested in a hosted version of PLANKA? Email us at [github@planka.group](mailto:github@planka.group).
For any security issues, please do not create a public issue on GitHub - instead, report it privately by emailing [security@planka.group](mailto:security@planka.group).
**Note:** We do NOT offer any public support via email, please use GitHub.
@@ -50,10 +39,10 @@ For any security issues, please do not create a public issue on GitHub - instead
PLANKA is [fair-code](https://faircode.io) distributed under the [Fair Use License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) and [PLANKA Pro/Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md).
- **Source Available:** The source code is always visible
- **Self-Hostable:** Deploy and host it anywhere
- **Extensible:** Customize with your own functionality
- **Enterprise Licenses:** Available for additional features and support
- **Source Available**: The source code is always visible
- **Self-Hostable**: Deploy and host it anywhere
- **Extensible**: Customize with your own functionality
- **Enterprise Licenses**: Available for additional features and support
For more details, check the [License Guide](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -15,13 +15,13 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 2.0.2
version: 1.0.3
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "2.0.2"
appVersion: "2.0.0-rc.4"
dependencies:
- alias: postgresql

View File

@@ -68,7 +68,7 @@ helm install planka . --set secretkey=$SECRETKEY \
or create a values.yaml file like:
````yaml
```yaml
secretkey: "<InsertSecretKey>"
# The admin section needs to be present for new instances of PLANKA, after the first start you can remove the lines starting with admin_. If you want the admin user to be unchangeable admin_email: has to stay
# After changing the config you have to run ```helm upgrade planka . -f values.yaml```
@@ -89,11 +89,11 @@ ingress:
- path: /
pathType: ImplementationSpecific
# Needed for HTTPS
# Needed for HTTPS
tls:
- secretName: planka-tls # existing TLS secret in k8s
hosts:
- planka.example.dev
- secretName: planka-tls # existing TLS secret in k8s
hosts:
- planka.example.dev
```
```bash
@@ -109,185 +109,3 @@ If you want to host PLANKA for more than just playing around with, you might wan
- Specify a password for `postgresql.auth.password` as there have been issues with the postgresql chart generating new passwords locking you out of the data you've already stored. (see [this issue](https://github.com/bitnami/charts/issues/2061))
Any questions or concerns, [raise an issue](https://github.com/Chris-Greaves/planka-helm-chart/issues/new).
## Advanced Configuration
### Extra Volume Mounts
The Helm chart supports mounting arbitrary ConfigMaps, Secrets, and Volumes to the PLANKA deployment using the `extraMounts` configuration. This is especially useful for scenarios like:
- Mounting custom CA certificates for OIDC with self-hosted identity providers
- Adding custom configuration files
- Mounting TLS certificates from existing secrets
- Adding temporary or persistent storage volumes
**Note**: ConfigMaps and Secrets must be created separately before referencing them in `extraMounts`.
#### Basic Usage
Use the `extraMounts` section to mount any type of volume:
```yaml
extraMounts:
# Mount CA certificate from existing ConfigMap
- name: ca-certs
mountPath: /etc/ssl/certs/custom-ca.crt
subPath: ca.crt
readOnly: true
configMap:
name: ca-certificates # Must exist
# Mount TLS certificates from existing Secret
- name: tls-certs
mountPath: /etc/ssl/private
readOnly: true
secret:
secretName: planka-tls-secret # Must exist
items:
- key: tls.crt
path: server.crt
- key: tls.key
path: server.key
# Temporary storage
- name: temp-storage
mountPath: /tmp/planka-temp
readOnly: false
emptyDir:
sizeLimit: 1Gi
# Host path mount
- name: backup-storage
mountPath: /var/lib/planka-backups
readOnly: false
hostPath:
path: /var/lib/planka-backups
type: DirectoryOrCreate
# NFS mount
- name: nfs-storage
mountPath: /shared/data
readOnly: false
nfs:
server: nfs.example.com
path: /exports/planka
```
### OIDC with Self-Hosted Keycloak
A common use case is configuring OIDC with a self-hosted Keycloak instance that uses custom CA certificates.
First, create the CA certificate ConfigMap:
```bash
kubectl create configmap ca-certificates --from-file=ca.crt=/path/to/your/ca.crt
```
Then configure the chart:
```yaml
# Mount custom CA certificate from existing ConfigMap
extraMounts:
- name: keycloak-ca
mountPath: /etc/ssl/certs/keycloak-ca.crt
subPath: ca.crt
readOnly: true
configMap:
name: ca-certificates
# Configure Node.js to trust the custom CA
extraEnv:
- name: NODE_EXTRA_CA_CERTS
value: "/etc/ssl/certs/keycloak-ca.crt"
# Enable OIDC
oidc:
enabled: true
clientId: "planka-client"
clientSecret: "your-client-secret"
issuerUrl: "https://keycloak.example.com/realms/master"
admin:
roles:
- "planka-admin"
```
### Environment Variables from Secrets
You can reference values from existing secrets in environment variables:
```yaml
extraEnv:
- name: SMTP_PASSWORD
valueFrom:
secretName: smtp-credentials
key: password
- name: CUSTOM_API_KEY
valueFrom:
secretName: api-credentials
key: api-key
```
### Image Digest Pinning
For enhanced security and reproducibility, you can pin the container image using its SHA256 digest instead of relying solely on tags. This ensures you always deploy the exact same image, preventing tag mutations or accidental updates.
#### Finding the Image Digest
You can find the digest of a specific image tag using:
```bash
docker inspect ghcr.io/plankanban/planka:latest --format='{{index .RepoDigests 0}}'
# Output: ghcr.io/plankanban/planka@sha256:abc123def456...
# Or with skopeo
skopeo inspect docker://ghcr.io/plankanban/planka:latest
```
#### Usage
You can use digest pinning in several ways:
**Option 1: Digest with tag (recommended)**
Includes the tag for reference while using the digest for verification:
```bash
helm install planka . --set secretkey=$SECRETKEY \
--set image.tag=latest \
--set image.digest=abc123def456... \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo"
```
Or in values.yaml:
```yaml
image:
repository: ghcr.io/plankanban/planka
tag: latest
digest: "abc123def456ab89cd12ef34ab56cd78ef90ab12cd34ef56ab78cd90ef12ab34"
```
**Option 2: Digest only**
If you prefer to pin only by digest without specifying a tag:
```yaml
image:
repository: ghcr.io/plankanban/planka
tag: "" # Empty - digest alone identifies the image
digest: "abc123def456ab89cd12ef34ab56cd78ef90ab12cd34ef56ab78cd90ef12ab34"
```
#### Security Benefits
- **Immutability**: Ensures you always deploy the exact same image
- **Supply Chain Security**: Protects against tag mutations or registry compromise
- **Reproducibility**: Makes deployments fully reproducible across environments
- **Audit Trail**: Provides clear image identity in deployment manifests
### Complete Example
See `values-example.yaml` for a comprehensive example that demonstrates all the advanced features including OIDC configuration with custom CA certificates.

View File

@@ -4,10 +4,6 @@ metadata:
name: {{ include "planka.fullname" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
{{- with .Values.deploymentAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
@@ -39,16 +35,7 @@ spec:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
{{- $imageTag := .Values.image.tag | default .Chart.AppVersion }}
{{- if .Values.image.digest }}
{{- if $imageTag }}
image: "{{ .Values.image.repository }}:{{ $imageTag }}@sha256:{{ .Values.image.digest }}"
{{- else }}
image: "{{ .Values.image.repository }}@sha256:{{ .Values.image.digest }}"
{{- end }}
{{- else }}
image: "{{ .Values.image.repository }}:{{ $imageTag }}"
{{- end }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
@@ -63,32 +50,22 @@ spec:
path: /
port: http
volumeMounts:
- mountPath: /app/data
subPath: data
- mountPath: /app/public/favicons
subPath: favicons
name: planka
- mountPath: /app/public/user-avatars
subPath: user-avatars
name: planka
- mountPath: /app/public/background-images
subPath: background-images
name: planka
- mountPath: /app/private/attachments
subPath: attachments
name: planka
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- mountPath: /app/logs
subPath: app-logs
name: emptydir
- mountPath: /app/.tmp
subPath: app-tmp
name: emptydir
- mountPath: /tmp
subPath: tmp
name: emptydir
{{- end }}
{{- /* Extra volume mounts */}}
{{- range .Values.extraMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
{{- if .subPath }}
subPath: {{ .subPath }}
{{- end }}
{{- if hasKey . "readOnly" }}
readOnly: {{ .readOnly }}
{{- else }}
readOnly: true
{{- end }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
@@ -196,9 +173,6 @@ spec:
value: {{ .Values.oidc.admin.ignoreRoles | quote }}
{{- end }}
{{- end }}
{{- if .Values.extraContainers -}}
{{ toYaml .Values.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -222,31 +196,4 @@ spec:
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- name: emptydir
emptyDir: {}
{{- end }}
{{- /* Extra volumes */}}
{{- range .Values.extraMounts }}
- name: {{ .name }}
{{- if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .secret }}
secret:
{{- toYaml .secret | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else if .hostPath }}
hostPath:
{{- toYaml .hostPath | nindent 12 }}
{{- else if .persistentVolumeClaim }}
persistentVolumeClaim:
{{- toYaml .persistentVolumeClaim | nindent 12 }}
{{- else if .nfs }}
nfs:
{{- toYaml .nfs | nindent 12 }}
{{- else }}
{{- /* Support any other volume type by removing known mount-specific keys */}}
{{- $volume := omit . "name" "mountPath" "subPath" "readOnly" }}
{{- toYaml $volume | nindent 10 }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -18,9 +18,6 @@ metadata:
name: {{ $fullName }}
labels:
{{- include "planka.labels" . | nindent 4 }}
{{- with .Values.ingress.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}

View File

@@ -9,10 +9,6 @@ image:
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
# Optional: specify the image digest for pinning by SHA256
# When set, the image reference will include the digest for enhanced security
# Example: "abc123def456..." (without sha256: prefix)
digest: ""
imagePullSecrets: []
nameOverride: ""
@@ -31,11 +27,6 @@ existingSecretkeySecret: ""
## NOTE: When it's set, the `admin_username` and `admin_password` parameters are ignored
existingAdminCredsSecret: ""
admin_email: ""
admin_password: ""
admin_name: ""
admin_username: ""
# Base url for PLANKA. Will override `ingress.hosts[0].host`
# Defaults to `http://localhost:3000` if ingress is disabled.
baseUrl: ""
@@ -54,9 +45,6 @@ podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
# Annotations to add to the deployment
deploymentAnnotations: {}
securityContext: {}
# capabilities:
# drop:
@@ -77,7 +65,6 @@ service:
ingress:
enabled: false
className: ""
labels: {}
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
@@ -118,11 +105,6 @@ tolerations: []
affinity: {}
postgresql:
global:
security:
allowInsecureImages: true
image:
repository: bitnamilegacy/postgresql
enabled: true
auth:
database: planka
@@ -228,85 +210,3 @@ oidc:
## key: key-inside-the-secret
##
extraEnv: []
## Example extraEnv for configuring SMTP
## extraEnv:
## - name: SMTP_HOST
## value: "smtp.example.com"
## - name: SMTP_PORT
## value: "587"
## - name: SMTP_NAME
## value: "Your Name"
## - name: SMTP_SECURE
## value: "true"
## - name: SMTP_TLS_REJECT_UNAUTHORIZED
## value: "false"
## - name: SMTP_USER
## value: "your_email@example.com"
## - name: SMTP_PASSWORD
## value: "your_password"
## - name: SMTP_FROM
## value: "your_email@example.com"
## Extra volume mounts configuration
## Mount ConfigMaps, Secrets, and arbitrary volumes to the PLANKA container
## This allows mounting any pre-existing ConfigMaps, Secrets, or other volume types
##
extraMounts: []
## Example extraMounts:
## extraMounts:
## - name: ca-certs
## mountPath: /etc/ssl/certs/ca-certificates.crt
## subPath: ca-bundle.crt
## readOnly: true
## configMap:
## name: ca-certificates
## - name: tls-certs
## mountPath: /etc/ssl/private
## readOnly: true
## secret:
## secretName: planka-tls
## items:
## - key: tls.crt
## path: server.crt
## - key: tls.key
## path: server.key
## - name: temp-storage
## mountPath: /tmp/planka-temp
## readOnly: false
## emptyDir:
## sizeLimit: 1Gi
## Example configuration for OIDC with self-hosted Keycloak using custom CA
## (Requires pre-existing ConfigMap "ca-certificates")
## extraMounts:
## - name: keycloak-ca
## mountPath: /etc/ssl/certs/keycloak-ca.crt
## subPath: ca.crt
## readOnly: true
## configMap:
## name: ca-certificates
##
## extraEnv:
## - name: NODE_EXTRA_CA_CERTS
## value: "/etc/ssl/certs/keycloak-ca.crt"
extraContainers: []
## Extra sidecar containers
## Add additional containers to the PLANKA pod
##
## Example extraContainers:
## extraContainers:
## - name: nginx-sidecar
## image: nginx:latest
## ports:
## - containerPort: 8085
## name: nginx-http
## - name: log-collector
## image: busybox:latest
## command: ['sh', '-c', 'tail -f /var/log/app.log']
## volumeMounts:
## - name: planka
## mountPath: /var/log
## subPath: app-logs
##

8962
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

104
client/package.json Normal file → Executable file
View File

@@ -42,7 +42,6 @@
"error",
{
"ignore": [
"\\?url$",
"\\.svg\\?react$"
]
}
@@ -78,26 +77,18 @@
"^.+\\.(js|jsx)$": "babel-jest"
}
},
"overrides": {
"@diplodoc/transform": {
"lodash": "^4.17.23"
},
"react-mentions": {
"@babel/runtime": "^7.28.6"
}
},
"dependencies": {
"@ballerina/highlightjs-ballerina": "^1.0.1",
"@diplodoc/cut-extension": "^1.1.1",
"@diplodoc/transform": "^4.64.1",
"@gravity-ui/components": "^4.18.0",
"@gravity-ui/markdown-editor": "^15.31.0",
"@gravity-ui/uikit": "^7.31.1",
"@diplodoc/cut-extension": "^0.7.3",
"@diplodoc/transform": "^4.57.2",
"@gravity-ui/markdown-editor": "^15.11.0",
"@gravity-ui/uikit": "^7.11.0",
"@juggle/resize-observer": "^3.4.0",
"@vitejs/plugin-react": "^5.1.3",
"@types/papaparse": "^5.3.16",
"@vitejs/plugin-react": "^4.4.1",
"browserslist-to-esbuild": "^2.1.1",
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"date-fns": "^2.30.0",
"dequal": "^2.0.3",
"highlight.js": "^11.11.1",
"highlightjs-4d": "^1.0.6",
@@ -105,12 +96,12 @@
"highlightjs-apex": "^1.5.0",
"highlightjs-blade": "^0.1.0",
"highlightjs-cobol": "^0.3.3",
"highlightjs-cshtml-razor": "^2.2.0",
"highlightjs-cshtml-razor": "^2.1.1",
"highlightjs-gf": "^1.0.1",
"highlightjs-jolie": "^0.1.8",
"highlightjs-lean": "^1.2.0",
"highlightjs-lookml": "^1.0.2",
"highlightjs-macaulay2": "^0.5.0",
"highlightjs-macaulay2": "^0.2.5",
"highlightjs-mlir": "^0.0.1",
"highlightjs-qsharp": "^1.0.2",
"highlightjs-redbol": "^2.1.2",
@@ -123,72 +114,71 @@
"highlightjs-zenscript": "^2.0.0",
"hightlightjs-papyrus": "^0.0.4",
"history": "^5.3.0",
"i18next": "^25.8.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next": "23.15.2",
"i18next-browser-languagedetector": "^8.1.0",
"initials": "^3.1.2",
"javascript-time-ago": "^2.6.2",
"javascript-time-ago": "^2.5.11",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"linkify-react": "^4.3.2",
"linkifyjs": "^4.3.2",
"lodash": "^4.17.23",
"linkify-react": "^4.3.1",
"linkifyjs": "^4.3.1",
"lodash": "^4.17.21",
"lowlight": "^3.3.0",
"markdown-it": "^13.0.2",
"nanoid": "^5.1.6",
"nanoid": "^5.1.5",
"papaparse": "^5.5.3",
"patch-package": "^8.0.1",
"patch-package": "^8.0.0",
"photoswipe": "^5.4.4",
"prop-types": "^15.8.1",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-datepicker": "^9.1.0",
"react-datepicker": "^4.25.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.4.0",
"react-dropzone": "^14.3.8",
"react-frame-component": "^5.2.7",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.5.4",
"react-hot-toast": "^2.5.2",
"react-i18next": "^15.5.1",
"react-input-mask": "^2.0.4",
"react-intersection-observer": "^10.0.2",
"react-mentions": "^4.4.10",
"react-photoswipe-gallery": "^4.0.0",
"react-redux": "^9.2.0",
"react-router": "^7.13.0",
"react-intersection-observer": "^9.16.0",
"react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3",
"react-router-dom": "^6.30.0",
"react-textarea-autosize": "^8.5.9",
"react-time-ago": "^7.4.1",
"redux": "^5.0.1",
"react-time-ago": "^7.3.3",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-orm": "^0.16.2",
"redux-saga": "^1.4.2",
"reselect": "^5.1.1",
"redux-saga": "^1.3.0",
"reselect": "^4.1.8",
"sails.io.js": "^1.2.1",
"sass-embedded": "^1.97.3",
"sass-embedded": "^1.87.0",
"semantic-ui-react": "^2.1.5",
"socket.io-client": "^4.8.3",
"validator": "^13.15.26",
"vite": "^7.3.1",
"socket.io-client": "^2.5.0",
"validator": "^13.15.0",
"vite": "^6.3.5",
"vite-plugin-commonjs": "^0.10.4",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-svgr": "^4.3.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/eslint-parser": "^7.28.6",
"@babel/preset-env": "^7.29.0",
"@cucumber/cucumber": "^12.6.0",
"@cucumber/pretty-formatter": "^3.0.0",
"@playwright/test": "^1.58.1",
"babel-jest": "^30.2.0",
"@babel/eslint-parser": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@cucumber/cucumber": "^11.2.0",
"@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.52.0",
"babel-jest": "^29.7.0",
"babel-preset-airbnb": "^5.0.0",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"jest": "^30.2.0",
"playwright": "^1.58.0",
"prettier": "3.8.1"
"jest": "^29.7.0",
"playwright": "^1.52.0",
"prettier": "3.3.3"
}
}

View File

@@ -1,19 +0,0 @@
diff --git a/node_modules/@diplodoc/transform/lib/md.js b/node_modules/@diplodoc/transform/lib/md.js
index a2d222e..8d1377e 100644
--- a/node_modules/@diplodoc/transform/lib/md.js
+++ b/node_modules/@diplodoc/transform/lib/md.js
@@ -101,8 +101,12 @@ function initPlugins(md, options, pluginOptions) {
}
md.use(ol_attr_conversion_1.olAttrConversion);
plugins.forEach((plugin) => md.use(plugin, pluginOptions));
- if (linkify && linkifyTlds) {
- md.linkify.tlds(linkifyTlds, true);
+ if (linkify) {
+ if (linkifyTlds) {
+ md.linkify.tlds(linkifyTlds, true);
+ } else if (linkifyTlds === null) {
+ md.linkify.set({ fuzzyLink: false });
+ }
}
}
function initParser(md, options, env, pluginOptions) {

View File

@@ -1,8 +1,8 @@
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js b/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
index dec01f0..a80b857 100644
index 2152fd6..ceda0c1 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
@@ -102,7 +102,6 @@ export const BundlePreset = (builder, opts) => {
@@ -101,7 +101,6 @@ export const BundlePreset = (builder, opts) => {
enableNewImageSizeCalculation: opts.enableNewImageSizeCalculation,
...opts.imgSize,
},
@@ -10,10 +10,10 @@ index dec01f0..a80b857 100644
deflist: {
deflistTermPlaceholder: () => i18nPlaceholder('deflist_term'),
deflistDescPlaceholder: () => i18nPlaceholder('deflist_desc'),
@@ -123,11 +122,6 @@ export const BundlePreset = (builder, opts) => {
...opts.yfmTable,
controls: opts.mobile ? false : opts.yfmTable?.controls,
@@ -118,11 +117,6 @@ export const BundlePreset = (builder, opts) => {
...opts.yfmNote,
},
yfmTable: { yfmTableCellPlaceholder: () => i18nPlaceholder('table_cell'), ...opts.yfmTable },
- yfmFile: {
- fileUploadHandler: opts.fileUploadHandler,
- needToSetDimensionsForUploadedImages: opts.needToSetDimensionsForUploadedImages,
@@ -22,41 +22,14 @@ index dec01f0..a80b857 100644
yfmHeading: {
h1Key: f.toPM(A.Heading1),
h2Key: f.toPM(A.Heading2),
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js b/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js
index 8aefe20..99e59e3 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js
@@ -42,6 +42,9 @@ export class ExtensionsManager {
if (options.linkifyTlds) {
this.#mdForMarkup.linkify.tlds(options.linkifyTlds, true);
this.#mdForText.linkify.tlds(options.linkifyTlds, true);
+ } else if (options.linkifyTlds === null) {
+ this.#mdForMarkup.linkify.set({ fuzzyLink: false });
+ this.#mdForText.linkify.set({ fuzzyLink: false });
}
if (options.pmTransformers) {
this.#pmTransformers = options.pmTransformers;
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js b/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js
index 212c583..b709383 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js
@@ -10,7 +10,7 @@ export { noteType, noteTitleType } from "./utils.js";
export const YfmNoteSpecs = (builder, opts) => {
const schemaSpecs = getSchemaSpecs(opts, builder.context.get('placeholder'));
builder
- .configureMd((md) => md.use(yfmPlugin, { log, lang: getConfig().lang || 'en' }))
+ .configureMd((md) => md.use(yfmPlugin, { log, lang: getConfig().lang || 'en', notesAutotitle: false }))
.addNode(NoteNode.Note, () => ({
spec: schemaSpecs[NoteNode.Note],
toMd: serializerTokens[NoteNode.Note],
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js b/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js
index ed2a9db..77f6d08 100644
index ed2a9db..f95b693 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js
@@ -1,5 +1,5 @@
import { Deflist, Subscript, Superscript, Underline, } from "../extensions/markdown/index.js";
-import { Checkbox, ImgSize, Monospace, Video, YfmConfigs, YfmCut, YfmFile, YfmHeading, YfmNote, YfmTable, YfmTabs, } from "../extensions/yfm/index.js";
+import { ImgSize, Monospace, Video, YfmConfigs, YfmCut, YfmHeading, YfmNote, YfmTable, } from "../extensions/yfm/index.js";
+import { ImgSize, Monospace, Video, YfmConfigs, YfmCut, YfmHeading, YfmNote, YfmTable } from "../extensions/yfm/index.js";
import { DefaultPreset } from "./default.js";
export const YfmPreset = (builder, opts) => {
builder.use(DefaultPreset, { ...opts, image: false, heading: false });

View File

@@ -1,24 +0,0 @@
diff --git a/node_modules/react-mentions/dist/react-mentions.esm.js b/node_modules/react-mentions/dist/react-mentions.esm.js
index 2efebba..b244446 100644
--- a/node_modules/react-mentions/dist/react-mentions.esm.js
+++ b/node_modules/react-mentions/dist/react-mentions.esm.js
@@ -1426,7 +1426,7 @@ var MentionsInput = /*#__PURE__*/function (_React$Component) {
var mentions = getMentions(newValue, config);
- if (ev.nativeEvent.isComposing && selectionStart === selectionEnd) {
+ if ((ev.nativeEvent.isComposing || newValue.length < value.length) && selectionStart === selectionEnd) {
_this.updateMentionsQueries(_this.inputElement.value, selectionStart);
} // Propagate change
// let handleChange = this.getOnChange(this.props) || emptyFunction;
@@ -1454,7 +1454,9 @@ var MentionsInput = /*#__PURE__*/function (_React$Component) {
var el = _this.inputElement;
if (ev.target.selectionStart === ev.target.selectionEnd) {
- _this.updateMentionsQueries(el.value, ev.target.selectionStart);
+ requestAnimationFrame(function () {
+ _this.updateMentionsQueries(el.value, ev.target.selectionStart);
+ });
} else {
_this.clearSuggestions();
} // sync highlighters scroll position

View File

@@ -0,0 +1,13 @@
diff --git a/node_modules/react-photoswipe-gallery/dist/gallery.js b/node_modules/react-photoswipe-gallery/dist/gallery.js
index 53cc02c..f4baccb 100644
--- a/node_modules/react-photoswipe-gallery/dist/gallery.js
+++ b/node_modules/react-photoswipe-gallery/dist/gallery.js
@@ -181,7 +181,7 @@ export const Gallery = ({
alt
} = pswpInstance.currSlide.data;
// eslint-disable-next-line no-param-reassign
- el.innerHTML = caption || alt || '';
+ el.textContent = caption || alt || '';
});
}
});

View File

@@ -1,87 +0,0 @@
diff --git a/node_modules/sails.io.js/sails.io.js b/node_modules/sails.io.js/sails.io.js
index 11694b5..bb5e594 100644
--- a/node_modules/sails.io.js/sails.io.js
+++ b/node_modules/sails.io.js/sails.io.js
@@ -138,6 +138,15 @@
CONNECTION_METADATA_PARAMS.platform + '=' + SDK_INFO.platform + '&' +
CONNECTION_METADATA_PARAMS.language + '=' + SDK_INFO.language;
+ var MANAGER_EVENT_NAMES = new Set([
+ 'error',
+ 'reconnect',
+ 'reconnect_attempt',
+ 'reconnect_error',
+ 'reconnect_failed',
+ 'ping'
+ ]);
+
@@ -668,6 +677,7 @@
// Okay to change global headers while socket is connected
if (option == 'headers') {return;}
Object.defineProperty(self, option, {
+ enumerable: true,
get: function() {
if (option == 'url') {
return _opts[option] || (self._raw && self._raw.io && self._raw.io.uri);
@@ -986,7 +996,7 @@
consolog('====================================');
});
- self.on('reconnecting', function(numAttempts) {
+ self.on('reconnect_attempt', function(numAttempts) {
consolog(
'\n'+
' Socket is trying to reconnect to '+(self.url ? self.url : 'Sails')+'...\n'+
@@ -1124,7 +1134,7 @@
// off to the self._raw for consumption
for (var evName in self.eventQueue) {
for (var i in self.eventQueue[evName]) {
- self._raw.on(evName, self.eventQueue[evName][i]);
+ self._getEventTarget(evName).on(evName, self.eventQueue[evName][i]);
}
}
@@ -1150,10 +1160,11 @@
* @return {SailsSocket}
*/
SailsSocket.prototype.on = function (evName, fn){
+ var target = this._getEventTarget(evName);
// Bind the event to the raw underlying socket if possible.
- if (this._raw) {
- this._raw.on(evName, fn);
+ if (target) {
+ target.on(evName, fn);
return this;
}
@@ -1176,10 +1187,11 @@
* @return {SailsSocket}
*/
SailsSocket.prototype.off = function (evName, fn){
+ var target = this._getEventTarget(evName);
// Bind the event to the raw underlying socket if possible.
- if (this._raw) {
- this._raw.off(evName, fn);
+ if (target) {
+ target.off(evName, fn);
return this;
}
@@ -1491,6 +1503,12 @@
throw new Error('`_request()` was a private API deprecated as of v0.11 of the sails.io.js client. Use `.request()` instead.');
};
+ SailsSocket.prototype._getEventTarget = function (evName) {
+ if (!this._raw) return null;
+
+ return MANAGER_EVENT_NAMES.has(evName) ? this._raw.io : this._raw;
+ };
+

View File

@@ -1,5 +1,5 @@
diff --git a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
index 6d06078..e22d4f0 100644
index 6d06078..fb7534d 100644
--- a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
+++ b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
@@ -17,13 +17,7 @@ var doesNodeContainClick = function doesNodeContainClick(node, e) {
@@ -17,113 +17,6 @@ index 6d06078..e22d4f0 100644
} // Below logic handles cases where the e.target is no longer in the document.
// The result of the click likely has removed the e.target node.
// Instead of node.contains(), we'll identify the click by X/Y position.
diff --git a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
index 1cc1bab..0e178ee 100644
--- a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
+++ b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
@@ -342,7 +342,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
return;
}
- if (searchQuery.length >= minCharacters || minCharacters === 1) {
+ if (searchQuery.length >= minCharacters || minCharacters === 0) {
_this.open(e);
return;
@@ -480,7 +480,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
} // close search dropdown if search query is too small
- if (open && minCharacters !== 1 && newQuery.length < minCharacters) _this.close();
+ if (open && minCharacters !== 0 && newQuery.length < minCharacters) _this.close();
};
_this.handleKeyDown = function (e) {
@@ -1048,7 +1048,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
if (!prevState.focus && this.state.focus) {
if (!this.isMouseDown) {
- var openable = !search || search && minCharacters === 1 && !this.state.open;
+ var openable = !search || search && minCharacters === 0 && !this.state.open;
if (openOnFocus && openable) this.open();
}
} else if (prevState.focus && !this.state.focus) {
@@ -1436,7 +1436,7 @@ Dropdown.defaultProps = {
closeOnEscape: true,
deburr: false,
icon: 'dropdown',
- minCharacters: 1,
+ minCharacters: 0,
noResultsMessage: 'No results found.',
openOnFocus: true,
renderLabel: renderItemContent,
diff --git a/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js b/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
index a39f694..c9c616a 100644
--- a/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
+++ b/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
@@ -20,6 +20,7 @@ import ModalDescription from './ModalDescription';
import ModalDimmer from './ModalDimmer';
import ModalHeader from './ModalHeader';
import { canFit, getLegacyStyles, isLegacy } from './utils';
+var IS_WINDOWS = navigator.platform.startsWith('Win');
/**
* A modal displays content that temporarily blocks interactions with the main view of a site.
@@ -41,6 +42,7 @@ var Modal = /*#__PURE__*/function (_Component) {
_this.ref = /*#__PURE__*/createRef();
_this.dimmerRef = /*#__PURE__*/createRef();
_this.latestDocumentMouseDownEvent = null;
+ _this.latestWindowFocusTimeOnWindows = null;
_this.getMountNode = function () {
return isBrowser() ? _this.props.mountNode || document.body : null;
@@ -68,11 +70,17 @@ var Modal = /*#__PURE__*/function (_Component) {
}));
};
+ _this.handleWindowFocusOnWindows = function () {
+ _this.latestWindowFocusTimeOnWindows = Date.now();
+ };
+
_this.handleDocumentMouseDown = function (e) {
+ if (_this.latestWindowFocusTimeOnWindows && Date.now() - _this.latestWindowFocusTimeOnWindows < 50) return;
_this.latestDocumentMouseDownEvent = e;
};
_this.handleDocumentClick = function (e) {
+ if (!_this.latestDocumentMouseDownEvent) return;
var closeOnDimmerClick = _this.props.closeOnDimmerClick;
var currentDocumentMouseDownEvent = _this.latestDocumentMouseDownEvent;
_this.latestDocumentMouseDownEvent = null;
@@ -116,6 +124,13 @@ var Modal = /*#__PURE__*/function (_Component) {
_this.setPositionAndClassNames();
+ if (IS_WINDOWS) {
+ eventStack.sub('focus', _this.handleWindowFocusOnWindows, {
+ pool: eventPool,
+ target: window
+ });
+ }
+
eventStack.sub('mousedown', _this.handleDocumentMouseDown, {
pool: eventPool,
target: _this.dimmerRef.current
@@ -131,6 +146,14 @@ var Modal = /*#__PURE__*/function (_Component) {
_this.handlePortalUnmount = function (e) {
var eventPool = _this.props.eventPool;
cancelAnimationFrame(_this.animationRequestId);
+
+ if (IS_WINDOWS) {
+ eventStack.unsub('focus', _this.handleWindowFocusOnWindows, {
+ pool: eventPool,
+ target: window
+ });
+ }
+
eventStack.unsub('mousedown', _this.handleDocumentMouseDown, {
pool: eventPool,
target: _this.dimmerRef.current
diff --git a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js
index d1ae271..43e1170 100644
--- a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,17 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ActionTypes from '../constants/ActionTypes';
const handleBootstrapUpdate = (bootstrap) => ({
type: ActionTypes.BOOTSTRAP_UPDATE_HANDLE,
payload: {
bootstrap,
},
});
export default {
handleBootstrapUpdate,
};

View File

@@ -160,72 +160,6 @@ const handleCardUpdate = (
},
});
const transferCard = (id, data) => ({
type: ActionTypes.CARD_TRANSFER,
payload: {
id,
data,
},
});
transferCard.success = (
card,
users,
cardMemberships,
cardLabels,
taskLists,
tasks,
attachments,
customFieldGroups,
customFields,
customFieldValues,
) => ({
type: ActionTypes.CARD_TRANSFER__SUCCESS,
payload: {
card,
users,
cardMemberships,
cardLabels,
taskLists,
tasks,
attachments,
customFieldGroups,
customFields,
customFieldValues,
},
});
transferCard.failure = (
id,
error,
card,
users,
cardMemberships,
cardLabels,
taskLists,
tasks,
attachments,
customFieldGroups,
customFields,
customFieldValues,
) => ({
type: ActionTypes.CARD_TRANSFER__FAILURE,
payload: {
id,
error,
card,
users,
cardMemberships,
cardLabels,
taskLists,
tasks,
attachments,
customFieldGroups,
customFields,
customFieldValues,
},
});
const duplicateCard = (id, localId, data) => ({
type: ActionTypes.CARD_DUPLICATE,
payload: {
@@ -270,25 +204,6 @@ duplicateCard.failure = (localId, error) => ({
},
});
const copyCard = (id) => ({
type: ActionTypes.CARD_COPY,
payload: {
id,
},
});
const cutCard = (id) => ({
type: ActionTypes.CARD_CUT,
payload: {
id,
},
});
const pasteCard = () => ({
type: ActionTypes.CARD_PASTE,
payload: {},
});
const deleteCard = (id) => ({
type: ActionTypes.CARD_DELETE,
payload: {
@@ -325,11 +240,7 @@ export default {
handleCardCreate,
updateCard,
handleCardUpdate,
transferCard,
duplicateCard,
copyCard,
cutCard,
pasteCard,
deleteCard,
handleCardDelete,
};

View File

@@ -1,59 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ActionTypes from '../constants/ActionTypes';
const updateConfig = (data) => ({
type: ActionTypes.CONFIG_UPDATE,
payload: {
data,
},
});
updateConfig.success = (config) => ({
type: ActionTypes.CONFIG_UPDATE__SUCCESS,
payload: {
config,
},
});
updateConfig.failure = (error) => ({
type: ActionTypes.CONFIG_UPDATE__FAILURE,
payload: {
error,
},
});
const handleConfigUpdate = (config) => ({
type: ActionTypes.CONFIG_UPDATE_HANDLE,
payload: {
config,
},
});
const testSmtpConfig = () => ({
type: ActionTypes.SMTP_CONFIG_TEST,
payload: {},
});
testSmtpConfig.success = (logs) => ({
type: ActionTypes.SMTP_CONFIG_TEST__SUCCESS,
payload: {
logs,
},
});
testSmtpConfig.failure = (error) => ({
type: ActionTypes.SMTP_CONFIG_TEST__FAILURE,
payload: {
error,
},
});
export default {
updateConfig,
handleConfigUpdate,
testSmtpConfig,
};

View File

@@ -6,10 +6,8 @@
import ActionTypes from '../constants/ActionTypes';
const initializeCore = (
config,
user,
board,
webhooks,
users,
projects,
projectManagers,
@@ -33,10 +31,8 @@ const initializeCore = (
) => ({
type: ActionTypes.CORE_INITIALIZE,
payload: {
config,
user,
board,
webhooks,
users,
projects,
projectManagers,
@@ -60,10 +56,10 @@ const initializeCore = (
},
});
initializeCore.fetchBootstrap = (bootstrap) => ({
type: ActionTypes.CORE_INITIALIZE__BOOTSTRAP_FETCH,
initializeCore.fetchConfig = (config) => ({
type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH,
payload: {
bootstrap,
config,
},
});
@@ -93,8 +89,8 @@ const logout = () => ({
payload: {},
});
logout.revokeAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE,
logout.invalidateAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE,
payload: {},
});

View File

@@ -5,12 +5,9 @@
import router from './router';
import socket from './socket';
import bootstrap from './bootstrap';
import login from './login';
import core from './core';
import modals from './modals';
import config from './config';
import webhooks from './webhooks';
import users from './users';
import projects from './projects';
import projectManagers from './project-managers';
@@ -35,12 +32,9 @@ import notificationServices from './notification-services';
export default {
...router,
...socket,
...bootstrap,
...login,
...core,
...modals,
...config,
...webhooks,
...users,
...projects,
...projectManagers,

View File

@@ -58,34 +58,10 @@ updateList.failure = (id, error) => ({
},
});
const handleListUpdate = (
list,
isFetched,
users,
cards,
cardMemberships,
cardLabels,
taskLists,
tasks,
attachments,
customFieldGroups,
customFields,
customFieldValues,
) => ({
const handleListUpdate = (list) => ({
type: ActionTypes.LIST_UPDATE_HANDLE,
payload: {
list,
isFetched,
users,
cards,
cardMemberships,
cardLabels,
taskLists,
tasks,
attachments,
customFieldGroups,
customFields,
customFieldValues,
},
});

View File

@@ -5,10 +5,10 @@
import ActionTypes from '../constants/ActionTypes';
const initializeLogin = (bootstrap) => ({
const initializeLogin = (config) => ({
type: ActionTypes.LOGIN_INITIALIZE,
payload: {
bootstrap,
config,
},
});
@@ -26,11 +26,10 @@ authenticate.success = (accessToken) => ({
},
});
authenticate.failure = (error, terms) => ({
authenticate.failure = (error) => ({
type: ActionTypes.AUTHENTICATE__FAILURE,
payload: {
error,
terms,
},
});
@@ -46,18 +45,10 @@ authenticateWithOidc.success = (accessToken) => ({
},
});
authenticateWithOidc.failure = (error, terms) => ({
authenticateWithOidc.failure = (error) => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE,
payload: {
error,
terms,
},
});
authenticateWithOidc.debug = (logs) => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE__DEBUG,
payload: {
logs,
},
});
@@ -66,71 +57,9 @@ const clearAuthenticateError = () => ({
payload: {},
});
const acceptTerms = (signature) => ({
type: ActionTypes.TERMS_ACCEPT,
payload: {
signature,
},
});
acceptTerms.success = (accessToken) => ({
type: ActionTypes.TERMS_ACCEPT__SUCCESS,
payload: {
accessToken,
},
});
acceptTerms.failure = (error) => ({
type: ActionTypes.TERMS_ACCEPT__FAILURE,
payload: {
error,
},
});
const cancelTerms = () => ({
type: ActionTypes.TERMS_CANCEL,
payload: {},
});
cancelTerms.success = () => ({
type: ActionTypes.TERMS_CANCEL__SUCCESS,
payload: {},
});
cancelTerms.failure = (error) => ({
type: ActionTypes.TERMS_CANCEL__FAILURE,
payload: {
error,
},
});
const updateTermsLanguage = (value) => ({
type: ActionTypes.TERMS_LANGUAGE_UPDATE,
payload: {
value,
},
});
updateTermsLanguage.success = (terms) => ({
type: ActionTypes.TERMS_LANGUAGE_UPDATE__SUCCESS,
payload: {
terms,
},
});
updateTermsLanguage.failure = (error) => ({
type: ActionTypes.TERMS_LANGUAGE_UPDATE__FAILURE,
payload: {
error,
},
});
export default {
initializeLogin,
authenticate,
authenticateWithOidc,
clearAuthenticateError,
acceptTerms,
cancelTerms,
updateTermsLanguage,
};

View File

@@ -11,11 +11,9 @@ const handleSocketDisconnect = () => ({
});
const handleSocketReconnect = (
bootstrap,
config,
user,
board,
webhooks,
users,
projects,
projectManagers,
@@ -39,11 +37,9 @@ const handleSocketReconnect = (
) => ({
type: ActionTypes.SOCKET_RECONNECT_HANDLE,
payload: {
bootstrap,
config,
user,
board,
webhooks,
users,
projects,
projectManagers,

View File

@@ -5,13 +5,6 @@
import ActionTypes from '../constants/ActionTypes';
const handleUsersReset = (users) => ({
type: ActionTypes.USERS_RESET_HANDLE,
payload: {
users,
},
});
const createUser = (data) => ({
type: ActionTypes.USER_CREATE,
payload: {
@@ -72,10 +65,8 @@ const handleUserUpdate = (
user,
projectIds,
boardIds,
bootstrap,
config,
board,
webhooks,
users,
projects,
projectManagers,
@@ -102,10 +93,8 @@ const handleUserUpdate = (
user,
projectIds,
boardIds,
bootstrap,
config,
board,
webhooks,
users,
projects,
projectManagers,
@@ -242,58 +231,6 @@ updateUserAvatar.failure = (id, error) => ({
},
});
const createUserApiKey = (id) => ({
type: ActionTypes.USER_API_KEY_CREATE,
payload: {
id,
},
});
createUserApiKey.success = (user, apiKey) => ({
type: ActionTypes.USER_API_KEY_CREATE__SUCCESS,
payload: {
user,
apiKey,
},
});
createUserApiKey.failure = (id, error) => ({
type: ActionTypes.USER_API_KEY_CREATE__FAILURE,
payload: {
id,
error,
},
});
const deleteUserApiKey = (id) => ({
type: ActionTypes.USER_API_KEY_DELETE,
payload: {
id,
},
});
deleteUserApiKey.success = (user) => ({
type: ActionTypes.USER_API_KEY_DELETE__SUCCESS,
payload: {
user,
},
});
deleteUserApiKey.failure = (id, error) => ({
type: ActionTypes.USER_API_KEY_DELETE__FAILURE,
payload: {
id,
error,
},
});
const clearUserApiKeyValue = (id) => ({
type: ActionTypes.USER_API_KEY_VALUE_CLEAR,
payload: {
id,
},
});
const deleteUser = (id) => ({
type: ActionTypes.USER_DELETE,
payload: {
@@ -406,7 +343,6 @@ const removeUserFromBoardFilter = (id, boardId, currentListId) => ({
});
export default {
handleUsersReset,
createUser,
handleUserCreate,
clearUserCreateError,
@@ -419,9 +355,6 @@ export default {
updateUserUsername,
clearUserUsernameUpdateError,
updateUserAvatar,
createUserApiKey,
deleteUserApiKey,
clearUserApiKeyValue,
deleteUser,
handleUserDelete,
addUserToCard,

View File

@@ -1,104 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ActionTypes from '../constants/ActionTypes';
const createWebhook = (webhook) => ({
type: ActionTypes.WEBHOOK_CREATE,
payload: {
webhook,
},
});
createWebhook.success = (localId, webhook) => ({
type: ActionTypes.WEBHOOK_CREATE__SUCCESS,
payload: {
localId,
webhook,
},
});
createWebhook.failure = (localId, error) => ({
type: ActionTypes.WEBHOOK_CREATE__FAILURE,
payload: {
localId,
error,
},
});
const handleWebhookCreate = (webhook) => ({
type: ActionTypes.WEBHOOK_CREATE_HANDLE,
payload: {
webhook,
},
});
const updateWebhook = (id, data) => ({
type: ActionTypes.WEBHOOK_UPDATE,
payload: {
id,
data,
},
});
updateWebhook.success = (webhook) => ({
type: ActionTypes.WEBHOOK_UPDATE__SUCCESS,
payload: {
webhook,
},
});
updateWebhook.failure = (id, error) => ({
type: ActionTypes.WEBHOOK_UPDATE__FAILURE,
payload: {
id,
error,
},
});
const handleWebhookUpdate = (webhook) => ({
type: ActionTypes.WEBHOOK_UPDATE_HANDLE,
payload: {
webhook,
},
});
const deleteWebhook = (id) => ({
type: ActionTypes.WEBHOOK_DELETE,
payload: {
id,
},
});
deleteWebhook.success = (webhook) => ({
type: ActionTypes.WEBHOOK_DELETE__SUCCESS,
payload: {
webhook,
},
});
deleteWebhook.failure = (id, error) => ({
type: ActionTypes.WEBHOOK_DELETE__FAILURE,
payload: {
id,
error,
},
});
const handleWebhookDelete = (webhook) => ({
type: ActionTypes.WEBHOOK_DELETE_HANDLE,
payload: {
webhook,
},
});
export default {
createWebhook,
handleWebhookCreate,
updateWebhook,
handleWebhookUpdate,
deleteWebhook,
handleWebhookDelete,
};

View File

@@ -13,21 +13,10 @@ const createAccessToken = (data, headers) =>
const exchangeForAccessTokenWithOidc = (data, headers) =>
http.post('/access-tokens/exchange-with-oidc?withHttpOnlyToken=true', data, headers);
const debugOidc = (data, headers) => http.post('/access-tokens/debug-oidc', data, headers);
// TODO: rename?
const acceptTerms = (data, headers) => http.post('/access-tokens/accept-terms', data, headers);
const revokePendingToken = (data, headers) =>
http.post('/access-tokens/revoke-pending-token', data, headers);
const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', undefined, headers);
export default {
createAccessToken,
exchangeForAccessTokenWithOidc,
debugOidc,
acceptTerms,
revokePendingToken,
deleteCurrentAccessToken,
};

View File

@@ -16,13 +16,13 @@ export const transformActivity = (activity) => ({
/* Actions */
const getBoardActivities = (boardId, data, headers) =>
const getActivitiesInBoard = (boardId, data, headers) =>
socket.get(`/boards/${boardId}/actions`, data, headers).then((body) => ({
...body,
items: body.items.map(transformActivity),
}));
const getCardActivities = (cardId, data, headers) =>
const getActivitiesInCard = (cardId, data, headers) =>
socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
...body,
items: body.items.map(transformActivity),
@@ -38,7 +38,7 @@ const makeHandleActivityCreate = (next) => (body) => {
};
export default {
getBoardActivities,
getCardActivities,
getActivitiesInBoard,
getActivitiesInCard,
makeHandleActivityCreate,
};

View File

@@ -1,14 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import http from './http';
/* Actions */
const getBootstrap = (headers) => http.get('/bootstrap', undefined, headers);
export default {
getBootstrap,
};

View File

@@ -3,18 +3,12 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import socket from './socket';
import http from './http';
/* Actions */
const getConfig = (headers) => socket.get('/config', undefined, headers);
const updateConfig = (data, headers) => socket.patch('/config', data, headers);
const testSmtpConfig = (headers) => socket.post('/config/test-smtp', undefined, headers);
const getConfig = (headers) => http.get('/config', undefined, headers);
export default {
getConfig,
updateConfig,
testSmtpConfig,
};

View File

@@ -7,10 +7,10 @@ import socket from './socket';
/* Actions */
const createBoardCustomFieldGroup = (cardId, data, headers) =>
const createCustomFieldGroupInBoard = (cardId, data, headers) =>
socket.post(`/boards/${cardId}/custom-field-groups`, data, headers);
const createCardCustomFieldGroup = (cardId, data, headers) =>
const createCustomFieldGroupInCard = (cardId, data, headers) =>
socket.post(`/cards/${cardId}/custom-field-groups`, data, headers);
const getCustomFieldGroup = (id, headers) =>
@@ -23,8 +23,8 @@ const deleteCustomFieldGroup = (id, headers) =>
socket.delete(`/custom-field-groups/${id}`, undefined, headers);
export default {
createBoardCustomFieldGroup,
createCardCustomFieldGroup,
createCustomFieldGroupInBoard,
createCustomFieldGroupInCard,
getCustomFieldGroup,
updateCustomFieldGroup,
deleteCustomFieldGroup,

View File

@@ -5,11 +5,8 @@
import http from './http';
import socket from './socket';
import bootstrap from './bootstrap';
import terms from './terms';
import accessTokens from './access-tokens';
import config from './config';
import webhooks from './webhooks';
import accessTokens from './access-tokens';
import users from './users';
import projects from './projects';
import projectManagers from './project-managers';
@@ -36,11 +33,8 @@ import notificationServices from './notification-services';
export { http, socket };
export default {
...bootstrap,
...terms,
...accessTokens,
...config,
...webhooks,
...accessTokens,
...users,
...projects,
...projectManagers,

View File

@@ -7,10 +7,10 @@ import socket from './socket';
/* Actions */
const createUserNotificationService = (userId, data, headers) =>
const createNotificationServiceInUser = (userId, data, headers) =>
socket.post(`/users/${userId}/notification-services`, data, headers);
const createBoardNotificationService = (boardId, data, headers) =>
const createNotificationServiceInBoard = (boardId, data, headers) =>
socket.post(`/boards/${boardId}/notification-services`, data, headers);
const updateNotificationService = (id, data, headers) =>
@@ -23,8 +23,8 @@ const deleteNotificationService = (id, headers) =>
socket.delete(`/notification-services/${id}`, undefined, headers);
export default {
createUserNotificationService,
createBoardNotificationService,
createNotificationServiceInUser,
createNotificationServiceInBoard,
updateNotificationService,
testNotificationService,
deleteNotificationService,

View File

@@ -30,8 +30,8 @@ socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle
headers,
url: `/api${url}`,
},
(body, { error }) => {
if (body instanceof Error || error) {
(_, { body, error }) => {
if (error) {
reject(body);
} else {
resolve(body);

View File

@@ -1,15 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import http from './http';
/* Actions */
const getTerms = (language, headers) =>
http.get(`/terms${language ? `?language=${language}` : ''}`, undefined, headers);
export default {
getTerms,
};

View File

@@ -33,9 +33,6 @@ const updateUserUsername = (id, data, headers) =>
const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, data, headers);
const createUserApiKey = (userId, headers) =>
socket.post(`/users/${userId}/api-key`, undefined, headers);
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
export default {
@@ -48,6 +45,5 @@ export default {
updateUserPassword,
updateUserUsername,
updateUserAvatar,
createUserApiKey,
deleteUser,
};

View File

@@ -1,23 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import socket from './socket';
/* Actions */
const getWebhooks = (headers) => socket.get('/webhooks', undefined, headers);
const createWebhook = (data, headers) => socket.post('/webhooks', data, headers);
const updateWebhook = (id, data, headers) => socket.patch(`/webhooks/${id}`, data, headers);
const deleteWebhook = (id, headers) => socket.delete(`/webhooks/${id}`, undefined, headers);
export default {
getWebhooks,
createWebhook,
updateWebhook,
deleteWebhook,
};

View File

@@ -1,167 +0,0 @@
# [2.0.2] - 2026-02-23
### Fixed
* Prevent dropzone from overflowing content
* Update Gravatar hash algorithm
* Improve backup and restore scripts
* Improve installation on Windows and containerized environments
## [2.0.1] - 2026-02-17
### Fixed
* Improve connection reliability after the app is idle
* Allow loading custom End User Terms of Service
## [2.0.0] - 2026-02-11
### Added
* Mention users in comments
* Add download button for file attachments
* Enable strikethrough for cards in closed lists
* Expand card descriptions
* Enable copy-to-clipboard for custom fields
* Include task assignees in member filters
* Link tasks to cards
* Open card actions menu on right-click
* Hide completed tasks
* Add dedicated button to make projects private
* Track navigation paths when switching cards
* Support OAuth callbacks for OIDC
* Display legal requirements in the app
* Track storage usage
* Move lists between boards
* Restore toggleable due dates
* Add Gravatar support for avatars
* Add board setting to expand task lists by default
* Configure and test SMTP via UI
* Add API key authentication
* Add create-board button on the open-board screen
* Support object-path mapping for OIDC attributes
* Add basic keyboard shortcuts for cards
* Enable copy/cut cards with keyboard shortcuts
* Enhance card actions menu with separators and action bar
* Display last updates in the About modal
* Allow unlinking SSO from user accounts
* Apply color to entire lists instead of only card bottoms
### Changed
* Move webhooks configuration to UI
* Parse dates as UTC without relying on TZ environment variable
* Move About and Terms into a separate modal
* Move infrequent card actions to a more-actions menu
* Improve error page display
* Enable favorites panel by default
* Improve login page appearance
* Enhance Markdown editor (colors, quote borders, disable fuzzy links)
* Improve PDF viewer compatibility across browsers
* Update background color for own comments
* Improve browser caching for public files and attachments
* Optimize and parallelize image processing tasks
* Re-stream static files from S3 with protected access
* Unify file storage directory
* Configure proxy for outgoing traffic to prevent SSRF
### Fixed
* Prevent editors from deleting other comments
* Handle escape actions in mentions input correctly
* Prevent text overflow in activities
* Prevent deactivated users from receiving notifications
* Preserve newlines in markdown with mentions
* Fix app crash when boards are added before their projects
* Enable spellcheck on all textareas
* Fix multiple UI, toolbar, and popup styling issues
* Limit attachment gallery content to prevent layout issues
* Correct translations for client, server, and Markdown editor
* Fix minor UI issues
---
## [2.0.0-rc.4] - 2025-09-04
### Fixed
* Prevent vulnerability where maliciously renamed file attachments could execute JavaScript in the gallery UI
---
## [2.0.0-rc.3] - 2025-05-28
### Added
* Notify users when they are added to a card
* Emphasize cards in colored and closed lists
* Track board activity log changes
* Display total number of comments on cards
* Add CSV attachment viewer
* Log actions when a user is removed from a card
* Log actions when task completion status changes
* Support Docker secrets
### Changed
* Improve notifications popup appearance
* Improve card content rendering and styling
* Limit attachment content display for clarity
* Increase maximum length of OIDC code challenge
### Fixed
* Fix disabled cards display
* Correct translations for client, server, and Markdown editor
* Fix minor UI issues
---
## [2.0.0-rc.2] - 2025-05-10
### Added
* Add global user roles and improve user management
* Enable user deactivation
* Support private and shared projects
* Search projects by name and project groups
* Add favorite projects with favorites panel
* Add project descriptions and background image gallery
* Add list types: Closed, Archive, Trash
* Add board views: List and Grid
* Add new Markdown editor
* Link attachments (attach URLs)
* Enable quick filter by current user
* Add board settings modal
* Subscribe to entire boards
* Assign users to tasks
* Support multiple task lists
* Add more label colors
* Always display card creator option
* Show notification badge for board tabs
* Display message about new version availability
### Changed
* Restrict access to users based on global roles
* Limit email visibility
* Make projects page responsive
* Redesign card appearance
* Show edit buttons only when needed
* Use time-ago format for dates
* Highlight recent cards
* Improve attachment viewers and syntax highlighting
* Restyle comments
* Restyle login page
* Enable user auto-subscription when commenting
* Navigate to adjacent cards using arrow keys
* Open same-site links in current tab
* Improve card deletion workflow
* Archive all cards in a closed list with one button
* Confirm deletion actions
* Close only active elements when clicking outside
### Fixed
* Prevent deleting the last project manager
* Prevent deleting projects with existing boards

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -7,12 +7,12 @@ import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useTranslation, Trans } from 'react-i18next';
import { Link } from 'react-router';
import { Link } from 'react-router-dom';
import { Comment } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { isUserStatic } from '../../../utils/record-helpers';
import Paths from '../../../constants/Paths';
import { StaticUserIds } from '../../../constants/StaticUsers';
import { ActivityTypes } from '../../../constants/Enums';
import TimeAgo from '../../common/TimeAgo';
import UserAvatar from '../../users/UserAvatar';
@@ -30,11 +30,12 @@ const Item = React.memo(({ id }) => {
const [t] = useTranslation();
const userName = isUserStatic(user)
? t(`common.${user.name}`, {
context: 'title',
})
: user.name;
const userName =
user.id === StaticUserIds.DELETED
? t(`common.${user.name}`, {
context: 'title',
})
: user.name;
const cardName = card ? card.name : activity.data.card.name;

View File

@@ -16,7 +16,6 @@
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
word-wrap: break-word;
}
.date {

View File

@@ -10,7 +10,7 @@ import { useTranslation, Trans } from 'react-i18next';
import { Comment } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { isUserStatic } from '../../../utils/record-helpers';
import { StaticUserIds } from '../../../constants/StaticUsers';
import { ActivityTypes } from '../../../constants/Enums';
import TimeAgo from '../../common/TimeAgo';
import UserAvatar from '../../users/UserAvatar';
@@ -26,11 +26,12 @@ const Item = React.memo(({ id }) => {
const [t] = useTranslation();
const userName = isUserStatic(user)
? t(`common.${user.name}`, {
context: 'title',
})
: user.name;
const userName =
user.id === StaticUserIds.DELETED
? t(`common.${user.name}`, {
context: 'title',
})
: user.name;
let contentNode;
switch (activity.type) {

View File

@@ -16,7 +16,6 @@
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
word-wrap: break-word;
}
.date {

View File

@@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon, Menu } from 'semantic-ui-react';
import { Menu } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../lib/custom-ui';
import entryActions from '../../../entry-actions';
@@ -47,7 +47,6 @@ const AddAttachmentStep = React.memo(({ onClose }) => {
<Menu secondary vertical className={styles.menu}>
<FilePicker multiple onSelect={handleFilesSelect}>
<Menu.Item className={styles.menuItem}>
<Icon name="computer" className={styles.menuItemIcon} />
{t('common.fromComputer', {
context: 'title',
})}

View File

@@ -12,7 +12,7 @@
}
.menu {
margin: 0 -12px -5px;
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
@@ -21,11 +21,6 @@
padding-left: 14px;
}
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
.tip {
opacity: 0.5;
}

View File

@@ -3,19 +3,20 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Pagination, Table } from 'semantic-ui-react';
import Papa from 'papaparse';
import Frame from 'react-frame-component';
import { Loader, Pagination, Table } from 'semantic-ui-react';
import styles from './CsvViewer.module.scss';
const ROWS_PER_PAGE = 50;
/* eslint-disable react/no-array-index-key */
const CsvViewer = React.memo(({ src, className }) => {
const [rows, setRows] = useState(null);
const [csvData, setCsvData] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const frameStyles = useMemo(
@@ -33,7 +34,7 @@ const CsvViewer = React.memo(({ src, className }) => {
[],
);
const handlePageChange = useCallback((_, { activePage }) => {
const handlePageChange = useCallback((e, { activePage }) => {
setCurrentPage(activePage);
}, []);
@@ -48,46 +49,47 @@ const CsvViewer = React.memo(({ src, className }) => {
Papa.parse(text, {
skipEmptyLines: true,
complete: ({ data }) => {
setRows(data);
complete: (results) => {
const rows = results.data;
setCsvData({
rows,
totalRows: rows.length,
});
},
});
} catch {
/* empty */
} catch (err) {
setCsvData(null);
}
}
fetchFile();
}, [src]);
if (rows === null) {
return <Loader active size="big" />;
if (!csvData) {
return null;
}
const startIndex = (currentPage - 1) * ROWS_PER_PAGE;
const endIndex = startIndex + ROWS_PER_PAGE;
const currentRows = rows.slice(startIndex, endIndex);
const totalPages = Math.ceil(rows.length / ROWS_PER_PAGE);
const startIdx = (currentPage - 1) * ROWS_PER_PAGE;
const endIdx = startIdx + ROWS_PER_PAGE;
const currentRows = csvData.rows.slice(startIdx, endIdx);
const totalPages = Math.ceil(csvData.totalRows / ROWS_PER_PAGE);
return (
<Frame
head={<style>{frameStyles.join('')}</style>}
className={classNames(styles.wrapper, className)}
>
const content = (
<div>
<div>
<Table celled compact>
<Table.Header>
<Table.Row>
{rows[0].map((cell) => (
<Table.HeaderCell key={cell}>{cell}</Table.HeaderCell>
{csvData.rows[0].map((header, index) => (
<Table.HeaderCell key={index}>{header}</Table.HeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{currentRows.slice(1).map((row) => (
<Table.Row key={row}>
{row.map((cell) => (
<Table.Cell key={cell}>{cell}</Table.Cell>
{currentRows.slice(1).map((row, rowIndex) => (
<Table.Row key={rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Cell key={cellIndex}>{cell}</Table.Cell>
))}
</Table.Row>
))}
@@ -96,18 +98,30 @@ const CsvViewer = React.memo(({ src, className }) => {
</div>
{totalPages > 1 && (
<Pagination
secondary
pointing
totalPages={totalPages}
activePage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
firstItem={null}
lastItem={null}
onPageChange={handlePageChange}
pointing
secondary
boundaryRange={1}
siblingRange={1}
/>
)}
</div>
);
return (
<Frame
head={<style>{frameStyles.join('')}</style>}
className={classNames(styles.wrapper, className)}
>
{content}
</Frame>
);
});
/* eslint-enable react/no-array-index-key */
CsvViewer.propTypes = {
src: PropTypes.string.isRequired,

View File

@@ -16,7 +16,6 @@ import Encodings from '../../../constants/Encodings';
import { AttachmentTypes } from '../../../constants/Enums';
import ItemContent from './ItemContent';
import ContentViewer from './ContentViewer';
import PdfViewer from './PdfViewer';
import CsvViewer from './CsvViewer';
import styles from './Item.module.scss';
@@ -41,8 +40,10 @@ const Item = React.memo(({ id, isVisible }) => {
switch (attachment.data.mimeType) {
case 'application/pdf':
content = (
<PdfViewer
src={attachment.data.url}
// eslint-disable-next-line jsx-a11y/alt-text
<object
data={attachment.data.url}
type={attachment.data.mimeType}
className={classNames(styles.content, styles.contentViewer)}
/>
);
@@ -59,15 +60,6 @@ const Item = React.memo(({ id, isVisible }) => {
<audio controls src={attachment.data.url} className={styles.content} />
);
break;
case 'text/csv':
content = (
<CsvViewer
src={attachment.data.url}
className={classNames(styles.content, styles.contentViewer)}
/>
);
break;
case 'video/mp4':
case 'video/ogg':
@@ -77,10 +69,19 @@ const Item = React.memo(({ id, isVisible }) => {
<video controls src={attachment.data.url} className={styles.content} />
);
break;
case 'text/csv':
content = (
<CsvViewer
src={attachment.data.url}
className={classNames(styles.content, styles.contentViewer)}
/>
);
break;
default:
if (attachment.data.encoding === Encodings.UTF8) {
if (attachment.data.size <= Config.MAX_SIZE_TO_DISPLAY_CONTENT) {
if (attachment.data.sizeInBytes <= Config.MAX_SIZE_IN_BYTES_TO_DISPLAY_CONTENT) {
content = (
<ContentViewer
src={attachment.data.url}

View File

@@ -8,8 +8,6 @@
bottom: 0;
left: 0;
margin: auto;
max-height: 90%;
max-width: 90%;
position: absolute;
right: 0;
top: 0;
@@ -21,15 +19,10 @@
}
.contentError {
align-items: center;
color: #fff;
display: flex;
font-size: 20px;
font-weight: bold;
height: fit-content;
justify-content: center;
line-height: 1.2;
text-align: center;
width: fit-content;
height: 20px;
width: 470px;
}
}

View File

@@ -16,7 +16,7 @@ import { usePopupInClosableContext } from '../../../hooks';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { AttachmentTypes, BoardMembershipRoles } from '../../../constants/Enums';
import EditStep from './EditStep';
import Favicon from '../../common/Favicon';
import Favicon from './Favicon';
import TimeAgo from '../../common/TimeAgo';
import styles from './ItemContent.module.scss';
@@ -54,19 +54,6 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
}
}, [onOpen, attachment.data]);
const handleDownloadClick = useCallback(
(event) => {
event.stopPropagation();
const linkElement = document.createElement('a');
linkElement.href = attachment.data.url;
linkElement.download = attachment.data.filename;
linkElement.target = '_blank';
linkElement.click();
},
[attachment.data],
);
const handleToggleCoverClick = useCallback(
(event) => {
event.stopPropagation();
@@ -127,35 +114,25 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
<span className={styles.information}>
<TimeAgo date={attachment.createdAt} />
</span>
{attachment.type === AttachmentTypes.FILE && (
{attachment.type === AttachmentTypes.FILE && attachment.data.image && canEdit && (
<span className={styles.options}>
<button type="button" className={styles.option} onClick={handleDownloadClick}>
<Icon name="download" size="small" className={styles.optionIcon} />
<button type="button" className={styles.option} onClick={handleToggleCoverClick}>
<Icon
name="window maximize outline"
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{t('action.download', {
context: 'title',
})}
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
context: 'title',
})}
</span>
</button>
{attachment.data.image && canEdit && (
<button type="button" className={styles.option} onClick={handleToggleCoverClick}>
<Icon
name="window maximize outline"
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
context: 'title',
})}
</span>
</button>
)}
</span>
)}
</div>

View File

@@ -52,10 +52,6 @@
outline: none;
padding: 0;
&:not(:last-child) {
margin-right: 10px;
}
&:hover {
color: #172b4d;
}

View File

@@ -1,26 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './PdfViewer.module.scss';
const PdfViewer = React.memo(({ src, className }) => (
// eslint-disable-next-line jsx-a11y/iframe-has-title
<iframe src={src} type="application/pdf" className={classNames(styles.wrapper, className)} />
));
PdfViewer.propTypes = {
src: PropTypes.string.isRequired,
className: PropTypes.string,
};
PdfViewer.defaultProps = {
className: undefined,
};
export default PdfViewer;

View File

@@ -121,7 +121,7 @@ const ActionsStep = React.memo(({ boardMembershipId, title, onBack, onClose }) =
)}
{user.organization && (
<div className={styles.information}>
<Icon name="building" className={styles.informationIcon} />
<Icon name="briefcase" className={styles.informationIcon} />
{user.organization}
</div>
)}

View File

@@ -67,6 +67,5 @@
font-size: 14px;
line-height: 1.2;
padding: 2px 0 2px 2px;
word-wrap: break-word;
}
}

View File

@@ -3,7 +3,6 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import orderBy from 'lodash/orderBy';
import React, { useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
@@ -32,13 +31,10 @@ const PureBoardMembershipsStep = React.memo(
const filteredItems = useMemo(
() =>
orderBy(
items.filter(
({ user }) =>
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
({ user }) => user.name.toLowerCase(),
items.filter(
({ user }) =>
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[items, cleanSearch],
);

View File

@@ -12,7 +12,6 @@ import { BoardContexts, BoardViews } from '../../../constants/Enums';
import KanbanContent from './KanbanContent';
import FiniteContent from './FiniteContent';
import EndlessContent from './EndlessContent';
import ShortcutsProvider from './ShortcutsProvider';
import CardModal from '../../cards/CardModal';
import BoardActivitiesModal from '../../activities/BoardActivitiesModal';
@@ -54,9 +53,7 @@ const Board = React.memo(() => {
return (
<>
<ShortcutsProvider>
<Content />
</ShortcutsProvider>
<Content />
{modalNode}
</>
);

View File

@@ -30,17 +30,12 @@ const EndlessContent = React.memo(() => {
[dispatch],
);
const handleCardPaste = useCallback(() => {
dispatch(entryActions.pasteCardInCurrentList());
}, [dispatch]);
const viewProps = {
cardIds,
isCardsFetching,
isAllCardsFetched,
onCardsFetch: handleCardsFetch,
onCardCreate: handleCardCreate,
onCardPaste: handleCardPaste,
};
let View;

View File

@@ -15,21 +15,17 @@ import ListView from './ListView';
const FiniteContent = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard);
const cardIds = useSelector(selectors.selectFilteredCardIdsForCurrentBoard);
const canAddCard = useSelector((state) => !!selectors.selectFirstKanbanListId(state));
const hasAnyFiniteList = useSelector((state) => !!selectors.selectFirstFiniteListId(state));
const dispatch = useDispatch();
const handleCardCreate = useCallback(
(data, autoOpen) => {
dispatch(entryActions.createCardInCurrentContext(data, undefined, autoOpen));
dispatch(entryActions.createCardInFirstFiniteList(data, autoOpen));
},
[dispatch],
);
const handleCardPaste = useCallback(() => {
dispatch(entryActions.pasteCardInCurrentContext());
}, [dispatch]);
let View;
switch (board.view) {
case BoardViews.GRID:
@@ -43,13 +39,7 @@ const FiniteContent = React.memo(() => {
default:
}
return (
<View
cardIds={cardIds}
onCardCreate={canAddCard ? handleCardCreate : undefined}
onCardPaste={canAddCard ? handleCardPaste : undefined}
/>
);
return <View cardIds={cardIds} onCardCreate={hasAnyFiniteList ? handleCardCreate : undefined} />;
});
export default FiniteContent;

View File

@@ -5,11 +5,10 @@
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useInView } from 'react-intersection-observer';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Loader } from 'semantic-ui-react';
import { Button, Loader } from 'semantic-ui-react';
import { useWindowWidth } from '../../../lib/hooks';
import { Masonry } from '../../../lib/custom-ui';
@@ -22,18 +21,11 @@ import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
import styles from './GridView.module.scss';
const GridView = React.memo(
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate, onCardPaste }) => {
const clipboard = useSelector(selectors.selectClipboard);
const { canAddCard, canPasteCard } = useSelector((state) => {
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate }) => {
const canAddCard = useSelector((state) => {
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
return {
canAddCard: isEditor,
canPasteCard: isEditor,
};
}, shallowEqual);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const [t] = useTranslation();
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
@@ -67,31 +59,17 @@ const GridView = React.memo(
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
</div>
) : (
<div>
<div className={styles.addCardButtonWrapper}>
<Button
type="button"
disabled={!onCardCreate}
className={styles.addCardButton}
onClick={handleAddCardClick}
>
<PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}>
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
</span>
</Button>
{onCardPaste && clipboard && canPasteCard && (
<Button
type="button"
disabled={!onCardCreate}
className={classNames(styles.addCardButton, styles.paste)}
onClick={onCardPaste}
>
<Icon fitted name="paste" />
</Button>
)}
</div>
</div>
<Button
type="button"
disabled={!onCardCreate}
className={styles.addCardButton}
onClick={handleAddCardClick}
>
<PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}>
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
</span>
</Button>
))}
{cardIds.map((cardId) => (
<div key={cardId} className={styles.card}>
@@ -119,7 +97,6 @@ GridView.propTypes = {
isAllCardsFetched: PropTypes.bool,
onCardsFetch: PropTypes.func,
onCardCreate: PropTypes.func,
onCardPaste: PropTypes.func,
};
GridView.defaultProps = {
@@ -127,7 +104,6 @@ GridView.defaultProps = {
isAllCardsFetched: undefined,
onCardsFetch: undefined,
onCardCreate: undefined,
onCardPaste: undefined,
};
export default GridView;

View File

@@ -10,16 +10,16 @@
border-radius: 3px;
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
display: block;
fill: rgba(255, 255, 255, 0.72);
flex: 1;
font-weight: normal;
height: 42px;
margin: 0;
min-height: 42px;
padding: 11px;
text-align: left;
transition: background 85ms ease-in, opacity 40ms ease-in,
border-color 85ms ease-in;
width: 100%;
&:active {
outline: none;
@@ -28,10 +28,6 @@
&:hover {
background: rgba(0, 0, 0, 0.32);
}
&.paste {
flex: 0 0 auto;
}
}
.addCardButtonIcon {
@@ -47,11 +43,6 @@
vertical-align: top;
}
.addCardButtonWrapper {
display: flex;
gap: 6px;
}
.card {
background: rgba(223, 227, 230, 0.8);
border-radius: 3px;

View File

@@ -23,7 +23,7 @@ import styles from './KanbanContent.module.scss';
import globalStyles from '../../../../styles.module.scss';
const KanbanContent = React.memo(() => {
const listIds = useSelector(selectors.selectKanbanListIdsForCurrentBoard);
const listIds = useSelector(selectors.selectFiniteListIdsForCurrentBoard);
const canAddList = useSelector((state) => {
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?

View File

@@ -6,10 +6,10 @@
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useInView } from 'react-intersection-observer';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Loader } from 'semantic-ui-react';
import { Button, Loader } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { BoardMembershipRoles } from '../../../constants/Enums';
@@ -20,18 +20,11 @@ import PlusMathIcon from '../../../assets/images/plus-math-icon.svg?react';
import styles from './ListView.module.scss';
const ListView = React.memo(
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate, onCardPaste }) => {
const clipboard = useSelector(selectors.selectClipboard);
const { canAddCard, canPasteCard } = useSelector((state) => {
({ cardIds, isCardsFetching, isAllCardsFetched, onCardsFetch, onCardCreate }) => {
const canAddCard = useSelector((state) => {
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
return {
canAddCard: isEditor,
canPasteCard: isEditor,
};
}, shallowEqual);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const [t] = useTranslation();
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
@@ -61,29 +54,17 @@ const ListView = React.memo(
<AddCard onCreate={onCardCreate} onClose={handleAddCardClose} />
</div>
) : (
<div className={styles.addCardButtonWrapper}>
<Button
type="button"
disabled={!onCardCreate}
className={styles.addCardButton}
onClick={handleAddCardClick}
>
<PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}>
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
</span>
</Button>
{onCardPaste && clipboard && canPasteCard && (
<Button
type="button"
disabled={!onCardCreate}
className={classNames(styles.addCardButton, styles.paste)}
onClick={onCardPaste}
>
<Icon fitted name="paste" />
</Button>
)}
</div>
<Button
type="button"
disabled={!onCardCreate}
className={styles.addCardButton}
onClick={handleAddCardClick}
>
<PlusMathIcon className={styles.addCardButtonIcon} />
<span className={styles.addCardButtonText}>
{onCardCreate ? t('action.addCard') : t('common.atLeastOneListMustBePresent')}
</span>
</Button>
))}
{cardIds.length > 0 && (
<div className={classNames(styles.segment, styles.cards)}>
@@ -114,7 +95,6 @@ ListView.propTypes = {
isAllCardsFetched: PropTypes.bool,
onCardsFetch: PropTypes.func,
onCardCreate: PropTypes.func,
onCardPaste: PropTypes.func,
};
ListView.defaultProps = {
@@ -122,7 +102,6 @@ ListView.defaultProps = {
isAllCardsFetched: undefined,
onCardsFetch: undefined,
onCardCreate: undefined,
onCardPaste: undefined,
};
export default ListView;

View File

@@ -12,14 +12,14 @@
cursor: pointer;
display: block;
fill: rgba(255, 255, 255, 0.72);
flex: 1;
font-weight: normal;
height: 42px;
margin: 0;
margin-bottom: 12px;
padding: 11px;
text-align: left;
transition: background 85ms ease-in, opacity 40ms ease-in,
border-color 85ms ease-in;
width: 100%;
&:active {
outline: none;
@@ -28,10 +28,6 @@
&:hover {
background: rgba(0, 0, 0, 0.32);
}
&.paste {
flex: 0 0 auto;
}
}
.addCardButtonIcon {
@@ -47,12 +43,6 @@
vertical-align: top;
}
.addCardButtonWrapper {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.card {
margin-bottom: 6px;
}

View File

@@ -1,433 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { push } from '../../../lib/redux-router';
import { useDidUpdate } from '../../../lib/hooks';
import { closePopup } from '../../../lib/popup';
import store from '../../../store';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { isActiveTextElement } from '../../../utils/element-helpers';
import { isModifierKeyPressed } from '../../../utils/event-helpers';
import { BoardShortcutsContext } from '../../../contexts';
import Paths from '../../../constants/Paths';
import {
BoardContexts,
BoardMembershipRoles,
BoardViews,
ListTypes,
} from '../../../constants/Enums';
import CardActionsStep from '../../cards/CardActionsStep';
const canCopyCard = (isManager, boardMembership) => {
if (isManager) {
return true;
}
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
};
const canCutCard = (boardMembership) =>
!!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
const canPasteCard = (boardMembership) =>
!!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
const canEditCardName = (boardMembership, list) => {
if (isListArchiveOrTrash(list)) {
return false;
}
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
};
const canArchiveCard = (boardMembership, list) => {
if (list.type === ListTypes.ARCHIVE) {
return false;
}
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
};
const canUseCardMembers = (boardMembership, list) => {
if (isListArchiveOrTrash(list)) {
return false;
}
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
};
const canUseCardLabels = (boardMembership, list) => {
if (isListArchiveOrTrash(list)) {
return false;
}
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
};
const ShortcutsProvider = React.memo(({ children }) => {
const { cardId, boardId } = useSelector(selectors.selectPath);
const dispatch = useDispatch();
const selectedListRef = useRef(null);
const selectedCardRef = useRef(null);
const handleListMouseEnter = useCallback((id, onPaste) => {
selectedListRef.current = {
id,
onPaste,
};
}, []);
const handleListMouseLeave = useCallback(() => {
selectedListRef.current = null;
}, []);
const handleCardMouseEnter = useCallback((id, editName, openActions) => {
selectedCardRef.current = {
id,
editName,
openActions,
};
}, []);
const handleCardMouseLeave = useCallback(() => {
selectedCardRef.current = null;
}, []);
const contextValue = useMemo(
() => [handleListMouseEnter, handleListMouseLeave, handleCardMouseEnter, handleCardMouseLeave],
[handleListMouseEnter, handleListMouseLeave, handleCardMouseEnter, handleCardMouseLeave],
);
useDidUpdate(() => {
selectedListRef.current = null;
selectedCardRef.current = null;
}, [cardId, boardId]);
useEffect(() => {
const handleCardCopy = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
if (!canCopyCard(isManager, boardMembership)) {
return;
}
event.preventDefault();
dispatch(entryActions.copyCard(card.id));
};
const handleCardCut = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
if (!canCutCard(boardMembership)) {
return;
}
event.preventDefault();
dispatch(entryActions.cutCard(card.id));
};
const handleCardPaste = (event) => {
const state = store.getState();
const clipboard = selectors.selectClipboard(state);
if (!clipboard) {
return;
}
const board = selectors.selectCurrentBoard(state);
let listId;
if (board.context === BoardContexts.BOARD) {
if (board.view === BoardViews.KANBAN) {
listId = selectedListRef.current?.id;
} else {
listId = selectors.selectFirstKanbanListId(state);
}
} else {
listId = selectors.selectCurrentListId(state);
}
if (!listId) {
return;
}
const list = selectors.selectListById(state, listId);
if (!list || !list.isPersisted) {
return;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
if (!canPasteCard(boardMembership)) {
return;
}
event.preventDefault();
dispatch(entryActions.pasteCard(list.id));
if (selectedListRef.current) {
selectedListRef.current.onPaste();
}
};
const handleCardOpen = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
event.preventDefault();
closePopup();
dispatch(push(Paths.CARDS.replace(':id', card.id)));
};
const handleCardNameEdit = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const list = selectors.selectListById(state, card.listId);
if (!canEditCardName(boardMembership, list)) {
return;
}
event.preventDefault();
selectedCardRef.current.editName();
};
const handleCardArchive = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const list = selectors.selectListById(state, card.listId);
if (!canArchiveCard(boardMembership, list)) {
return;
}
event.preventDefault();
selectedCardRef.current.openActions(CardActionsStep.StepTypes.ARCHIVE);
};
const handleCardMembers = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const list = selectors.selectListById(state, card.listId);
if (!canUseCardMembers(boardMembership, list)) {
return;
}
event.preventDefault();
selectedCardRef.current.openActions(CardActionsStep.StepTypes.MEMBERS);
};
const handleCardLabels = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const list = selectors.selectListById(state, card.listId);
if (!canUseCardLabels(boardMembership, list)) {
return;
}
event.preventDefault();
selectedCardRef.current.openActions(CardActionsStep.StepTypes.LABELS);
};
const handleLabelToCardAdd = (event) => {
if (!selectedCardRef.current) {
return;
}
const state = store.getState();
const card = selectors.selectCardById(state, selectedCardRef.current.id);
if (!card || !card.isPersisted) {
return;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const list = selectors.selectListById(state, card.listId);
if (!canUseCardLabels(boardMembership, list)) {
return;
}
const index = event.code === 'Digit0' ? 10 : parseInt(event.code.slice(-1), 10) - 1;
const label = selectors.selectLabelsForCurrentBoard(state)[index];
if (!label) {
return;
}
event.preventDefault();
const labelIds = selectors.selectLabelIdsByCardId(state, card.id);
if (labelIds.includes(label.id)) {
dispatch(entryActions.removeLabelFromCard(label.id, card.id));
} else {
dispatch(entryActions.addLabelToCard(label.id, card.id));
}
};
const handleKeyDown = (event) => {
if (isActiveTextElement(event.target)) {
return;
}
if (isModifierKeyPressed(event)) {
switch (event.key) {
case 'c':
handleCardCopy(event);
break;
case 'x':
handleCardCut(event);
break;
case 'v':
handleCardPaste(event);
break;
default:
}
return;
}
switch (event.code) {
case 'KeyE':
case 'Enter':
handleCardOpen(event);
break;
case 'KeyL':
handleCardLabels(event);
break;
case 'KeyM':
handleCardMembers(event);
break;
case 'KeyT':
handleCardNameEdit(event);
break;
case 'KeyV':
handleCardArchive(event);
break;
case 'Digit1':
case 'Digit2':
case 'Digit3':
case 'Digit4':
case 'Digit5':
case 'Digit6':
case 'Digit7':
case 'Digit8':
case 'Digit9':
case 'Digit0':
handleLabelToCardAdd(event);
break;
default:
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [dispatch]);
return (
<BoardShortcutsContext.Provider value={contextValue}>{children}</BoardShortcutsContext.Provider>
);
});
ShortcutsProvider.propTypes = {
children: PropTypes.element.isRequired,
};
export default ShortcutsProvider;

View File

@@ -6,12 +6,8 @@
import React from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { BoardContexts } from '../../../constants/Enums';
import { BoardContextIcons } from '../../../constants/Icons';
import Filters from './Filters';
import RightSide from './RightSide';
import BoardMemberships from '../../board-memberships/BoardMemberships';
@@ -19,15 +15,7 @@ import BoardMemberships from '../../board-memberships/BoardMemberships';
import styles from './BoardActions.module.scss';
const BoardActions = React.memo(() => {
const boardContext = useSelector((state) => selectors.selectCurrentBoard(state).context);
const withContextTitle = boardContext !== BoardContexts.BOARD;
const withMemberships = useSelector((state) => {
if (withContextTitle) {
return false;
}
const boardMemberships = selectors.selectMembershipsForCurrentBoard(state);
if (boardMemberships.length > 0) {
@@ -37,19 +25,9 @@ const BoardActions = React.memo(() => {
return selectors.selectIsCurrentUserManagerForCurrentProject(state);
});
const [t] = useTranslation();
return (
<div className={styles.wrapper}>
<div className={styles.actions}>
{withContextTitle && (
<div className={styles.action}>
<div className={styles.contextTitle}>
<Icon name={BoardContextIcons[boardContext]} className={styles.contextTitleIcon} />
{t(`common.${boardContext}`)}
</div>
</div>
)}
{withMemberships && (
<div className={styles.action}>
<BoardMemberships />

View File

@@ -31,18 +31,6 @@
height: 46px;
}
.contextTitle {
background: rgba(0, 0, 0, 0.08);
border-radius: 3px;
color: #fff;
line-height: 34px;
padding: 2px 14px;
}
.contextTitleIcon {
margin-right: 8px;
}
.wrapper {
overflow-x: auto;
overflow-y: hidden;

View File

@@ -141,7 +141,7 @@ const ActionsStep = React.memo(({ onClose }) => {
</Menu.Item>
{withTrashEmptier && (
<>
<hr className={styles.divider} />
{(withSubscribe || withCustomFieldGroups) && <hr className={styles.divider} />}
<Menu.Item className={styles.menuItem} onClick={handleEmptyTrashClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.emptyTrash', {
@@ -151,7 +151,7 @@ const ActionsStep = React.memo(({ onClose }) => {
</>
)}
<>
<hr className={styles.divider} />
{(withSubscribe || withTrashEmptier) && <hr className={styles.divider} />}
{[BoardContexts.BOARD, BoardContexts.ARCHIVE, BoardContexts.TRASH].map((context) => (
<Menu.Item
key={context}

View File

@@ -12,7 +12,7 @@
}
.menu {
margin: 0 -12px -5px;
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
@@ -23,6 +23,6 @@
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
margin-right: 0.5em;
}
}

View File

@@ -11,7 +11,7 @@ import { usePopup } from '../../../../lib/popup';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { BoardContexts, BoardViews } from '../../../../constants/Enums';
import { BoardViewIcons } from '../../../../constants/Icons';
import { BoardContextIcons, BoardViewIcons } from '../../../../constants/Icons';
import ActionsStep from './ActionsStep';
import styles from './RightSide.module.scss';
@@ -56,7 +56,7 @@ const RightSide = React.memo(() => {
<div className={styles.action}>
<ActionsPopup>
<button type="button" className={styles.button}>
<Icon fitted name="ellipsis vertical" />
<Icon fitted name={BoardContextIcons[board.context]} />
</button>
</ActionsPopup>
</div>

View File

@@ -43,14 +43,6 @@ const Others = React.memo(() => {
className={styles.radio}
onChange={handleChange}
/>
<Radio
toggle
name="expandTaskListsByDefault"
checked={board.expandTaskListsByDefault}
label={t('common.expandTaskListsByDefault')}
className={styles.radio}
onChange={handleChange}
/>
</Segment>
);
});

View File

@@ -9,20 +9,20 @@ import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Icon } from 'semantic-ui-react';
import { useDidUpdate, useToggle } from '../../../lib/hooks';
import { Input, Popup } from '../../../lib/custom-ui';
import { useDidUpdate, useToggle } from '../../../../lib/hooks';
import { Input, Popup } from '../../../../lib/custom-ui';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef, useSteps } from '../../../hooks';
import entryActions from '../../../../entry-actions';
import { useForm, useNestedRef, useSteps } from '../../../../hooks';
import ImportStep from './ImportStep';
import styles from './AddBoardStep.module.scss';
import styles from './AddStep.module.scss';
const StepTypes = {
IMPORT: 'IMPORT',
};
const AddBoardStep = React.memo(({ onClose }) => {
const AddStep = React.memo(({ onClose }) => {
const dispatch = useDispatch();
const [t] = useTranslation();
@@ -123,8 +123,8 @@ const AddBoardStep = React.memo(({ onClose }) => {
);
});
AddBoardStep.propTypes = {
AddStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default AddBoardStep;
export default AddStep;

View File

@@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../lib/custom-ui';
import { FilePicker, Popup } from '../../../../lib/custom-ui';
import styles from './ImportStep.module.scss';

View File

@@ -3,6 +3,6 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import Linkify from './Linkify';
import AddStep from './AddStep';
export default Linkify;
export default AddStep;

View File

@@ -13,7 +13,7 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import DroppableTypes from '../../../constants/DroppableTypes';
import Item from './Item';
import AddBoardStep from '../AddBoardStep';
import AddStep from './AddStep';
import styles from './Boards.module.scss';
import globalStyles from '../../../styles.module.scss';
@@ -59,7 +59,7 @@ const Boards = React.memo(() => {
});
}, []);
const AddBoardPopup = usePopup(AddBoardStep);
const AddPopup = usePopup(AddStep);
return (
<div className={styles.wrapper} onWheel={handleWheel}>
@@ -74,9 +74,9 @@ const Boards = React.memo(() => {
))}
{placeholder}
{canAdd && (
<AddBoardPopup>
<AddPopup>
<Button icon="plus" className={styles.addButton} />
</AddBoardPopup>
</AddPopup>
)}
</div>
)}

View File

@@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router';
import { Link } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd';
import { Button, Icon } from 'semantic-ui-react';

View File

@@ -166,6 +166,7 @@ const AddCard = React.memo(({ isOpened, className, onCreate, onClose }) => {
placeholder={t('common.enterCardTitle')}
maxLength={1024}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}

View File

@@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon, Menu } from 'semantic-ui-react';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
@@ -23,12 +23,12 @@ import ConfirmationStep from '../../common/ConfirmationStep';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelsStep from '../../labels/LabelsStep';
import styles from './CardActionsStep.module.scss';
import styles from './ActionsStep.module.scss';
const StepTypes = {
MEMBERS: 'MEMBERS',
LABELS: 'LABELS',
EDIT_TYPE: 'EDIT_TYPE',
USERS: 'USERS',
LABELS: 'LABELS',
EDIT_DUE_DATE: 'EDIT_DUE_DATE',
EDIT_STOPWATCH: 'EDIT_STOPWATCH',
MOVE: 'MOVE',
@@ -36,7 +36,7 @@ const StepTypes = {
DELETE: 'DELETE',
};
const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }) => {
const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -60,8 +60,6 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canEditName,
canEditDueDate,
canEditStopwatch,
canCopy,
canCut,
canDuplicate,
canMove,
canRestore,
@@ -70,8 +68,6 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canUseMembers,
canUseLabels,
} = useSelector((state) => {
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
@@ -81,8 +77,6 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canEditName: false,
canEditDueDate: false,
canEditStopwatch: false,
canCopy: isManager || isEditor,
canCut: isEditor,
canDuplicate: false,
canMove: false,
canRestore: isEditor,
@@ -98,8 +92,6 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
canEditName: isEditor,
canEditDueDate: isEditor,
canEditStopwatch: isEditor,
canCopy: isManager || isEditor,
canCut: isEditor,
canDuplicate: isEditor,
canMove: isEditor,
canRestore: null,
@@ -112,104 +104,7 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps(defaultStep || null);
const withActionBar = useMemo(() => {
let menuItemsTotal = 0;
let actionBarItemsTotal = 0;
if (card.type === CardTypes.PROJECT && canUseMembers) menuItemsTotal += 1;
if (canUseLabels) menuItemsTotal += 1;
if (card.type !== CardTypes.PROJECT && canUseMembers) menuItemsTotal += 1;
if (card.type === CardTypes.PROJECT && canEditDueDate) menuItemsTotal += 1;
if (card.type === CardTypes.PROJECT && canEditStopwatch) menuItemsTotal += 1;
if (canEditName) menuItemsTotal += 1;
if (!board.limitCardTypesToDefaultOne && canEditType) menuItemsTotal += 1;
if (canDuplicate) menuItemsTotal += 1;
if (canMove) menuItemsTotal += 1;
if (prevList && canRestore) menuItemsTotal += 1;
if (canCopy) {
menuItemsTotal += 1;
actionBarItemsTotal += 1;
}
if (canCut) {
menuItemsTotal += 1;
actionBarItemsTotal += 1;
}
if (list.type !== ListTypes.ARCHIVE && canArchive) {
menuItemsTotal += 1;
actionBarItemsTotal += 1;
}
if (canDelete) {
menuItemsTotal += 1;
actionBarItemsTotal += 1;
}
return menuItemsTotal > 4 && actionBarItemsTotal > 1;
}, [
board.limitCardTypesToDefaultOne,
card.type,
list.type,
prevList,
canEditType,
canEditName,
canEditDueDate,
canEditStopwatch,
canCopy,
canCut,
canDuplicate,
canMove,
canRestore,
canArchive,
canDelete,
canUseMembers,
canUseLabels,
]);
const hasTopSection = useMemo(() => {
return (
(card.type === CardTypes.PROJECT && canUseMembers) ||
canUseLabels ||
(card.type !== CardTypes.PROJECT && canUseMembers) ||
(card.type === CardTypes.PROJECT && canEditDueDate) ||
(card.type === CardTypes.PROJECT && canEditStopwatch) ||
canEditName ||
(!board.limitCardTypesToDefaultOne && canEditType)
);
}, [
board.limitCardTypesToDefaultOne,
card.type,
canEditType,
canEditName,
canEditDueDate,
canEditStopwatch,
canUseMembers,
canUseLabels,
]);
const hasBottomSection = useMemo(() => {
return (
(canCopy && !withActionBar) ||
(canCut && !withActionBar) ||
canDuplicate ||
canMove ||
(prevList && canRestore) ||
(list.type !== ListTypes.ARCHIVE && canArchive && !withActionBar) ||
(canDelete && !withActionBar)
);
}, [
list.type,
prevList,
canCopy,
canCut,
canDuplicate,
canMove,
canRestore,
canArchive,
canDelete,
withActionBar,
]);
const [step, openStep, handleBack] = useSteps();
const handleTypeSelect = useCallback(
(type) => {
@@ -222,20 +117,17 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
[cardId, dispatch],
);
const handleCopyClick = useCallback(() => {
dispatch(entryActions.copyCard(cardId));
onClose();
}, [cardId, onClose, dispatch]);
const handleCutClick = useCallback(() => {
dispatch(entryActions.cutCard(cardId));
onClose();
}, [cardId, onClose, dispatch]);
const handleDuplicateClick = useCallback(() => {
dispatch(entryActions.duplicateCard(cardId));
dispatch(
entryActions.duplicateCard(cardId, {
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [cardId, onClose, dispatch]);
}, [cardId, onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCard(cardId, card.prevListId, undefined, true));
@@ -288,18 +180,18 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
onClose();
}, [onNameEdit, onClose]);
const handleMembersClick = useCallback(() => {
openStep(StepTypes.MEMBERS);
const handleEditTypeClick = useCallback(() => {
openStep(StepTypes.EDIT_TYPE);
}, [openStep]);
const handleUsersClick = useCallback(() => {
openStep(StepTypes.USERS);
}, [openStep]);
const handleLabelsClick = useCallback(() => {
openStep(StepTypes.LABELS);
}, [openStep]);
const handleEditTypeClick = useCallback(() => {
openStep(StepTypes.EDIT_TYPE);
}, [openStep]);
const handleEditDueDateClick = useCallback(() => {
openStep(StepTypes.EDIT_DUE_DATE);
}, [openStep]);
@@ -322,7 +214,19 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
if (step) {
switch (step.type) {
case StepTypes.MEMBERS:
case StepTypes.EDIT_TYPE:
return (
<SelectCardTypeStep
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.USERS:
return (
<BoardMembershipsStep
currentUserIds={userIds}
@@ -341,18 +245,6 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
onBack={handleBack}
/>
);
case StepTypes.EDIT_TYPE:
return (
<SelectCardTypeStep
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.EDIT_DUE_DATE:
return <EditDueDateStep cardId={cardId} onBack={handleBack} onClose={onClose} />;
case StepTypes.EDIT_STOPWATCH:
@@ -396,49 +288,8 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{card.type === CardTypes.PROJECT && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleMembersClick}>
<Icon name="user outline" className={styles.menuItemIcon} />
{t('common.members', {
context: 'title',
})}
</Menu.Item>
)}
{canUseLabels && (
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
<Icon name="bookmark outline" className={styles.menuItemIcon} />
{t('common.labels', {
context: 'title',
})}
</Menu.Item>
)}
{card.type !== CardTypes.PROJECT && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleMembersClick}>
<Icon name="user outline" className={styles.menuItemIcon} />
{t('common.members', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.PROJECT && canEditDueDate && (
<Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}>
<Icon name="calendar check outline" className={styles.menuItemIcon} />
{t('action.editDueDate', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.PROJECT && canEditStopwatch && (
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
<Icon name="clock outline" className={styles.menuItemIcon} />
{t('action.editStopwatch', {
context: 'title',
})}
</Menu.Item>
)}
{canEditName && (
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
<Icon name="edit outline" className={styles.menuItemIcon} />
{t('action.editTitle', {
context: 'title',
})}
@@ -446,32 +297,48 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
<Icon name="map outline" className={styles.menuItemIcon} />
{t('action.editType', {
context: 'title',
})}
</Menu.Item>
)}
{hasTopSection && hasBottomSection && <hr className={styles.divider} />}
{canCopy && !withActionBar && (
<Menu.Item className={styles.menuItem} onClick={handleCopyClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />
{t('action.copyCard', {
{card.type === CardTypes.PROJECT && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
{t('common.members', {
context: 'title',
})}
</Menu.Item>
)}
{canCut && !withActionBar && (
<Menu.Item className={styles.menuItem} onClick={handleCutClick}>
<Icon name="cut" className={styles.menuItemIcon} />
{t('action.cutCard', {
{canUseLabels && (
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
{t('common.labels', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.STORY && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
{t('common.members', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.PROJECT && canEditDueDate && (
<Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}>
{t('action.editDueDate', {
context: 'title',
})}
</Menu.Item>
)}
{card.type === CardTypes.PROJECT && canEditStopwatch && (
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
{t('action.editStopwatch', {
context: 'title',
})}
</Menu.Item>
)}
{canDuplicate && (
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />
{t('action.duplicateCard', {
context: 'title',
})}
@@ -479,7 +346,6 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
)}
{canMove && (
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
<Icon name="share square outline" className={styles.menuItemIcon} />
{t('action.moveCard', {
context: 'title',
})}
@@ -487,24 +353,21 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
)}
{prevList && canRestore && (
<Menu.Item className={styles.menuItem} onClick={handleRestoreClick}>
<Icon name="undo alternate" className={styles.menuItemIcon} />
{t('action.restoreToList', {
context: 'title',
list: prevList.name || t(`common.${prevList.type}`),
})}
</Menu.Item>
)}
{list.type !== ListTypes.ARCHIVE && canArchive && !withActionBar && (
{list.type !== ListTypes.ARCHIVE && canArchive && (
<Menu.Item className={styles.menuItem} onClick={handleArchiveClick}>
<Icon name="folder open outline" className={styles.menuItemIcon} />
{t('action.archiveCard', {
context: 'title',
})}
</Menu.Item>
)}
{canDelete && !withActionBar && (
{canDelete && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{isInTrashList
? t('action.deleteForever', {
context: 'title',
@@ -514,86 +377,16 @@ const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }
})}
</Menu.Item>
)}
{withActionBar && (
<>
<hr className={styles.divider} />
<div className={styles.actionBar}>
{canCopy && (
/* eslint-disable-next-line jsx-a11y/anchor-is-valid,
jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<a className={styles.actionBarItem} onClick={handleCopyClick}>
<Icon fitted name="copy outline" />
<span className={styles.actionBarItemText}>
{t('action.copy', {
context: 'title',
})}
</span>
</a>
)}
{canCut && (
/* eslint-disable-next-line jsx-a11y/anchor-is-valid,
jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<a className={styles.actionBarItem} onClick={handleCutClick}>
<Icon fitted name="cut" />
<span className={styles.actionBarItemText}>
{t('action.cut', {
context: 'title',
})}
</span>
</a>
)}
{list.type !== ListTypes.ARCHIVE && canArchive && (
/* eslint-disable-next-line jsx-a11y/anchor-is-valid,
jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<a className={styles.actionBarItem} onClick={handleArchiveClick}>
<Icon fitted name="archive" />
<span className={styles.actionBarItemText}>
{t('action.archive', {
context: 'title',
})}
</span>
</a>
)}
{canDelete && (
/* eslint-disable-next-line jsx-a11y/anchor-is-valid,
jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */
<a className={styles.actionBarItem} onClick={handleDeleteClick}>
<Icon fitted name="trash alternate outline" />
<span className={styles.actionBarItemText}>
{isInTrashList
? t('action.deleteForever', {
context: 'title',
})
: t('action.delete', {
context: 'title',
})}
</span>
</a>
)}
</div>
</>
)}
</Menu>
</Popup.Content>
</>
);
});
CardActionsStep.propTypes = {
ActionsStep.propTypes = {
cardId: PropTypes.string.isRequired,
defaultStep: PropTypes.string,
onNameEdit: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
CardActionsStep.defaultProps = {
defaultStep: undefined,
};
CardActionsStep.StepTypes = StepTypes;
export default CardActionsStep;
export default ActionsStep;

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