1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

Compare commits

...

121 commits

Author SHA1 Message Date
Maksim Eltyshev
fdac299fc7 feat: Open card actions on right-click 2025-07-17 17:43:01 +02:00
Maksim Eltyshev
e659ed4a2d fix: Add icons to popup menu items 2025-07-17 15:23:53 +02:00
Maksim Eltyshev
adf60c0c74 fix: Enable spellcheck on all textareas 2025-07-17 14:18:30 +02:00
uppertoe
71b4fd32cc
fix: Fix typo in English translations (#1259) 2025-07-16 12:18:56 +02:00
Maksim Eltyshev
b3f226c10b fix: Fix red curtain background gradient 2025-07-15 17:42:46 +02:00
Symon Baikov
b4be12eb28
fix: Update Greek translation (#1255) 2025-07-15 13:13:52 +02:00
Maksim Eltyshev
3aba4d4a56 ref: Little refactoring 2025-07-14 14:54:06 +02:00
Symon Baikov
70cadcd974
fix: Update Finnish translation (#1251) 2025-07-11 12:16:22 +02:00
Maksim Eltyshev
230f50e3d9 feat: Add ability to link tasks to cards 2025-07-11 01:04:02 +02:00
HanJiang-cn
49203e9d56
fix: Update Chinese translation (#1250) 2025-07-09 17:53:58 +02:00
Maksim Eltyshev
709a0d1758 feat: Persist closed state per card 2025-07-09 17:45:47 +02:00
Maksim Eltyshev
69c75a03b1 fix: Fix toolbar styles in markdown markup editor 2025-07-08 17:26:21 +02:00
Daniel Silvestre
4d40af9c8a
feat: Display card name when linking in task (#1234) 2025-07-08 17:15:48 +02:00
Maksim Eltyshev
b76ca9547f fix: Handle webhooks correctly during socket reconnection 2025-07-08 12:41:27 +02:00
Maksim Eltyshev
9d07178e57 fix: Move webhooks declaration to correct position 2025-07-08 00:22:24 +02:00
Maksim Eltyshev
4346b7040a feat: Add INTERNAL_ACCESS_TOKEN to support internal user configuration 2025-07-07 21:35:37 +02:00
Winston-1874
b61b4e658f
fix: Update French translation (#1242) 2025-07-07 13:01:25 +02:00
Symon Baikov
a8b9ac8165
fix: Update Turkish translation (#1239) 2025-07-07 12:03:14 +02:00
Daniel Silvestre
a55fc08c43
fix: Update French translation (#1238) 2025-07-07 12:00:36 +02:00
Symon Baikov
76c7be5063
fix: Update German translation (#1235) 2025-07-07 11:47:21 +02:00
Maksim Eltyshev
b22dba0d11 feat: Move webhooks configuration from environment variable to UI 2025-07-04 22:04:11 +02:00
Maksim Eltyshev
f0680831c2 fix: Improve PDF viewer cross-browser compatibility
Closes #1219
2025-07-02 18:26:03 +02:00
Maksim Eltyshev
e58a9f5d21 chore: Update dependencies 2025-06-30 12:43:06 +02:00
dependabot[bot]
463b6284c7
build(deps): Bump pbkdf2 from 3.1.2 to 3.1.3 in /client (#1223)
Bumps [pbkdf2](https://github.com/crypto-browserify/pbkdf2) from 3.1.2 to 3.1.3.
- [Changelog](https://github.com/browserify/pbkdf2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crypto-browserify/pbkdf2/compare/v3.1.2...v3.1.3)

---
updated-dependencies:
- dependency-name: pbkdf2
  dependency-version: 3.1.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 12:23:57 +02:00
Maksim Eltyshev
f5653cde80 chore: Add missing environment variables for OIDC roles 2025-06-30 12:07:44 +02:00
Roman T
b37fc61b12
docs: Add example SMTP config using extraEnv in Helm (#1217) 2025-06-18 15:11:03 +02:00
Maksim Eltyshev
6988864808 fix: Fix filtering by task assignees 2025-06-17 16:04:22 +02:00
millenium-codebug
c8cb1f4a20
feat: Include task assignees in member filter logic (#1214) 2025-06-16 23:16:57 +02:00
Maksim Eltyshev
774bdc2b64 fix: Display tasks with preserved newlines in card modal 2025-06-16 22:44:27 +02:00
Maksim Eltyshev
b2e8c453b7 fix: Use correct action to clear user creation error 2025-06-16 22:37:41 +02:00
Maksim Eltyshev
8df49a636a build: Use Node.js v22 2025-06-16 21:32:54 +02:00
Symon Baikov
2d1a8658bb
feat: Add Estonian translation (#1212) 2025-06-16 20:39:50 +02:00
Maksim Eltyshev
3126a40bba feat: Add copy-to-clipboard for custom fields 2025-06-11 00:14:42 +02:00
Maksim Eltyshev
99a06ce1ae feat: Make card descriptions expandable 2025-06-11 00:09:33 +02:00
Maksim Eltyshev
98a7f1c9a9 feat: Strikethrough cards in closed lists
Closes #1207
2025-06-10 23:43:26 +02:00
Symon Baikov
bdac15240f
fix: Update Serbian (Cyrillic) translation (#1206) 2025-06-10 18:54:23 +02:00
Maksim Eltyshev
5a3a4240c6 fix: Add cancel button when editing comment 2025-06-10 14:00:08 +02:00
Maksim Eltyshev
144731ccb4 fix: Increase max username length for identity provider users
Closes #1174
2025-06-10 13:49:23 +02:00
Symon Baikov
e24bb845c6
fix: Update Slovak translation (#1205) 2025-06-10 12:18:24 +02:00
Symon Baikov
240b01e694
fix: Update Romanian translation (#1203) 2025-06-09 23:42:44 +02:00
Symon Baikov
07483909b4
fix: Update Brazilian Portuguese translation (#1202) 2025-06-09 23:40:35 +02:00
Symon Baikov
84dc0385af
fix: Update Dutch translation (#1201) 2025-06-09 12:35:31 +02:00
Symon Baikov
041199af8b
fix: Update Korean translation (#1197) 2025-06-09 12:33:51 +02:00
Symon Baikov
f72ac437b9
fix: Update Japanese translation (#1196) 2025-06-09 12:32:17 +02:00
Symon Baikov
128028c848
fix: Update Italian translation (#1195) 2025-06-09 12:30:12 +02:00
Symon Baikov
0f1e7ecc2c
fix: Update Indonesian translation (#1194) 2025-06-08 13:11:03 +02:00
Symon Baikov
9ca49f1f51
fix: Update Hungarian translation (#1193) 2025-06-08 13:09:31 +02:00
Symon Baikov
8447f0c884
fix: Update French translation (#1192) 2025-06-08 13:08:17 +02:00
Symon Baikov
180984799c
fix: Update Spanish translation (#1189) 2025-06-08 13:06:40 +02:00
Maksim Eltyshev
2b86484421 fix: FIx deps in download attachment handler 2025-06-07 19:23:15 +02:00
Maksim Eltyshev
a6820162fb fix: Fix mentions input styles 2025-06-07 18:03:36 +02:00
Symon Baikov
037a9101c2
fix: Update Bulgarian translation (#1187) 2025-06-07 16:33:15 +02:00
Symon Baikov
754629e6a9
fix: Update Czech translation (#1185) 2025-06-07 13:52:33 +02:00
Symon Baikov
8bc1569242
fix: Responsive label color selector, improve action button styles (#1184) 2025-06-07 13:44:45 +02:00
Maksim Eltyshev
1a8dcd9858 fix: Update Chinese translation
Closes #1181
2025-06-06 18:20:40 +02:00
Maksim Eltyshev
edda5dda79 ref: Format translations 2025-06-06 18:17:25 +02:00
Symon Baikov
6dbce8c790
feat: Add Finnish translation (#1180) 2025-06-06 18:00:13 +02:00
Symon Baikov
b696fe0e4d
feat: Add Greek translation (#1176) 2025-06-06 14:14:00 +02:00
Maksim Eltyshev
7286ecaf35 fix: Align text to center in content error message 2025-06-06 13:54:01 +02:00
Maksim Eltyshev
2e6658221f fix: Handle escape actions properly in mentions input 2025-06-06 12:48:43 +02:00
Maksim Eltyshev
63de346b0e ref: Little refactoring 2025-06-06 12:34:09 +02:00
Christian T R
585464eef3
fix: Update Danish translation (#1177) 2025-06-06 12:07:02 +02:00
Maksim Eltyshev
c2bd31d523 fix: Use console as logger for YFM note
Closes #1178
2025-06-06 11:49:35 +02:00
Maksim Eltyshev
21f90610bc fix: Prevent auto-filling title in YFM note
Closes #1172
2025-06-05 15:10:46 +02:00
Christian T R
608a7c983f
fix: Update Danish translation (#1173) 2025-06-05 14:06:45 +02:00
Maksim Eltyshev
dd5e0f448f fix: Prevent text overflow in card name 2025-06-05 00:01:34 +02:00
Maksim Eltyshev
c4a48d510b feat: Add download button for file attachments 2025-06-05 00:00:36 +02:00
Mario Aparcero
2f62d56242
fix: Update Spanish translation (#1170) 2025-06-04 22:45:28 +02:00
Maksim Eltyshev
4eb8b75c24 license: Remove ambiguity in usage restrictions 2025-06-04 21:04:00 +02:00
Maksim Eltyshev
49770ea9ec ref: Move comment counter logic into Card model 2025-06-04 18:38:39 +02:00
Anthony
40a84d0c8a
feat: Allow to add custom annotations for deployment in Helm (#1168) 2025-06-04 12:25:43 +02:00
Maksim Eltyshev
9690f7b73f fix: Prevent text overflow in activities 2025-06-03 23:15:07 +02:00
Maksim Eltyshev
04b97b66cb chore: Update dependencies 2025-06-03 13:02:16 +02:00
Maksim Eltyshev
46f4d5c1f8 fix: Canonicalize locale codes 2025-06-03 12:46:06 +02:00
Maksim Eltyshev
fc7863aaaf fix: Add mentions support when editing comments 2025-06-02 21:35:51 +02:00
Maksim Eltyshev
6c2999044b chore: Update logo assets 2025-06-02 20:13:22 +02:00
Maksim Eltyshev
a2495b664e chore: Add missing things 2025-06-02 19:44:08 +02:00
Jason Jack
e0374f30db
build: Add tmpfs to compose file (#1165) 2025-06-02 19:39:10 +02:00
Maksim Eltyshev
665f9998dc fix: Prevent auto-updating translation files when key is missing 2025-06-01 21:44:14 +02:00
Maksim Eltyshev
fcf1fc4319 ref: Little refactoring 2025-06-01 20:53:35 +02:00
Maksim Eltyshev
4e05b88ecf chore: Add links to GitHub
Closes #1161
2025-05-31 00:10:00 +02:00
Roman Zavarnitsyn
c0b0436851
feat: Add ability to mention users in comments (#1162) 2025-05-30 22:01:29 +02:00
Maksim Eltyshev
eb2a3a2875 ref: Fix locale position 2025-05-30 13:44:58 +02:00
Niccolò Pedrini
74274e511f
fix: Update Italian translation (#1163) 2025-05-30 13:42:57 +02:00
Maksim Eltyshev
40e107d6d4 chore: Update version 2025-05-27 23:37:47 +02:00
Maksim Eltyshev
39df82c1ce license: Provide additional details 2025-05-27 23:35:00 +02:00
Roman Zavarnitsyn
42817c5199
feat: Add CSV attachment viewer (#1154) 2025-05-27 14:19:44 +02:00
Piotr Patalong
20b5351dfe
fix: Update Polish translation (#1150) 2025-05-26 15:02:51 +02:00
Maksim Eltyshev
978b2a7402 fix: Set limit for displaying attachment content
Closes #1141
2025-05-26 14:35:23 +02:00
Roman Zavarnitsyn
a7c1d4ca11
build: Improve dev setup (#1149) 2025-05-26 12:49:52 +02:00
Maksim Eltyshev
71e2c5d8df fix: Apply disabled style to card content instead of wrapper 2025-05-24 13:01:07 +02:00
Hannes
4049b4c396
feat: Display comments total on front of cards (#1146)
Closes #1136
2025-05-23 17:31:28 +02:00
Hannes
f9d3e73651
build: Improve Docker dev setup, add nodemon config (#1145)
* Updated the base image in `Dockerfile.dev` from `node:18-alpine` to `node:lts-alpine` to use more or less the same as for production docker build (where the client is build lts and server is 18... so yeah not really...)
* Removed the `USER node` directive, to prevent permission issues as we hook the whole folder into the container.
* Modified the `docker-compose-dev.yml` file to enhance the database healthcheck. The new healthcheck ensures not only postgres is ready but also the database is ready by running a sample query (`SELECT 1`) and adjusts the interval, retries, and start period for better reliability.
* Added a `nodemon.json` configuration file to watch only relevant files (node_modules would be ignored but not files like the python venv) and avoid rapid successive restarts with the delay.
2025-05-23 16:19:22 +02:00
Maksim Eltyshev
86cfd155f2 feat: Add board activity log 2025-05-22 23:14:46 +02:00
Maksim Eltyshev
777ff467f3 fix: Prevent upgrade script from running other migrations 2025-05-22 16:40:46 +02:00
Maksim Eltyshev
9fd9370120 fix: Remove curly bracket typo from HTML links
Closes #1142
2025-05-22 11:16:08 +02:00
Maksim Eltyshev
2db75ed420 chore: Downgrade and pin i18next version 2025-05-21 13:25:54 +02:00
Maksim Eltyshev
c05dcf45f2 feat: Enhance notifications popup appearance 2025-05-21 13:03:25 +02:00
Maksim Eltyshev
d5fd2fd33b feat: Log actions when task completion status changes 2025-05-20 21:17:51 +02:00
Maksim Eltyshev
5d53f44fe8 fix: Add clearfix and missing id property 2025-05-20 00:06:51 +02:00
Maksim Eltyshev
3b8bec5743 docs: Update docker pulls counter 2025-05-19 18:03:08 +02:00
Maksim Eltyshev
652c5e9475 fix: Use batch inserts to avoid query size limits 2025-05-19 17:27:18 +02:00
Maksim Eltyshev
3aedbff5d0 feat: Log action when user is removed from card 2025-05-17 22:24:37 +02:00
Maksim Eltyshev
eeb52d19bb feat: Enhance card emphasis in colored and closed lists
Closes #428, closes #506
2025-05-17 15:40:43 +02:00
Maksim Eltyshev
f6568ce41b feat: Add notification when user is added to card 2025-05-17 01:50:40 +02:00
Hannes
f43785c3d0
fix: Update German translation (#1134) 2025-05-16 11:18:12 +02:00
iosabi
ac8361aee3
feat: Support for Docker secrets (#1132)
Closes #1001
2025-05-15 19:32:37 +02:00
leroyloren
3bd8eba416
fix: Update Czech translation (#1129) 2025-05-14 22:28:39 +02:00
Maksim Eltyshev
cb32ea6931 chore: Fix sample value for TRUST_PROXY environment variable 2025-05-13 16:29:31 +02:00
cjmaldonado
57450a7651
fix: Increase max length of OIDC code challenge (#1126)
Closes #1113
2025-05-13 10:56:01 +02:00
sivertillia
c0f3dbe401
fix: Update Ukrainian translation (#1125) 2025-05-13 10:51:16 +02:00
Maksim Eltyshev
742aa21b9b docs: Add chat badge to readme 2025-05-12 23:42:24 +02:00
Maksim Eltyshev
f17334b1d7 fix: Prevent core rendering before redirecting to login page
Closes #1122
2025-05-12 14:19:28 +02:00
leroyloren
307f4b1d01
fix: Update Czech translation (#1120) 2025-05-12 11:35:52 +02:00
Maksim Eltyshev
1b583890cb docs: Fix number of notification providers 2025-05-10 23:11:44 +02:00
Maksim Eltyshev
9b32249e9f build: Suppress npm update notifier 2025-05-10 21:55:03 +02:00
Maksim Eltyshev
dd8cd2f4fd chore: Bump vite to version 6 2025-05-10 21:45:31 +02:00
Maksim Eltyshev
fa02d96a2e test: Rename config file to match case 2025-05-10 21:15:19 +02:00
Viktor S.
e03f3d1c94
fix: Update Russian translation (#1115) 2025-05-10 18:57:12 +02:00
Niccolò Pedrini
b8270a0532
fix: Update Italian translation (#1114) 2025-05-10 17:46:56 +02:00
Maksim Eltyshev
aca5bfbbe0 docs: Add demo notice 2025-05-10 03:30:51 +02:00
403 changed files with 18176 additions and 3476 deletions

View file

@ -15,7 +15,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Update npm - name: Update npm

View file

@ -24,7 +24,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Set up PostgreSQL - name: Set up PostgreSQL

View file

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

View file

@ -1,4 +1,4 @@
FROM node:18-alpine AS server-dependencies FROM node:22-alpine AS server-dependencies
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add build-base python3 --no-cache && apk add build-base python3 --no-cache
@ -10,7 +10,7 @@ COPY server/package.json server/package-lock.json server/requirements.txt ./
RUN npm install npm --global \ RUN npm install npm --global \
&& npm install --omit=dev && npm install --omit=dev
FROM node:lts AS client FROM node:22 AS client
WORKDIR /app WORKDIR /app
@ -21,7 +21,7 @@ RUN npm install npm --global \
RUN DISABLE_ESLINT_PLUGIN=true npm run build RUN DISABLE_ESLINT_PLUGIN=true npm run build
FROM node:18-alpine FROM node:22-alpine
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add bash python3 --no-cache \ && apk add bash python3 --no-cache \
@ -34,7 +34,8 @@ COPY --chown=node:node server .
RUN python3 -m venv .venv \ RUN python3 -m venv .venv \
&& .venv/bin/pip3 install -r requirements.txt --no-cache-dir \ && .venv/bin/pip3 install -r requirements.txt --no-cache-dir \
&& mv .env.sample .env && mv .env.sample .env \
&& npm config set update-notifier false
COPY --from=server-dependencies --chown=node:node /app/node_modules node_modules COPY --from=server-dependencies --chown=node:node /app/node_modules node_modules

View file

@ -1,8 +1,7 @@
FROM node:18-alpine FROM node:22-alpine
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add bash build-base python3 xdg-utils --no-cache \ && apk add bash build-base python3 xdg-utils --no-cache \
&& npm install npm --global && npm install npm --global
USER node
WORKDIR /app WORKDIR /app

View file

@ -1,15 +1,17 @@
**PLANKA Community License** **PLANKA Community License**
Version 1.0 - Last updated: May 2, 2025 Version 1.1 - Last updated: May 20, 2025
Related files in English: Related files in English:
- PLANKA Community License EN.md (this file) - PLANKA Community License EN.md (this file)
- [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md) - [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md) - [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Related files in German: Related files in German:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) - [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- [PLANKA Enterprise License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) - [PLANKA Commercial License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md) - [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
--- ---
@ -19,18 +21,18 @@ Related files in German:
Files accessible to and marked for community use are licensed as follows: Files accessible to and marked for community use are licensed as follows:
- Content of branches other than the main branch (usually "master" or "main") is not licensed. - Content of branches other than the main branch (usually "master" or "main") is not licensed.
- Source code files or other files that contain ".pe." (for "PLANKA Enterprise") in their file names or folder names or are otherwise marked as "PLANKA Enterprise" in their file headers or folders are NOT licensed under the "Fair Use License". These files are "PLANKA Enterprise" files and are licensed under the "PLANKA Enterprise License". - Source code files or other files that contain ".pe." (for "PLANKA Pro/Enterprise") in their file names or folder names or are otherwise marked as "PLANKA Pro/Enterprise" in their file headers or folders are NOT licensed under the "Fair Use License". These files are "PLANKA Pro/Enterprise" files and are licensed under the "PLANKA Pro/Enterprise License".
- To use any "PLANKA Enterprise" files or sources, you must own a valid "PLANKA Enterprise License". You can read more about our commercial license in the [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md). - To use any "PLANKA Pro/Enterprise" files or sources, you must own a valid "PLANKA Pro/Enterprise License". You can read more about our commercial license in the [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md).
- All third-party components incorporated into our software are licensed under the original license provided by the owner of the applicable component. - All third-party components incorporated into our software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above-mentioned files or restrictions is available under the "Fair Use License" as defined below. - Content outside of the above-mentioned files or restrictions is available under the "Fair Use License" as defined below.
## Fair Use License ## Fair Use License
Version 1.0 Version 1.1
### Acceptance ### Acceptance
By using the software, you agree to all of the terms and conditions below. By using the software, you agree to all of the terms and conditions above and below.
### Copyright License ### Copyright License
@ -40,9 +42,19 @@ The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensa
"You may use the PLANKA name or logo only to describe that your service incorporates the software. Any other trademark use (e.g., in product names, domains, or marketing material) requires our prior written consent." "You may use the PLANKA name or logo only to describe that your service incorporates the software. Any other trademark use (e.g., in product names, domains, or marketing material) requires our prior written consent."
### Permitted Use
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.
### 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.
For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license.
### Limitations ### Limitations
You may use or modify the software (a) for personal, hobby, or educational purposes or (b) inside your own legal entity (including wholly owned subsidiaries) for workflows that are not offered as a paid product or as a cross-company service to a third party. Cross-company services or use between your legal entity and other unrelated third-party legal entities are explicitly prohibited. 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. 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.
### Patents ### Patents

View file

@ -0,0 +1,35 @@
**PLANKA Commercial License**
Version 1.1 - Zuletzt aktualisiert: 20. Mai 2025
Zugehörige Dateien in Englisch:
- [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md)
- [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Zugehörige Dateien in Deutsch:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- PLANKA Commercial License DE.md (diese Datei)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
---
# PLANKA Pro/Enterprise-Lizenz
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. 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
Für alle Komponenten von Drittanbietern, die in unsere Software integriert sind, werden diese Komponenten unter der ursprünglichen Lizenz lizenziert, die vom Eigentümer der jeweiligen Komponente bereitgestellt wird.
## PLANKA Pro/Enterprise Repositories
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
UNSERE SOFTWARE WIRD "WIE SIE IST" BEREITGESTELLT, OHNE JEGLICHE GARANTIE, AUSDRÜCKLICH ODER IMPLIZIERT, EINSCHLIEßLICH, ABER NICHT BESCHRÄNKT AUF DIE GARANTIEN DER MARKTGÄNGIGKEIT, EIGNUNG FÜR EINEN BESTIMMTEN ZWECK UND NICHTVERLETZUNG VON RECHTEN DRITTER. IN KEINEM FALL HAFTEN DIE AUTOREN ODER URHEBERRECHTSINHABER FÜR ANSPRÜCHE, SCHÄDEN ODER ANDERE HAFTUNG, OB AUS VERTRAG, UNERLAUBTER HANDLUNG ODER ANDERWEITIG, DIE SICH AUS, AUS ODER IN VERBINDUNG MIT DER SOFTWARE ODER DER NUTZUNG ODER ANDEREN GESCHÄFTEN MIT DER SOFTWARE ERGEBEN.

View file

@ -0,0 +1,35 @@
**PLANKA Commercial License**
Version 1.1 - Last updated: May 20, 2025
Related files in English:
- [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md)
- PLANKA Commercial License EN.md (this file)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Related files in German:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- [PLANKA Commercial License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
---
# PLANKA Pro/Enterprise License
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. 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
For all third-party components incorporated into our Software, those components are licensed under the original license provided by the owner of the applicable component.
## PLANKA Pro/Enterprise Repositories
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
OUR SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,36 +1,38 @@
**PLANKA Community License** **PLANKA Community License**
Version 1.0 - Zuletzt aktualisiert: 2. Mai 2025 Version 1.1 - Zuletzt aktualisiert: 20. Mai 2025
Zugehörige Dateien in Englisch: Zugehörige Dateien in Englisch:
- [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) - [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md)
- [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md) - [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md) - [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Zugehörige Dateien in Deutsch: Zugehörige Dateien in Deutsch:
- PLANKA Community License DE.md (diese Datei) - PLANKA Community License DE.md (diese Datei)
- [PLANKA Enterprise License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) - [PLANKA Commercial License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md) - [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
--- ---
# PLANKA-Gemeinnutzungslizenz # PLANKA Community-Lizenz
Für die Gemeinschaft zugängliche und gekennzeichnete Dateien sind wie folgt lizenziert: Für die Gemeinschaft zugängliche und gekennzeichnete Dateien sind wie folgt lizenziert:
- Inhalte von Sourcecode-Branches außer dem Hauptbranch (üblicherweise "master" oder "main") sind nicht zur Gemeinnutzung bestimmt und lizenziert. - Inhalte von Sourcecode-Branches außer dem Hauptbranch (üblicherweise "master" oder "main") sind nicht lizenziert.
- Quellcode-Dateien oder andere Dateien, die ".pe." (für "PLANKA Enterprise") in ihren Datei- oder Ordnernamen enthalten oder anderweitig durch "PLANKA Enterprise" in ihren Dateiköpfen oder Ordnern als Unternehmenslizenz gekennzeichnet sind, sind NICHT unter der "Fair Use Lizenz" lizenziert. Diese Dateien sind "PLANKA Enterprise"-Dateien und sind unter der "PLANKA-Unternehmenslizenz" lizenziert. - Quellcode-Dateien oder andere Dateien, die ".pe." (für "PLANKA Pro/Enterprise") in ihren Datei- oder Ordnernamen enthalten oder anderweitig durch "PLANKA Pro/Enterprise" in ihren Dateiköpfen oder Ordnern gekennzeichnet sind, sind NICHT unter der "Fair Use Lizenz" lizenziert. Diese Dateien sind "PLANKA Pro/Enterprise"-Dateien und sind unter der "PLANKA Pro/Enterprise-Lizenz" lizenziert.
- Um "PLANKA Enterprise"-Dateien oder Quellen zu nutzen, müssen Sie eine gültige "PLANKA-Unternehmenslizenz" besitzen. Sie können mehr über unsere kommerzielle Lizenz in der [PLANKA Enterprise License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) lesen. - Um "PLANKA Pro/Enterprise"-Dateien oder Quellen zu nutzen, müssen Sie eine gültige "PLANKA Pro/Enterprise-Lizenz" besitzen. Sie können mehr über unsere kommerzielle Lizenz in der [PLANKA Commercial License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md) lesen.
- Alle Komponenten von Drittanbietern, die in unsere Software integriert sind, sind unter der ursprünglichen Lizenz lizenziert, die vom Eigentümer der jeweiligen Komponente bereitgestellt wird. - Alle Komponenten von Drittanbietern, die in unsere Software integriert sind, sind unter der ursprünglichen Lizenz lizenziert, die vom Eigentümer der jeweiligen Komponente bereitgestellt wird.
- Inhalte außerhalb der oben genannten Dateien oder Einschränkungen sind unter der "Fair Use Lizenz" verfügbar, wie unten definiert. - Inhalte außerhalb der oben genannten Dateien oder Einschränkungen sind unter der "Fair Use Lizenz" verfügbar, wie unten definiert.
## Fair Use Lizenz ## Fair Use Lizenz
Version 1.0 Version 1.1
### Annahme ### Annahme
Durch die Nutzung der Software stimmen Sie allen unten aufgeführten Bedingungen zu. Durch die Nutzung der Software stimmen Sie allen oben und unten aufgeführten Bedingungen zu.
### Urheberrechtslizenz ### Urheberrechtslizenz
@ -40,9 +42,19 @@ Der Lizenzgeber gewährt Ihnen eine nicht-exklusive, gebührenfreie, weltweite,
"Sie dürfen den Namen oder das Logo von PLANKA nur verwenden, um zu beschreiben, dass Ihr Dienst die Software enthält. Jede andere Markennutzung (z.B. in Produktnamen, Domains oder Marketingmaterial) bedarf unserer vorherigen schriftlichen Zustimmung." "Sie dürfen den Namen oder das Logo von PLANKA nur verwenden, um zu beschreiben, dass Ihr Dienst die Software enthält. Jede andere Markennutzung (z.B. in Produktnamen, Domains oder Marketingmaterial) bedarf unserer vorherigen schriftlichen Zustimmung."
### Erlaubte Nutzung
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.
### 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.
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.
### Einschränkungen ### Einschränkungen
Sie dürfen die Software nutzen oder modifizieren (a) für persönliche, Hobby- oder Bildungszwecke oder (b) innerhalb Ihrer eigenen juristischen Person (einschließlich hundertprozentiger Tochtergesellschaften) für Arbeitsabläufe, die nicht als kostenpflichtiges Produkt oder als unternehmensübergreifender Dienst an Dritte angeboten werden. Unternehmensübergreifende Dienste oder Nutzung zwischen deren juristischen Personen und anderen nicht verbundenen dritten juristischen Personen sind ausdrücklich untersagt. 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. 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.
### Patente ### Patente

View file

@ -1,15 +1,17 @@
**PLANKA Community License** **PLANKA Community License**
Version 1.0 - Last updated: May 2, 2025 Version 1.1 - Last updated: May 20, 2025
Related files in English: Related files in English:
- PLANKA Community License EN.md (this file) - PLANKA Community License EN.md (this file)
- [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md) - [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md) - [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Related files in German: Related files in German:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) - [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- [PLANKA Enterprise License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) - [PLANKA Commercial License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md) - [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
--- ---
@ -19,18 +21,18 @@ Related files in German:
Files accessible to and marked for community use are licensed as follows: Files accessible to and marked for community use are licensed as follows:
- Content of branches other than the main branch (usually "master" or "main") is not licensed. - Content of branches other than the main branch (usually "master" or "main") is not licensed.
- Source code files or other files that contain ".pe." (for "PLANKA Enterprise") in their file names or folder names or are otherwise marked as "PLANKA Enterprise" in their file headers or folders are NOT licensed under the "Fair Use License". These files are "PLANKA Enterprise" files and are licensed under the "PLANKA Enterprise License". - Source code files or other files that contain ".pe." (for "PLANKA Pro/Enterprise") in their file names or folder names or are otherwise marked as "PLANKA Pro/Enterprise" in their file headers or folders are NOT licensed under the "Fair Use License". These files are "PLANKA Pro/Enterprise" files and are licensed under the "PLANKA Pro/Enterprise License".
- To use any "PLANKA Enterprise" files or sources, you must own a valid "PLANKA Enterprise License". You can read more about our commercial license in the [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md). - To use any "PLANKA Pro/Enterprise" files or sources, you must own a valid "PLANKA Pro/Enterprise License". You can read more about our commercial license in the [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md).
- All third-party components incorporated into our software are licensed under the original license provided by the owner of the applicable component. - All third-party components incorporated into our software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above-mentioned files or restrictions is available under the "Fair Use License" as defined below. - Content outside of the above-mentioned files or restrictions is available under the "Fair Use License" as defined below.
## Fair Use License ## Fair Use License
Version 1.0 Version 1.1
### Acceptance ### Acceptance
By using the software, you agree to all of the terms and conditions below. By using the software, you agree to all of the terms and conditions above and below.
### Copyright License ### Copyright License
@ -40,9 +42,19 @@ The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensa
"You may use the PLANKA name or logo only to describe that your service incorporates the software. Any other trademark use (e.g., in product names, domains, or marketing material) requires our prior written consent." "You may use the PLANKA name or logo only to describe that your service incorporates the software. Any other trademark use (e.g., in product names, domains, or marketing material) requires our prior written consent."
### Permitted Use
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.
### 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.
For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license.
### Limitations ### Limitations
You may use or modify the software (a) for personal, hobby, or educational purposes or (b) inside your own legal entity (including wholly owned subsidiaries) for workflows that are not offered as a paid product or as a cross-company service to a third party. Cross-company services or use between your legal entity and other unrelated third-party legal entities are explicitly prohibited. 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. 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.
### Patents ### Patents

View file

@ -1,33 +0,0 @@
**PLANKA Enterprise License**
Version 1.0 - Zuletzt aktualisiert: 2. Mai 2025
Zugehörige Dateien in Englisch:
- [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md)
- [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Zugehörige Dateien in Deutsch:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- PLANKA Enterprise License DE.md (diese Datei)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
---
# PLANKA-Unternehmenslizenz
Copyright (c) 2025 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 Enterprise License", also eine "PLANKA-Unternehmenslizenz" 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-Unternehmenslizenz" 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
Für alle Komponenten von Drittanbietern, die in unsere Software integriert sind, werden diese Komponenten unter der ursprünglichen Lizenz lizenziert, die vom Eigentümer der jeweiligen Komponente bereitgestellt wird.
## PLANKA Enterprise Repositories
Nach dem Kauf unserer "PLANKA-Unternehmenslizenz" erhalten Sie Zugang zu unseren "PLANKA 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 Enterprise"-Repositories ohne vorherige Genehmigung der PLANKA Software GmbH an Personen weitergeben, die nicht zugangsberechtigt sind-.
## Eingeschränkte Garantie
UNSERE SOFTWARE WIRD "WIE SIE IST" BEREITGESTELLT, OHNE JEGLICHE GARANTIE, AUSDRÜCKLICH ODER IMPLIZIERT, EINSCHLIEßLICH, ABER NICHT BESCHRÄNKT AUF DIE GARANTIEN DER MARKTGÄNGIGKEIT, EIGNUNG FÜR EINEN BESTIMMTEN ZWECK UND NICHTVERLETZUNG VON RECHTEN DRITTER. IN KEINEM FALL HAFTEN DIE AUTOREN ODER URHEBERRECHTSINHABER FÜR ANSPRÜCHE, SCHÄDEN ODER ANDERE HAFTUNG, OB AUS VERTRAG, UNERLAUBTER HANDLUNG ODER ANDERWEITIG, DIE SICH AUS, AUS ODER IN VERBINDUNG MIT DER SOFTWARE ODER DER NUTZUNG ODER ANDEREN GESCHÄFTEN MIT DER SOFTWARE ERGEBEN.

View file

@ -1,33 +0,0 @@
**PLANKA Enterprise License**
Version 1.0 - Last updated: May 2, 2025
Related files in English:
- [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md)
- PLANKA Enterprise License EN.md (this file)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Related files in German:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- [PLANKA Enterprise License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
---
# PLANKA Enterprise License
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 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 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
For all third-party components incorporated into our Software, those components are licensed under the original license provided by the owner of the applicable component.
## PLANKA Enterprise Repositories
After purchasing our "PLANKA Enterprise License", you get access to our "PLANKA 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 Enterprise" repositories to anyone not eligible for access without prior permission from PLANKA Software GmbH.
## Limited Warranty
OUR SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,64 +1,66 @@
**PLANKA License Guide** **PLANKA License Guide**
Version 1.0 - Zuletzt aktualisiert: 2. Mai 2025 Version 1.1 - Zuletzt aktualisiert: 20. Mai 2025
Zugehörige Dateien in Englisch: Zugehörige Dateien in Englisch:
- [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) - [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md)
- [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md) - [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md)
- [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md) - [PLANKA License Guide EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20EN.md)
Zugehörige Dateien in Deutsch: Zugehörige Dateien in Deutsch:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) - [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- [PLANKA Enterprise License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) - [PLANKA Commercial License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md)
- PLANKA License Guide DE.md (diese Datei) - PLANKA License Guide DE.md (diese Datei)
--- ---
## PLANKAs "Fair Use Lizenz" und "PLANLA-Unternehmenslizenz" (Enterprise License) ## PLANKAs "Fair Use Lizenz" und die "PLANKA Pro/Enterprise-Lizenz"
Unsere [Fair Use Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) und unsere [PLANKA-Unternehmenslizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) basieren auf dem [fair-code](http://faircode.io)-Modell. Unsere [Fair Use Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) und unsere [PLANKA Pro/Enterprise-Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md) basieren auf dem [fair-code](http://faircode.io)-Modell.
#### Proprietäre Lizenzen für Unternehmen und die "PLANKA-Bildungslizenz" (Educational License) ### Spezielle Lizenzierung für Bildungseinrichtungen und gemeinnützige Organisationen
Wir bieten unseren Unternehmenskunden nach Absprache auch proprietäre Lizenzen sowie die "PLANKA-Bildungslizenz" für Schulen und Universitäten an. Bitte kontaktieren Sie uns für detaillierte Informationen unter [license@planka.group](mailto:license@planka.group). Bildungseinrichtungen und gemeinnützige Organisationen, die kommerzielle Funktionen oder eine Nutzung über unsere "Fair Use Lizenz" hinaus benötigen, sind eingeladen, uns für maßgeschneiderte Lizenzlösungen und Bildungspreise zu kontaktieren. Wir freuen uns darauf, Ihrer Organisation dabei zu helfen, ihre Mission zu erfüllen!
# Lizenz-FAQs # Lizenz-FAQs
### Unter welcher Lizenz wird PLANKA angeboten? ### Unter welcher Lizenz wird PLANKA angeboten?
PLANKA verwendet die [Fair Use Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) und die [PLANKA-Unternehmenslizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md). Diese Lizenzen basieren auf dem [fair-code](http://faircode.io)-Modell. PLANKA verwendet die [Fair Use Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) und die [PLANKA Pro/Enterprise-Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md). Diese Lizenzen basieren auf dem [fair-code](http://faircode.io)-Modell.
### Welcher Quellcode ist durch PLANKAs "Fair Use Lizenz" abgedeckt? ### Welcher Quellcode ist durch PLANKAs "Fair Use Lizenz" abgedeckt?
Die [Fair Use Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) gilt für unseren Quellcode, der in unserem [GitHub-Hauptrepository](https://github.com/plankanban/planka) gehostet wird, mit folgenden Ausnahmen: Die [Fair Use Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) gilt für unseren Quellcode, der in unserem [GitHub-Hauptrepository](https://github.com/plankanban/planka) gehostet wird, mit folgenden Ausnahmen:
* Inhalte von Branches außer dem Hauptbranch (üblicherweise "master" oder "main"). - Inhalte von Branches außer dem Hauptbranch (üblicherweise "master" oder "main").
* Quellcode-Dateien oder andere Dateien, die ".pe." (für "PLANKA Enterprise") in ihren Datei- oder Ordnernamen enthalten. - Quellcode-Dateien oder andere Dateien, die ".pe." (für "PLANKA Pro/Enterprise") in ihren Datei- oder Ordnernamen enthalten.
* Quellcode-Dateien, die in den Dateien oder Ordnern als "PLANKA Enterprise" gekennzeichnet sind. - Quellcode-Dateien, die in den Dateien oder Ordnern als "PLANKA Pro/Enterprise" gekennzeichnet sind.
* Quellcode in Ordnern, die separate Lizenzdateien enthalten, die sie eindeutig als "PLANKA Enterprise" kennzeichnen. - Quellcode in Ordnern, die separate Lizenzdateien enthalten, die sie eindeutig als "PLANKA Pro/Enterprise" kennzeichnen.
Diese Ausnahmen sind unter der [PLANKA-Unternehmenslizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) lizenziert. Diese Ausnahmen sind unter der [PLANKA Pro/Enterprise-Lizenz](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md) lizenziert.
### Was genau ist die "Fair Use Lizenz"? ### Was genau ist die "Fair Use Lizenz"?
Die "Fair Use Lizenz" fällt in die Kategorie der sogenannten [fair-code](http://faircode.io)-Lizenzen. PLANKAs Lizenz basiert auf und erweitert die "Sustainable Use License", die von [n8n](https://n8n.io) eingeführt wurde und für deren Beratung wir sehr dankbar sind. Mit ähnlichen Zielen vor Augen haben wir beschlossen, deren Beispiel zu folgen und dieses Lizenz-Modell für unsere eigenen Bedürfnisse anzupassen. [Weiter unten](#warum-haben-sie-diese-lizenz-gewählt) können Sie nachlesen, warum wir diese Lizenzart gewählt haben. Die "Fair Use Lizenz" fällt in die Kategorie der sogenannten [fair-code](http://faircode.io)-Lizenzen. PLANKAs Lizenz basiert auf und erweitert die "Sustainable Use License", die von [n8n](https://n8n.io) eingeführt wurde und für deren Beratung wir sehr dankbar sind. Mit ähnlichen Zielen vor Augen haben wir beschlossen, deren Beispiel zu folgen und dieses Lizenz-Modell für unsere eigenen Bedürfnisse anzupassen. [Weiter unten](#warum-haben-sie-diese-lizenz-gewählt) können Sie nachlesen, warum wir diese Lizenzart gewählt haben.
Die Lizenz gewährt unseren Benutzern das kostenlose Recht, die Community Version der Software zu nutzen, zu modifizieren und abgeleitete Werke zu erstellen und weiterzugeben, mit drei Einschränkungen: Die Lizenz gewährt Ihnen das kostenlose Recht, die Software zu nutzen, zu modifizieren, abgeleitete Werke zu erstellen und weiterzugeben unter folgenden Bedingungen:
- Sie dürfen die Software nutzen oder modifizieren (a) für persönliche, Freizeit- oder Bildungszwecke oder (b) innerhalb Ihres eigenen Unternehmens bzw. Ihrer juristischen Person (einschließlich hundertprozentiger Tochtergesellschaften) für Arbeitsabläufe, die nicht als kostenpflichtiges Produkt oder als unternehmensübergreifender Dienst für Dritte angeboten werden. - 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.
- Sie dürfen keine Lizenz-, Urheber- oder sonstigen Hinweise des Lizenzgebers in der Software ändern, entfernen oder verschleiern. Jede Verwendung des Namens, bzw. von Marken des Lizenzgebers unterliegen dem geltenden Recht. - 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.
- "Sie dürfen den Namen oder das Logo von PLANKA verwenden, um sachlich zu beschreiben, dass Ihr Dienst die PLANKA-Software enthält oder auf andere Weise nutzt. Jede andere Verwendung der Marke/des Namens (z.B. in Produktnamen oder -marken, Domainnamen oder Marketingmaterial) erfordert unsere vorherige schriftliche Zustimmung." - 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.
### Was ist im Rahmen der Lizenz im Kontext von PLANKAs Produkten erlaubt und was NICHT? ### Was ist im Rahmen der Lizenz im Kontext von PLANKAs Produkten erlaubt und was NICHT?
Unsere Lizenz beschränkt die Nutzung auf "interne Geschäftszwecke". In der Praxis bedeutet dies, dass jegliche Nutzung erlaubt ist, es sei denn, Sie verkaufen ein Produkt, einen Dienst oder ein Modul, bei dem der Wert vollständig oder im Wesentlichen von der PLANKA-Funktionalität abgeleitet wird. Unsere Lizenz erlaubt die Nutzung für "interne Geschäftszwecke" sowie für persönliche, Hobby-, Bildungs- und begrenzte private Hosting-Szenarien. Sie verbietet jedoch die Nutzung von PLANKA für jede Form des kommerziellen Gewinns, wie z.B. den Verkauf eines Produkts oder einer Dienstleistung, bei dem der Wert in irgendeiner Form von PLANKAs Funktionalität abgeleitet wird, den Betrieb als kostenpflichtiger gehosteter Dienst, die Monetarisierung von Zugang oder Benutzerdaten oder die Erzielung anderer indirekter kommerzieller Vorteile.
###### Hier sind einige Beispiele, die nicht erlaubt wären: ##### Hier sind einige Beispiele, die nicht erlaubt wären:
- PLANKA unter einem White-Label anzubieten und es Ihren Kunden oder Partnern gegen Geld anzubieten. - PLANKA unter einem White-Label anzubieten und es Ihren Kunden oder Partnern gegen Geld anzubieten.
@ -66,25 +68,25 @@ Unsere Lizenz beschränkt die Nutzung auf "interne Geschäftszwecke". In der Pra
- PLANKAs API für Dienste zu nutzen, für die Geld verlangt wird. - PLANKAs API für Dienste zu nutzen, für die Geld verlangt wird.
- Die Verwendung von PLANKA, um Ihre eigene Organisation mit anderen Organisationen oder Personen außerhalb Ihrer eigenen Organisation zu verbinden. - Die Verwendung von PLANKA, um Ihre eigene juristische Person mit juristischen Personen oder Personen außerhalb Ihrer Organisation im Kontext einer kommerziellen Beziehung zu verbinden.
- PLANKA zur Durchführung oder Unterstützung illegaler oder rechtswidriger Aktivitäten zu verwenden. - PLANKA zur Durchführung oder Unterstützung illegaler oder rechtswidriger Aktivitäten zu verwenden.
###### Die folgenden Beispiele sind unter unserer Lizenz erlaubt: ##### Die folgenden Beispiele sind unter unserer Lizenz erlaubt:
- Verwendung von PLANKA zur Kontrolle Ihrer internen Prozesse und Verwaltung Ihrer internen Projekte. - Verwendung von PLANKA zur Kontrolle Ihrer internen Prozesse und Verwaltung Ihrer internen Projekte.
- Integration von PLANKA in andere intern genutzte Produkte, um deren Fähigkeiten zu erweitern. - Integration von PLANKA in andere intern genutzte Produkte, um deren Fähigkeiten zu erweitern.
- Bereitstellung von Beratungs- oder Bildungsdienstleistungen im Zusammenhang mit PLANKA, zum Beispiel zum Aufbau oder zur Integration von Arbeitsabläufen für oder in Verbindung mit PLANKA oder zur Entwicklung benutzerdefinierter Module zur Erweiterung seiner Funktionalitäten. - Bereitstellung von Beratungs- oder öffentlichen Bildungsdienstleistungen im Zusammenhang mit PLANKA, zum Beispiel zum Aufbau oder zur Integration von Arbeitsabläufen für oder in Verbindung mit PLANKA oder zur Entwicklung benutzerdefinierter Module zur Erweiterung seiner Funktionalitäten.
- Unterstützung von PLANKA, zum Beispiel durch Einrichtung oder Wartung auf einem internen Firmenserver. - Unterstützung von PLANKA, zum Beispiel durch Einrichtung oder Wartung auf einem internen Firmenserver.
### Ist es erlaubt, PLANKA als Backend-Integration zu nutzen? ### Ist es erlaubt, PLANKA als Backend-Integration zu nutzen?
Wenn Sie PLANKA und sein Backend sowie zugehörige Dienste außerhalb der Grenzen interner Betriebsabläufe und Integrationen innerhalb Ihrer eigenen Organisation nutzen, müssen Sie eine "PLANKA-Unternehmenslizenz" erwerben. Dies gilt auch, wenn Sie PLANKA als Kern-Backend-Infrastruktur für Drittanbieteranwendungen, -dienste oder ein System nutzen möchten, das an externe Parteien verteilt wird oder von diesen genutzt wird. Wenn Sie PLANKA und seine Backend-Dienste über interne Betriebsabläufe innerhalb Ihrer eigenen Organisation hinaus nutzen, indem Sie Dritten Zugang für kommerziellen Gewinn ermöglichen, wie z.B. das Teilen von Konten mit Kunden oder den Verkauf eines Produkts oder einer Dienstleistung, bei dem der Wert in irgendeiner Form von PLANKAs Funktionalität abgeleitet wird, den Betrieb als kostenpflichtiger gehosteter Dienst, die Monetarisierung von Zugang oder Benutzerdaten oder die Erzielung anderer indirekter kommerzieller Vorteile, sind Sie verpflichtet, eine "PLANKA Pro/Enterprise-Lizenz" zu erwerben.
Jede Vereinbarung, welche die Unterlizenzierung, Umlabelung oder anderweitige Bereitstellung von PLANKA für externe Parteien oder die Integration von PLANKA in ein anderes Produkt beinhaltet, um als primärer Dienst für dieses Produkt zu fungieren, erfordert ebenfalls eine gültige "PLANKA-Unternehmenslizenz" oder proprietäre Lizenz, die für unsere Unternehmenskunden ebenfalls verfügbar ist. Dies umfasst Szenarien, in denen PLANKA als Kerninfrastruktur für Drittanbieterlösungen dient, von Benutzern außerhalb Ihrer juristischen Person als Teil eines kostenpflichtigen oder kommerziellen Angebots genutzt wird oder bei denen Sie PLANKA unterlizenzieren, neu verpacken oder anderweitig externen Parteien zur Verfügung stellen. Jede Vereinbarung, die die Integration von PLANKA in ein anderes Produkt beinhaltet, um als primärer Betriebsmotor für dieses Produkt zu dienen, erfordert ebenfalls eine gültige "PLANKA Pro/Enterprise-Lizenz" oder proprietäre Lizenz, die für unsere Unternehmenskunden verfügbar ist.
### Können Sie mir einige schnelle Beispiele geben, um kostenlose Nutzung vs. Unternehmensnutzung zu verdeutlichen? ### Können Sie mir einige schnelle Beispiele geben, um kostenlose Nutzung vs. Unternehmensnutzung zu verdeutlichen?
@ -108,39 +110,67 @@ Eine Schule oder gemeinnützige Organisation bündelt PLANKA in einem kostenlose
**ERLAUBT** unter der "Fair Use Lizenz". Da PLANKA kostenlos und ohne kommerzielle Einnahmen im Sinn weitergegeben wird, sind Sie mehr als willkommen, gemeinnützigen Einrichtungen und Schulen einen besseren Zugang zu PLANKA zu ermöglichen. **ERLAUBT** unter der "Fair Use Lizenz". Da PLANKA kostenlos und ohne kommerzielle Einnahmen im Sinn weitergegeben wird, sind Sie mehr als willkommen, gemeinnützigen Einrichtungen und Schulen einen besseren Zugang zu PLANKA zu ermöglichen.
**JEDOCH** wenn Sie auch planen, Schülern und Studenten Zugang zu einer zentralen bzw. schulinternen PLANKA-Instanz zu gewähren, würde dies eine "PLANKA-Bildungslizenz" erfordern, die wir sehr gerne auf Anfrage anbieten. **JEDOCH** wenn Sie auch planen, Schülern und Studenten Zugang zu einer ansonsten schulinternen PLANKA-Instanz zu gewähren, würde dies unsere "PLANKA-Bildungslizenz" erfordern, die wir sehr gerne auf Anfrage anbieten.
##### Beispiel 4: PLANKA-Logins für Kunden und Partner bereitstellen ##### Beispiel 4: PLANKA-Logins für Kunden und Partner bereitstellen
Um ein besseres Projekt-Feedback zu ermöglichen, bieten Sie Ihrem Kunden oder Jemandem aus einem Partnerunternehmen Zugang zu Projektboards innerhalb Ihres Unternehmens an. Sie können nun z.B. Karten kommentieren und auch von PLANKAs Echtzeit-Update-Funktionen profitieren. Um ein besseres Projekt-Feedback zu ermöglichen, bieten Sie Ihrem Kunden und jemandem aus einem Joint Venture, mit denen Sie beide eine kommerzielle Beziehung haben, Zugang zu Projektboards innerhalb Ihres Unternehmens an. Sie können nun z.B. Karten kommentieren und auch von PLANKAs Echtzeit-Update-Funktionen profitieren.
**NICHT ERLAUBT** unter der "Fair Use Lizenz". Das Anbieten von PLANKA als Teil eines kostenpflichtigen Dienstes für Dritte oder die Bereitstellung von PLANKA-Zugang für Drittbenutzer außerhalb Ihrer eigenen Organisation erfordert, dass Sie eine "PLANKA-Unternehmenslizenz" erwerben. **NICHT ERLAUBT** unter der "Fair Use Lizenz". Das Anbieten von PLANKA als Teil eines kostenpflichtigen Dienstes für Dritte oder die Bereitstellung von PLANKA-Zugang für Drittbenutzer außerhalb Ihrer eigenen juristischen Person im Kontext einer kommerziellen Beziehung erfordert, dass Sie eine "PLANKA Pro/Enterprise-Lizenz" registrieren.
##### Beispiel 5: PLANKA als gehostetes Produkt für andere Unternehmen anbieten ##### Beispiel 5: PLANKA als gehostetes Produkt für andere Unternehmen anbieten
Sie möchten Geld damit verdienen, indem Sie PLANKA anderen Unternehmen, Freiberuflern oder anderen Personen zur Verfügung stellen damit diese Zugriff auf bzw. Kontrolle über ein PLANKA-System erhalten. Sie möchten Geld damit verdienen, indem Sie PLANKA Unternehmen, Freiberuflern und anderen Personen zur Verfügung stellen. Auf diese Weise haben sie einfachen Zugang zu Projektmanagement und -kontrolle von überall.
**NICHT ERLAUBT** unter der "Fair Use Lizenz". Der Verkauf PLANKA-basierter Dienste erfordert, dass Sie einer "PLANKA-Wiederverkäuferlizenz" (Hosting License) zustimmen. Wir haben eine spezielle Hosting-Vereinbarung und sehr günstige Hosting Lizenzpreise für diejenigen, die kostenpflichtige PLANKA-Dienste für Kunden anbieten oder einfach als Wiederverkäufer für unsere eigenen "PLANKA Corporate Hosting Services" fungieren möchten. **NICHT ERLAUBT** unter der "Fair Use Lizenz". Der Verkauf PLANKA-basierter Dienste erfordert, dass Sie einer "PLANKA-Wiederverkäuferlizenz" zustimmen. Wir haben eine spezielle Hosting-Vereinbarung für diejenigen, die kostenpflichtige PLANKA-Dienste für Kunden anbieten oder einfach als Wiederverkäufer für unsere eigenen "PLANKA Corporate Hosting Services" fungieren möchten.
--- ---
### Kurzübersicht der "Fair Use Lizenz"
- Persönliche, Bildungs-, Hobby- oder interne Geschäftsnutzung:
Kostenlos nutzbar. Sie können PLANKA für sich selbst, Ihr Team oder Ihre Organisation für persönliche, Bildungs- oder interne operative Zwecke nutzen, modifizieren und hosten.
- Nutzung durch gemeinnützige Organisationen und öffentliche Bildungseinrichtungen:
Kostenlos für akademische oder Forschungszwecke nutzbar, vorausgesetzt die Organisation qualifiziert sich als gemeinnützig unter den geltenden Steuergesetzen. Keine Anmeldung oder spezielle Lizenz erforderlich.
- Bereitstellung von Zugang für Dritte für kommerziellen Gewinn:
Nicht erlaubt. Das Teilen von Konten oder Zugangsdaten mit Dritten für kommerziellen Gewinn ist unter der Lizenz verboten, auch wenn Sie nicht direkt für den Zugang berechnen.
- Hosting von PLANKA als kommerzieller Dienst:
Nicht erlaubt. Sie dürfen PLANKA nicht als gehosteten Dienst anbieten oder in ein kostenpflichtiges Produkt oder eine Dienstleistung integrieren, bei dem der Wert wesentlich von PLANKA stammt. Dies umfasst Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil.
### Was, wenn ich PLANKA für etwas nutzen möchte, das nicht durch die Lizenz erlaubt ist? ### Was, wenn ich PLANKA für etwas nutzen möchte, das nicht durch die Lizenz erlaubt ist?
Sie müssen eine separate kommerzielle Vereinbarung mit uns abschließen. Wir ermutigen Softwareintegratoren und technisches Personal dazu, PLANKA in ihre Produkte zu integrieren oder mit ihnen zu verbinden und unsere umfangreiche API zu nutzen, um Prozesse innerhalb ihres Unternehmens zu steuern - wir bitten sie nur, eine Vereinbarung zu unterzeichnen, die die Nutzungsbedingungen und die von PLANKA für die Nutzung des Produkts erforderlichen Lizenzgebühren festlegt. Durch unsere API kann PLANKA externe Systeme steuern oder auf sie reagieren. Sie können [hier](https://docs.planka.cloud/docs/category/api-reference) mehr darüber erfahren oder uns hierüber kontaktieren. Um die Grenzen der selbst gehosteten Community-Version zu überschreiten, benötigen Sie eine "PLANKA Pro/Enterprise-Lizenz". Sobald Sie sich bei uns registrieren, werden die Beschränkungen der "Fair Use Lizenz" nicht nur rechtlich aufgehoben, sondern Sie erhalten auch Zugang zum vollständigen Enterprise-Funktionsumfang.
Alternativ können Sie diese Grenzen durch ein Abonnement einer gehosteten Version - entweder Community oder Pro/Enterprise - überschreiten. Dies ermöglicht beispielsweise die Bereitstellung von Konten für Ihre Kunden.
Wenn Sie PLANKA als Dienstleistung für andere Organisationen anbieten oder PLANKA in Ihre eigene Software integrieren möchten, müssen Sie eine separate Umsatzvereinbarung mit uns abschließen. Wir ermutigen aktiv Softwareintegratoren und technisches Personal, PLANKA in ihre anderen Produkte zu integrieren und zu verbinden und unsere umfangreiche API zu nutzen, um auf Prozesse innerhalb ihres Unternehmens zu reagieren, sie zu steuern und zu beherrschen; wir bitten sie lediglich, eine Vereinbarung zu unterzeichnen, die die Nutzungsbedingungen und die von PLANKA für die Nutzung des Produkts erforderlichen Lizenzgebühren festlegt. Über PLANKAs API kann es externe Systeme steuern und auf sie reagieren. Sie können [hier](https://docs.planka.cloud/docs/category/api-reference/) mehr darüber erfahren oder uns kontaktieren.
Wenn Sie sich nicht sicher sind, ob der von Ihnen ins Auge gefasste Anwendungsfall einen internen Geschäftszweck darstellt oder nicht, werfen Sie einen Blick auf die [Beispiele oben](#können-sie-mir-einige-schnelle-beispiele-geben-um-kostenlose-nutzung-vs-unternehmensnutzung-zu-verdeutlichen), und falls Sie immer noch unsicher sind, kontaktieren Sie uns bitte unter [license@planka.group](mailto:license@planka.group). Wenn Sie sich nicht sicher sind, ob der von Ihnen ins Auge gefasste Anwendungsfall einen internen Geschäftszweck darstellt oder nicht, werfen Sie einen Blick auf die [Beispiele oben](#können-sie-mir-einige-schnelle-beispiele-geben-um-kostenlose-nutzung-vs-unternehmensnutzung-zu-verdeutlichen), und falls Sie immer noch unsicher sind, kontaktieren Sie uns bitte unter [license@planka.group](mailto:license@planka.group).
### Warum nutzen Sie keine Standard-Open-Source-Lizenz? ### Warum gibt es keine kommerzielle Lizenz für eine selbst-gehostete Community-Version um die "Fair Use License"-Beschränkungen aufzuheben?
Wir verbringen viel Zeit damit, ein einfaches, aber leistungsstarkes Tool zu erstellen, welches die Kontrolle und Beherrschung von Projekten zu einer angenehmen Erfahrung macht. Außerdem wollten wir, dass PLANKA so weit als möglich frei verfügbar ist, während wir gleichzeitig sicherstellen müssen, dass wir ein nachhaltiges und tragfähiges Geschäft aufbauen können. In dem wir unser Produkt kostenlos, und den Quellcode verfügbar und verteilbar machten, helfen wir allen, auf das Produkt zuzugreifen. Indem wir als Unternehmen tätig sind, können wir neue Funktionen entwickeln und veröffentlichen, Fehler beheben und langfristig zuverlässige Software mit wachsendem Umfang bereitstellen. Wir unterstützen die selbst-gehostete Community-Version über unsere GitHub- und Discord-Community-Kanäle, aber wir können nicht effizient direkten Telefon- oder E-Mail-Support für mehrere Versionen bereitstellen, während wir unseren Verpflichtungen gegenüber zahlenden Kunden nachkommen. Deshalb ist direkter Support ausschließlich Pro/Enterprise-Kunden vorbehalten.
Wir bieten jedoch professionelle Installationsdienste und monatliche Serviceverträge für die Einrichtung, Aktualisierung und Überwachung aller Versionen an, um Organisationen dabei zu helfen, das Beste aus ihrer PLANKA-Bereitstellung herauszuholen - bitte kontaktieren Sie uns unter [license@planka.group](mailto:license@planka.group).
### Warum nutzt PLANKA keine Standard-Open-Source-Lizenz?
Wir verbringen viel Zeit damit, ein einfaches, aber leistungsstarkes Tool zu erstellen, welches die Kontrolle und Beherrschung von Projekten zu einer angenehmen Erfahrung macht. Außerdem wollten wir, dass PLANKA so weit als möglich frei verfügbar ist, während wir gleichzeitig sicherstellen müssen, dass wir ein nachhaltiges und tragfähiges Geschäft aufbauen können. Indem wir unser Produkt kostenlos nutzbar, einfach verteilbar und quelloffen machten, helfen wir allen, auf das Produkt zuzugreifen. Indem wir als Unternehmen tätig sind, können wir langfristig neue Funktionen entwickeln und veröffentlichen, Fehler beheben und zuverlässige Software in großem Maßstab bereitstellen.
### Warum haben Sie diese Lizenz gewählt? ### Warum haben Sie diese Lizenz gewählt?
Wir glauben, dass die "Fair Use Lizenz" sowohl für die Gemeinschaft als auch für die Entwickler von Vorteil ist. Entwicklung ist ein kostspieliges Unterfangen, und eine Community-Version kostenlos weiterzugeben, ist ein Risiko, welches viele Unternehmen nicht überleben, ohne ihre Software oder ihr Unternehmen zu veräußern. Daher leben viele Open-Source-Unternehmen von Spenden oder Finanzinvestoren. Anstatt unsere Seele zu verkaufen, verkaufen wir Dienstleistungen und Softwarelizenzen. Auf diese Weise können wir weiter wachsen, programmieren und unsere Community unterstützen. Die kurze Antwort lautet also "Leben und leben lassen" - so denken wir über PLANKA. Wir glauben, dass die "Fair Use Lizenz" sowohl für die Gemeinschaft als auch für die Entwickler von Vorteil ist. Entwicklung ist ein kostspieliges Unterfangen, und eine Community-Version kostenlos weiterzugeben, ist ein Risiko, welches viele Unternehmen nicht überleben, ohne ihre Software oder ihr Unternehmen zu veräußern. Daher leben viele Open-Source-Unternehmen von Spenden oder Finanzinvestoren. Anstatt unsere Seele zu verkaufen, verkaufen wir Dienstleistungen und Softwarelizenzen. Auf diese Weise können wir weiter wachsen, programmieren und unsere Community unterstützen. Die kurze Antwort lautet also "Leben und leben lassen" - so denken wir über PLANKA.
Daher helfen wir dabei, [fair-code](https://faircode.io)-Software zu fördern, mit dem Ziel, sie zu einem bekannten Sammelbegriff zu machen, um Softwaremodelle wie Unseres zu beschreiben. Um jegliche Reibung um unsere proprietäre Lizenz auf ein absolutes Minimum zu beschränken, konzentrieren wir uns auf zwei Dinge: Daher helfen wir dabei, [fair-code](https://faircode.io)-Software zu fördern, mit dem Ziel, sie zu einem bekannten Sammelbegriff zu machen, um Softwaremodelle wie unseres zu beschreiben. Um jegliche Reibung um unsere proprietäre Lizenz auf ein absolutes Minimum zu beschränken, konzentrieren wir uns auf zwei Dinge:
1. Klare Sprache und minimale Länge - die Lizenz ist in klarem, präzisem Englisch bzw Deutsch geschrieben, mit nur den unbedingt notwendigen Klauseln. 1. Klare Sprache und minimale Länge - die Lizenz ist in klarem, präzisem Deutsch (eine englische Version existiert ebenfalls) geschrieben, mit nur den unbedingt notwendigen Klauseln.
2. Förderung von fair-code - wir fördern aktiv das fair-code-Modell, damit die Menschen es als unkomplizierte, nachhaltige Möglichkeit erkennen, Software wie PLANKA zu teilen und zu verbessern. 2. Förderung von fair-code - wir fördern aktiv das fair-code-Modell, damit die Menschen es als unkomplizierte, nachhaltige Möglichkeit erkennen, Software wie PLANKA zu teilen und zu verbessern.
@ -158,7 +188,7 @@ Es ist einfach, mit den Code-Beiträgen zu PLANKA auf [GitHub](https://github.co
### Ist PLANKA Open Source? ### Ist PLANKA Open Source?
PLANKAs Quellcode ist unter unserer "Fair Use Lizenz" frei verfügbar. Während dies nicht mit der strengen Definition der Open Source Initiative übereinstimmt (die keine Nutzungsbeschränkungen erlaubt), bietet PLANKA den meisten Benutzern, einschließlich Unternehmen, dennoch fast die selben Vorteile wie traditionelle Open-Source-Software. PLANKAs Quellcode ist unter der "Fair Use Lizenz" frei verfügbar. Während dies nicht mit der strengen Definition der Open Source Initiative übereinstimmt (die keine Nutzungsbeschränkungen erlaubt), bietet PLANKA den meisten Benutzern, einschließlich Unternehmen, dennoch fast die gleichen Vorteile wie traditionelle Open-Source-Software.
Wir befürworten, was oft als 'fair-code'-Modell bezeichnet wird - unser Code ist quelloffen und folgt einer einfachen "Leben und leben lassen"-Philosophie. Dieser Ansatz ermöglicht es uns, ein nachhaltiges Unternehmen zu führen und gleichzeitig Transparenz und Flexibilität für unsere Community zu bieten. Viele Unternehmen übernehmen diesen ausgewogenen Lizenzierungsansatz, der den Geist der Offenheit bewahrt und gleichzeitig die langfristige Lebensfähigkeit des Projekts sicherstellt. Wir sind stolz darauf, Teil dieser Bewegung zu sein! Wir befürworten, was oft als 'fair-code'-Modell bezeichnet wird - unser Code ist quelloffen und folgt einer einfachen "Leben und leben lassen"-Philosophie. Dieser Ansatz ermöglicht es uns, ein nachhaltiges Unternehmen zu führen und gleichzeitig Transparenz und Flexibilität für unsere Community zu bieten. Viele Unternehmen übernehmen diesen ausgewogenen Lizenzierungsansatz, der den Geist der Offenheit bewahrt und gleichzeitig die langfristige Lebensfähigkeit des Projekts sicherstellt. Wir sind stolz darauf, Teil dieser Bewegung zu sein!
@ -174,8 +204,8 @@ Fair-code ist keine Softwarelizenz. Es beschreibt ein Softwaremodell, bei dem So
- Von ihren Autoren kommerziell eingeschränkt wird. - Von ihren Autoren kommerziell eingeschränkt wird.
Unsere "Fair Use Lizenz" ist eine fair-code-Lizenz. Sie können mehr darüber lesen und andere Beispiele für fair-code-Lizenzen [hier](https://faircode.io) sehen. Um mit uns bzgl. Lizenzfragen in Verbindung zu treten, senden Sie bitte eine E-Mail an [license@planka.group](mailto:license@planka.group). Die "Fair Use Lizenz" ist eine fair-code-Lizenz. Sie können mehr darüber lesen und andere Beispiele für fair-code-Lizenzen [hier](https://faircode.io) sehen. Um mit uns bzgl. Lizenzfragen in Verbindung zu treten, senden Sie bitte eine E-Mail an [license@planka.group](mailto:license@planka.group).
### Kann ich diese Art der Lizenz für mein eigenes Projekt verwenden? ### Kann ich die "Fair Use Lizenz" für mein eigenes Projekt verwenden?
Ja! Wir selbst haben die "Fair Use Lizenz" genutzt, indem wir den Fußstapfen anderer folgten, die auf ihrer Website und in ihrer Lizenz offen andere dazu einladen, dem fair-code-Pfad zu folgen. Und auch wir sind gespannt darauf, mehr Software zu sehen, die unsere "Fair Use Lizenz" verwendet. Ja! Wir selbst haben die "Fair Use Lizenz" genutzt, indem wir den Fußstapfen anderer folgten, die auf ihrer Website und in ihrer Lizenz offen andere dazu einladen, dem fair-code-Pfad zu folgen. Wie sie sind auch wir gespannt darauf, mehr Software zu sehen, die die "Fair Use Lizenz" verwendet.

View file

@ -1,64 +1,66 @@
**PLANKA License Guide** **PLANKA License Guide**
Version 1.0 - Last updated: May 2, 2025 Version 1.1 - Last updated: May 20, 2025
Related files in English: Related files in English:
- [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) - [PLANKA Community License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md)
- [PLANKA Enterprise License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md) - [PLANKA Commercial License EN.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md)
- PLANKA License Guide EN.md (this file) - PLANKA License Guide EN.md (this file)
Related files in German: Related files in German:
- [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md) - [PLANKA Community License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20DE.md)
- [PLANKA Enterprise License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20DE.md) - [PLANKA Commercial License DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20DE.md)
- [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md) - [PLANKA License Guide DE.md](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20License%20Guide%20DE.md)
--- ---
## PLANKA's "Fair Use License" and the "PLANKA Enterprise License" ## PLANKA's "Fair Use License" and the "PLANKA Pro/Enterprise License"
Our [Fair Use License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) and our [PLANKA Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md) are based on the [fair-code](http://faircode.io) model. Our [Fair Use License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) and our [PLANKA Pro/Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md) are based on the [fair-code](http://faircode.io) model.
#### Proprietary licenses for enterprise and the "PLANKA Educational License" ### Special licensing for educational & non-profit organizations
We offer proprietary licenses to our enterprise customers, but also the "PLANKA Educational License" for schools and universities. Please contact us for detailed information at [license@planka.group](mailto:license@planka.group). Educational institutions and non-profit organizations requiring commercial-level features or usage beyond our "Fair Use License" are encouraged to contact us for tailored licensing solutions and educational pricing. We look forward to helping your organization accomplish its mission!
# License FAQs # License FAQs
### What license do you use for PLANKA? ### What license do you use for PLANKA?
PLANKA uses the [Fair Use License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) and the [PLANKA Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md). These licenses are based on the [fair-code](http://faircode.io) model. PLANKA uses the [Fair Use License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) and the [PLANKA Pro/Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md). These licenses are based on the [fair-code](http://faircode.io) model.
### What source code is covered by the PLANKA's "Fair Use License"? ### What source code is covered by the PLANKA's "Fair Use License"?
The [Fair Use License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) applies to our source code hosted in our [main GitHub repository](https://github.com/plankanban/planka) except: The [Fair Use License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Community%20License%20EN.md) applies to our source code hosted in our [main GitHub repository](https://github.com/plankanban/planka) except:
* Content of branches other than the main branch (usually "master" or "main"). - Content of branches other than the main branch (usually "master" or "main").
* Source code files or other files that contain ".pe." (for "PLANKA Enterprise") in their file names or folder names. - Source code files or other files that contain ".pe." (for "PLANKA Pro/Enterprise") in their file names or folder names.
* Source code files that are marked as "PLANKA Enterprise" in their file headers or folders. - Source code files that are marked as "PLANKA Pro/Enterprise" in their file headers or folders.
* Source code in folders that contain separate license files that clearly mark them as "PLANKA Enterprise". - Source code in folders that contain separate license files that clearly mark them as "PLANKA Pro/Enterprise".
These exceptions are licensed under the [PLANKA Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md). These exceptions are licensed under the [PLANKA Pro/Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Commercial%20License%20EN.md).
### What is the "Fair Use License"? ### What is the "Fair Use License"?
The "Fair Use License" falls under the so-called [fair-code](http://faircode.io) licenses category. PLANKA's license is based on and extends the "Sustainable Use License" introduced by [n8n](https://n8n.io) who's advice was greatly appreciated. With similar goals in mind, we decided to follow their lead and adopt their model for our own needs. [Further below](#why-did-you-choose-this-license) you can read why we chose this license. The "Fair Use License" falls under the so-called [fair-code](http://faircode.io) licenses category. PLANKA's license is based on and extends the "Sustainable Use License" introduced by [n8n](https://n8n.io) who's advice was greatly appreciated. With similar goals in mind, we decided to follow their lead and adopt their model for our own needs. [Further below](#why-did-you-choose-this-license) you can read why we chose this license.
The license allows you the free right to use, modify, create derivative works, and redistribute with three limitations: The license allows you the free right to use, modify, create derivative works, and redistribute under the following conditions:
- You may use or modify the software (a) for personal, hobby, or educational purposes, or (b) inside your own legal entity (including wholly owned subsidiaries) for workflows that are not offered as a paid product or as a cross-company service to any third party. - 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.
- You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor's trademarks is subject to applicable law. - 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.
- "You may use the PLANKA name or logo to factually describe that your service incorporates or in other ways uses PLANKA software. Any other trademark use (e.g., in product names or brands, domain names, or marketing material) requires our prior written consent." - 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.
### What is and is NOT allowed under the license in the context of PLANKA's products? ### What is and is NOT allowed under the license in the context of PLANKA's products?
Our license restricts use to "internal business purposes". In practice this means all use is allowed unless you are selling a product, service, or module in which the value derives entirely or substantially from PLANKA functionality. Our license allows use for "internal business purposes", as well as for personal, hobby, educational, and limited private hosting scenarios. However, it prohibits using PLANKA for any form of commercial gain, such as selling a product or service where the value derives in any form from PLANKA's functionality, operating it as a paid hosted service, monetizing access or user data, or deriving other indirect commercial benefits.
###### Here are some examples that would not be allowed: ##### Here are some examples that would not be allowed:
- White-labeling PLANKA and offering it to your customers or affiliates for money. - White-labeling PLANKA and offering it to your customers or affiliates for money.
@ -66,25 +68,25 @@ Our license restricts use to "internal business purposes". In practice this mean
- Use PLANKA's API to power services for which money is charged. - Use PLANKA's API to power services for which money is charged.
- Use of PLANKA to connect your own legal entity with legal entities or persons outside your own legal entity. - Use of PLANKA to connect your own legal entity with legal entities or persons outside your organization in the context of a commercial relationship.
- Use PLANKA to conduct or support any kind of illegal or unlawful activity. - Use PLANKA to conduct or support any kind of illegal or unlawful activity.
###### All of the following examples are allowed under our license: ##### All of the following examples are allowed under our license:
- Using PLANKA to control your internal processes and manage your internal projects. - Using PLANKA to control your internal processes and manage your internal projects.
- Integrate PLANKA into other internally used products to enhance their capabilities. - Integrate PLANKA into other internally used products to enhance their capabilities.
- Providing consulting or educational services related to PLANKA, for example, to build or integrate workflows for or in connection with PLANKA or develop custom modules to extend its functionalities. - Providing consulting or public educational services related to PLANKA, for example, to build or integrate workflows for or in connection with PLANKA or develop custom modules to extend its functionalities.
- Supporting PLANKA, for example, by setting it up or maintaining it on an internal company server. - Supporting PLANKA, for example, by setting it up or maintaining it on an internal company server.
### Is it allowed to use PLANKA as a backend integration? ### Is it allowed to use PLANKA as a backend integration?
If you use PLANKA and its backend and related services outside the boundaries of internal operations and integrations within your own organization, you need to buy a "PLANKA Enterprise License". This also applies if you want to use PLANKA as a core backend infrastructure for third-party applications, services, or a system that is distributed to or accessed by external parties. If you use PLANKA and its backend services beyond internal operations within your own organization by enabling third-party access for commercial gain, such as sharing accounts with clients or selling a product or service where the value derives in any form from PLANKA's functionality, operating it as a paid hosted service, monetizing access or user data, or deriving other indirect commercial benefits, you are required to purchase a "PLANKA Pro/Enterprise License".
Any arrangement that involves sublicensing, repackaging, or otherwise making PLANKA available to external parties or integrating PLANKA into another product to serve as the primary operational engine for that product also requires a valid "PLANKA Enterprise License" or proprietary license available for our enterprise customers. This includes scenarios where PLANKA serves as core infrastructure for third-party solutions, is accessed by users outside your legal entity as part of a paid or commercial offering, or where you sublicense, repackage, or otherwise make PLANKA available to external parties. Any arrangement that involves integrating PLANKA into another product to serve as the primary operational engine for that product also requires a valid "PLANKA Pro/Enterprise License" or proprietary license available for our enterprise customers.
### Can you give me some quick examples to clarify free use vs. enterprise use? ### Can you give me some quick examples to clarify free use vs. enterprise use?
@ -112,9 +114,9 @@ A school or charity bundles PLANKA in a free, public Docker image for students w
##### Example 4: Provide PLANKA logins to clients and affiliates ##### Example 4: Provide PLANKA logins to clients and affiliates
To facilitate better project feedback, you offer your client and someone from a joint venture access to project boards inside your company. They can now comment on cards and also benefit from PLANKA's real-time update capabilities. To facilitate better project feedback, you offer your client and someone from a joint venture, both of whom you have a commercial relationship with, access to project boards inside your company. They can now comment on cards and also benefit from PLANKA's real-time update capabilities.
**NOT ALLOWED** under the "Fair Use License". Offering PLANKA as part of a paid service to third parties or providing PLANKA access to third-party users outside your own legal entity requires you to register a "PLANKA Enterprise License". **NOT ALLOWED** under the "Fair Use License". Offering PLANKA as part of a paid service to third parties or providing PLANKA access to third-party users outside your own legal entity in the context of a commercial relationship requires you to register a "PLANKA Pro/Enterprise License".
##### Example 5: Offer PLANKA as a hosted product to other companies ##### Example 5: Offer PLANKA as a hosted product to other companies
@ -124,19 +126,47 @@ You want to earn money by providing PLANKA to companies, freelancers, and other
--- ---
### Quick "Fair Use License" summary
- Personal, educational, hobby, or internal business use:
Free to use. You can use, modify, and host PLANKA for yourself, your team, or your organization for personal, educational, or internal operational purposes.
- Use by non-profits and public educational institutions:
Free to use for academic or research purposes, provided the organization qualifies as a non-profit under applicable tax laws. No application or special license is required.
- Providing access to third parties for any commercial gain:
Not allowed. Sharing accounts or credentials with third parties for any commercial gain is prohibited under the license, even if you are not directly charging for access.
- Hosting PLANKA as a commercial service:
Not allowed. You may not offer PLANKA as a hosted service or integrate it into a paid product or service where the value substantially comes from PLANKA. This includes advertising revenue, data monetization, or indirect commercial benefit or business advantage.
### What if I want to use PLANKA for something that's not permitted by the license? ### What if I want to use PLANKA for something that's not permitted by the license?
You must sign a separate commercial agreement with us. We actively encourage software integrators and technical staff to integrate and connect PLANKA within their other products and use our extensive API to respond to, control, and master processes within their company; we just ask them to sign an agreement laying out the terms of use and the license fees required by PLANKA for using the product. Through PLANKA's API, it is capable of controlling and responding to external systems. You can learn more [here](https://docs.planka.cloud/docs/category/api-reference) or contact us about it. To exceed the limits of the self-hosted Community version, you'll need a "PLANKA Pro/Enterprise License". Once you register with us, the "Fair Use License" restrictions are not only legally lifted, but you also gain access to the full enterprise feature set.
Alternatively, you can exceed these limits by subscribing to a hosted version - either Community or Pro/Enterprise. This allows, for example, providing accounts for your customers.
If you want to provide PLANKA as a service for other organizations or integrate PLANKA into your own software, you must sign a separate revenue agreement with us. We actively encourage software integrators and technical staff to integrate and connect PLANKA within their other products and use our extensive API to respond to, control, and master processes within their company; we just ask them to sign an agreement laying out the terms of use and the license fees required by PLANKA for using the product. Through PLANKA's API, it is capable of controlling and responding to external systems. You can learn more [here](https://docs.planka.cloud/docs/category/api-reference/) or contact us about it.
If you are unsure whether the use case you have in mind constitutes an internal business purpose or not, take a look at the [examples above](#can-you-give-me-some-quick-examples-to-clarify-free-use-vs-enterprise-use), and if you're still not sure, please contact us at [license@planka.group](mailto:license@planka.group). If you are unsure whether the use case you have in mind constitutes an internal business purpose or not, take a look at the [examples above](#can-you-give-me-some-quick-examples-to-clarify-free-use-vs-enterprise-use), and if you're still not sure, please contact us at [license@planka.group](mailto:license@planka.group).
### Why don't you use a default open-source license? ### Why isn't there a commercial license for a self-hosted Community version which lifts the "Fair Use License" limits?
We support the self-hosted Community version through our GitHub and Discord community channels, but we can't efficiently provide direct phone or email support for multiple versions while meeting our obligations to paying customers. That's why direct support is reserved exclusively for Pro/Enterprise customers.
However, we do offer professional installation services and monthly-based service contracts for setting up, updating, and monitoring all versions to help organizations get the most out of their PLANKA deployment - please contact us at [license@planka.group](mailto:license@planka.group).
### Why doesn't PLANKA use a default open-source license?
We spend a lot of time creating an easy yet powerful tool that makes controlling and mastering projects a fun experience. Also, we wanted PLANKA to be as widely and freely available as possible while also ensuring that we can build a sustainable and viable business. By making our product free to use, easy to distribute, and source-available, we help everyone access the product. By operating as a business, we can develop and release new features, fix bugs, and provide reliable software at scale long-term. We spend a lot of time creating an easy yet powerful tool that makes controlling and mastering projects a fun experience. Also, we wanted PLANKA to be as widely and freely available as possible while also ensuring that we can build a sustainable and viable business. By making our product free to use, easy to distribute, and source-available, we help everyone access the product. By operating as a business, we can develop and release new features, fix bugs, and provide reliable software at scale long-term.
### Why did you choose this license? ### Why did you choose this license?
We believe that the "Fair Use License" is beneficial for the community as well as for the developers. Development is a costly enterprise, and giving away a community version for free is a risk that many companies don't survive without selling software or the company. Therefore, many open-source companies live from donations or financial investors. Instead of selling our soul, we sell services and software licenses. This way we continue to grow, code, and support our community. So the short answer is "Live and let live" is how we feel about PLANKA. We believe that the "Fair Use License" is beneficial for the community as well as for the developers. Development is a costly enterprise, and giving away a Community version for free is a risk that many companies don't survive without selling software or the company. Therefore, many open-source companies live from donations or financial investors. Instead of selling our soul, we sell services and software licenses. This way we continue to grow, code, and support our community. So the short answer is "Live and let live" is how we feel about PLANKA.
Therefore, we are helping to promote [fair-code](https://faircode.io) software with the goal of making it a well-known umbrella term to describe software models like ours. To keep any friction around our proprietary license to an absolute minimum, we focus on two things: Therefore, we are helping to promote [fair-code](https://faircode.io) software with the goal of making it a well-known umbrella term to describe software models like ours. To keep any friction around our proprietary license to an absolute minimum, we focus on two things:

View file

@ -2,18 +2,20 @@
**Project mastering driven by fun** **Project mastering driven by fun**
![Version](https://img.shields.io/github/package-json/v/plankanban/planka?style=flat-square) ![Docker Pulls](https://img.shields.io/badge/docker_pulls-5M%2B-%23066da5?style=flat-square&color=%23e05d44) ![Contributors](https://img.shields.io/github/contributors/plankanban/planka?style=flat-square) ![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)
![Demo](https://raw.githubusercontent.com/plankanban/planka/master/assets/demo.gif) ![Demo](https://raw.githubusercontent.com/plankanban/planka/master/assets/demo.gif)
[**Client demo**](https://plankanban.github.io/planka) (without server features). [**Client demo**](https://plankanban.github.io/planka) (without server features).
> ⚠️ The demo GIF and client demo are based on **v1** and will be updated soon.
## Key Features ## Key Features
- **Collaborative Kanban Boards**: Create projects, boards, lists, cards, and manage tasks with an intuitive drag-and-drop interface - **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 - **Real-Time Updates**: Instant syncing across all users, no refresh needed
- **Rich Markdown Support**: Write beautifully formatted card descriptions with a powerful markdown editor - **Rich Markdown Support**: Write beautifully formatted card descriptions with a powerful markdown editor
- **Flexible Notifications**: Get alerts through 140+ providers, fully customizable to your workflow - **Flexible Notifications**: Get alerts through 100+ providers, fully customizable to your workflow
- **Seamless Authentication**: Single sign-on with OpenID Connect integration - **Seamless Authentication**: Single sign-on with OpenID Connect integration
- **Multilingual & Easy to Translate**: Full internationalization support for a global audience - **Multilingual & Easy to Translate**: Full internationalization support for a global audience
@ -35,7 +37,7 @@ For any security issues, please do not create a public issue on GitHub - instead
## License ## License
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 Enterprise License](https://github.com/plankanban/planka/blob/master/LICENSES/PLANKA%20Enterprise%20License%20EN.md). 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 - **Source Available**: The source code is always visible
- **Self-Hostable**: Deploy and host it anywhere - **Self-Hostable**: Deploy and host it anywhere

View file

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

View file

@ -4,6 +4,10 @@ metadata:
name: {{ include "planka.fullname" . }} name: {{ include "planka.fullname" . }}
labels: labels:
{{- include "planka.labels" . | nindent 4 }} {{- include "planka.labels" . | nindent 4 }}
{{- with .Values.deploymentAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
{{- if not .Values.autoscaling.enabled }} {{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }} replicas: {{ .Values.replicaCount }}
@ -120,7 +124,7 @@ spec:
value: {{ required "A secret key needs to be generated using 'openssl rand -hex 64' and assigned to secretkey." .Values.secretkey }} value: {{ required "A secret key needs to be generated using 'openssl rand -hex 64' and assigned to secretkey." .Values.secretkey }}
{{- end }} {{- end }}
- name: TRUST_PROXY - name: TRUST_PROXY
value: "0" value: "true"
- name: DEFAULT_ADMIN_EMAIL - name: DEFAULT_ADMIN_EMAIL
value: {{ .Values.admin_email }} value: {{ .Values.admin_email }}
- name: DEFAULT_ADMIN_NAME - name: DEFAULT_ADMIN_NAME

View file

@ -45,6 +45,9 @@ podAnnotations: {}
podSecurityContext: {} podSecurityContext: {}
# fsGroup: 2000 # fsGroup: 2000
# Annotations to add to the deployment
deploymentAnnotations: {}
securityContext: {} securityContext: {}
# capabilities: # capabilities:
# drop: # drop:
@ -210,3 +213,22 @@ oidc:
## key: key-inside-the-secret ## key: key-inside-the-secret
## ##
extraEnv: [] 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_USER
## value: "your_email@example.com"
## - name: SMTP_PASSWORD
## value: "your_password"
## - name: SMTP_FROM
## value: "your_email@example.com"
## - name: SMTP_TLS_REJECT_UNAUTHORIZED
## value: "false"

3727
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -79,12 +79,13 @@
}, },
"dependencies": { "dependencies": {
"@ballerina/highlightjs-ballerina": "^1.0.1", "@ballerina/highlightjs-ballerina": "^1.0.1",
"@diplodoc/cut-extension": "^0.7.3", "@diplodoc/cut-extension": "^0.7.4",
"@diplodoc/transform": "^4.57.2", "@diplodoc/transform": "^4.57.7",
"@gravity-ui/markdown-editor": "^15.11.0", "@gravity-ui/components": "^4.4.0",
"@gravity-ui/uikit": "^7.11.0", "@gravity-ui/markdown-editor": "^15.14.1",
"@gravity-ui/uikit": "^7.16.2",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.6.0",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
@ -95,7 +96,7 @@
"highlightjs-apex": "^1.5.0", "highlightjs-apex": "^1.5.0",
"highlightjs-blade": "^0.1.0", "highlightjs-blade": "^0.1.0",
"highlightjs-cobol": "^0.3.3", "highlightjs-cobol": "^0.3.3",
"highlightjs-cshtml-razor": "^2.1.1", "highlightjs-cshtml-razor": "^2.2.0",
"highlightjs-gf": "^1.0.1", "highlightjs-gf": "^1.0.1",
"highlightjs-jolie": "^0.1.8", "highlightjs-jolie": "^0.1.8",
"highlightjs-lean": "^1.2.0", "highlightjs-lean": "^1.2.0",
@ -113,8 +114,8 @@
"highlightjs-zenscript": "^2.0.0", "highlightjs-zenscript": "^2.0.0",
"hightlightjs-papyrus": "^0.0.4", "hightlightjs-papyrus": "^0.0.4",
"history": "^5.3.0", "history": "^5.3.0",
"i18next": "^23.16.8", "i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.2.0",
"initials": "^3.1.2", "initials": "^3.1.2",
"javascript-time-ago": "^2.5.11", "javascript-time-ago": "^2.5.11",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -125,6 +126,7 @@
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"markdown-it": "^13.0.2", "markdown-it": "^13.0.2",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"papaparse": "^5.5.3",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"photoswipe": "^5.4.4", "photoswipe": "^5.4.4",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
@ -135,12 +137,13 @@
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-frame-component": "^5.2.7", "react-frame-component": "^5.2.7",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.3",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-mentions": "^4.4.10",
"react-photoswipe-gallery": "^2.2.7", "react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3", "react-redux": "^8.1.3",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.1",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"react-time-ago": "^7.3.3", "react-time-ago": "^7.3.3",
"redux": "^4.2.1", "redux": "^4.2.1",
@ -149,34 +152,34 @@
"redux-saga": "^1.3.0", "redux-saga": "^1.3.0",
"reselect": "^4.1.8", "reselect": "^4.1.8",
"sails.io.js": "^1.2.1", "sails.io.js": "^1.2.1",
"sass-embedded": "^1.87.0", "sass-embedded": "^1.89.2",
"semantic-ui-react": "^2.1.5", "semantic-ui-react": "^2.1.5",
"socket.io-client": "^2.5.0", "socket.io-client": "^2.5.0",
"validator": "^13.15.0", "validator": "^13.15.15",
"vite": "^5.4.19", "vite": "^6.3.5",
"vite-plugin-commonjs": "^0.10.4", "vite-plugin-commonjs": "^0.10.4",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-svgr": "^4.3.0", "vite-plugin-svgr": "^4.3.0",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.27.1", "@babel/eslint-parser": "^7.27.5",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.27.2",
"@cucumber/cucumber": "^11.2.0", "@cucumber/cucumber": "^11.3.0",
"@cucumber/pretty-formatter": "^1.0.1", "@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.53.1",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"babel-preset-airbnb": "^5.0.0", "babel-preset-airbnb": "^5.0.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.4.0", "eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"jest": "^29.7.0", "jest": "^29.7.0",
"playwright": "^1.52.0", "playwright": "^1.53.1",
"prettier": "3.3.3" "prettier": "3.3.3"
} }
} }

View file

@ -22,6 +22,19 @@ index 2152fd6..ceda0c1 100644
yfmHeading: { yfmHeading: {
h1Key: f.toPM(A.Heading1), h1Key: f.toPM(A.Heading1),
h2Key: f.toPM(A.Heading2), h2Key: f.toPM(A.Heading2),
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 bd3d4b9..5a5a5be 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
@@ -9,7 +9,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 }))
+ .configureMd((md) => md.use(yfmPlugin, { log, 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 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..f95b693 100644 index ed2a9db..f95b693 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js --- a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js

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 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..fb7534d 100644 index 6d06078..e22d4f0 100644
--- a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js --- a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
+++ b/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,13 +17,7 @@ var doesNodeContainClick = function doesNodeContainClick(node, e) {
@ -17,6 +17,46 @@ index 6d06078..fb7534d 100644
} // Below logic handles cases where the e.target is no longer in the document. } // 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. // 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. // 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..7abb016 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/src/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js 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 index d1ae271..43e1170 100644
--- a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js --- a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

@ -5,15 +5,39 @@
import ActionTypes from '../constants/ActionTypes'; import ActionTypes from '../constants/ActionTypes';
const fetchActivities = (cardId) => ({ const fetchActivitiesInBoard = (boardId) => ({
type: ActionTypes.ACTIVITIES_FETCH, type: ActionTypes.ACTIVITIES_IN_BOARD_FETCH,
payload: {
boardId,
},
});
fetchActivitiesInBoard.success = (boardId, activities, users) => ({
type: ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS,
payload: {
boardId,
activities,
users,
},
});
fetchActivitiesInBoard.failure = (boardId, error) => ({
type: ActionTypes.ACTIVITIES_IN_BOARD_FETCH__FAILURE,
payload: {
boardId,
error,
},
});
const fetchActivitiesInCard = (cardId) => ({
type: ActionTypes.ACTIVITIES_IN_CARD_FETCH,
payload: { payload: {
cardId, cardId,
}, },
}); });
fetchActivities.success = (cardId, activities, users) => ({ fetchActivitiesInCard.success = (cardId, activities, users) => ({
type: ActionTypes.ACTIVITIES_FETCH__SUCCESS, type: ActionTypes.ACTIVITIES_IN_CARD_FETCH__SUCCESS,
payload: { payload: {
cardId, cardId,
activities, activities,
@ -21,8 +45,8 @@ fetchActivities.success = (cardId, activities, users) => ({
}, },
}); });
fetchActivities.failure = (cardId, error) => ({ fetchActivitiesInCard.failure = (cardId, error) => ({
type: ActionTypes.ACTIVITIES_FETCH__FAILURE, type: ActionTypes.ACTIVITIES_IN_CARD_FETCH__FAILURE,
payload: { payload: {
cardId, cardId,
error, error,
@ -37,6 +61,7 @@ const handleActivityCreate = (activity) => ({
}); });
export default { export default {
fetchActivities, fetchActivitiesInBoard,
fetchActivitiesInCard,
handleActivityCreate, handleActivityCreate,
}; };

View file

@ -8,6 +8,7 @@ import ActionTypes from '../constants/ActionTypes';
const initializeCore = ( const initializeCore = (
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,
@ -33,6 +34,7 @@ const initializeCore = (
payload: { payload: {
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,

View file

@ -8,6 +8,7 @@ import socket from './socket';
import login from './login'; import login from './login';
import core from './core'; import core from './core';
import modals from './modals'; import modals from './modals';
import webhooks from './webhooks';
import users from './users'; import users from './users';
import projects from './projects'; import projects from './projects';
import projectManagers from './project-managers'; import projectManagers from './project-managers';
@ -35,6 +36,7 @@ export default {
...login, ...login,
...core, ...core,
...modals, ...modals,
...webhooks,
...users, ...users,
...projects, ...projects,
...projectManagers, ...projectManagers,

View file

@ -14,6 +14,7 @@ const handleSocketReconnect = (
config, config,
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,
@ -40,6 +41,7 @@ const handleSocketReconnect = (
config, config,
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,

View file

@ -67,6 +67,7 @@ const handleUserUpdate = (
boardIds, boardIds,
config, config,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,
@ -95,6 +96,7 @@ const handleUserUpdate = (
boardIds, boardIds,
config, config,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,

View file

@ -0,0 +1,104 @@
/*!
* 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

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

View file

@ -7,6 +7,7 @@ import http from './http';
import socket from './socket'; import socket from './socket';
import config from './config'; import config from './config';
import accessTokens from './access-tokens'; import accessTokens from './access-tokens';
import webhooks from './webhooks';
import users from './users'; import users from './users';
import projects from './projects'; import projects from './projects';
import projectManagers from './project-managers'; import projectManagers from './project-managers';
@ -35,6 +36,7 @@ export { http, socket };
export default { export default {
...config, ...config,
...accessTokens, ...accessTokens,
...webhooks,
...users, ...users,
...projects, ...projects,
...projectManagers, ...projectManagers,

View file

@ -16,6 +16,9 @@ export const transformNotification = (notification) => ({
activityId: notification.actionId, activityId: notification.actionId,
} }
: notification), : notification),
...(notification.createdAt && {
createdAt: new Date(notification.createdAt),
}),
}); });
/* Actions */ /* Actions */

23
client/src/api/webhooks.js Executable file
View file

@ -0,0 +1,23 @@
/*!
* 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,
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

@ -0,0 +1,73 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useInView } from 'react-intersection-observer';
import { Comment, Loader } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import Item from './Item';
import styles from './BoardActivitiesModal.module.scss';
const BoardActivitiesModal = React.memo(() => {
const activityIds = useSelector(selectors.selectActivityIdsForCurrentBoard);
const { isActivitiesFetching, isAllActivitiesFetched } = useSelector(
selectors.selectCurrentBoard,
);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleClose = useCallback(() => {
dispatch(entryActions.closeModal());
}, [dispatch]);
const [inViewRef] = useInView({
threshold: 1,
onChange: (inView) => {
if (inView) {
dispatch(entryActions.fetchActivitiesInCurrentBoard());
}
},
});
const [ClosableModal] = useClosableModal();
return (
<ClosableModal closeIcon size="small" centered={false} onClose={handleClose}>
<ClosableModal.Header>
{t('common.boardActions', {
context: 'title',
})}
</ClosableModal.Header>
<ClosableModal.Content>
<div className={styles.itemsWrapper}>
<Comment.Group className={styles.items}>
{activityIds.map((activityId) => (
<Item key={activityId} id={activityId} />
))}
</Comment.Group>
</div>
{isActivitiesFetching !== undefined && isAllActivitiesFetched !== undefined && (
<div className={styles.loaderWrapper}>
{isActivitiesFetching ? (
<Loader active inverted inline="centered" size="small" />
) : (
!isAllActivitiesFetched && <div ref={inViewRef} />
)}
</div>
)}
</ClosableModal.Content>
</ClosableModal>
);
});
export default BoardActivitiesModal;

View file

@ -0,0 +1,18 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.items {
max-width: none;
}
.itemsWrapper {
margin-top: 12px;
}
.loaderWrapper {
margin-top: 10px;
}
}

View file

@ -0,0 +1,220 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
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-dom';
import { Comment } from 'semantic-ui-react';
import selectors from '../../../selectors';
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';
import styles from './Item.module.scss';
const Item = React.memo(({ id }) => {
const selectActivityById = useMemo(() => selectors.makeSelectActivityById(), []);
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const activity = useSelector((state) => selectActivityById(state, id));
const user = useSelector((state) => selectUserById(state, activity.userId));
const card = useSelector((state) => selectCardById(state, activity.cardId));
const [t] = useTranslation();
const userName =
user.id === StaticUserIds.DELETED
? t(`common.${user.name}`, {
context: 'title',
})
: user.name;
const cardName = card ? card.name : activity.data.card.name;
let contentNode;
switch (activity.type) {
case ActivityTypes.CREATE_CARD: {
const { list } = activity.data;
const listName = list.name || t(`common.${list.type}`);
contentNode = (
<Trans
i18nKey="common.userAddedCardToList"
values={{
user: userName,
card: cardName,
list: listName,
}}
>
<span className={styles.author}>{userName}</span>
{' added '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
{' to '}
{listName}
</Trans>
);
break;
}
case ActivityTypes.MOVE_CARD: {
const { fromList, toList } = activity.data;
const fromListName = fromList.name || t(`common.${fromList.type}`);
const toListName = toList.name || t(`common.${toList.type}`);
contentNode = (
<Trans
i18nKey="common.userMovedCardFromListToList"
values={{
user: userName,
card: cardName,
fromList: fromListName,
toList: toListName,
}}
>
<span className={styles.author}>{userName}</span>
{' moved '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
{' from '}
{fromListName}
{' to '}
{toListName}
</Trans>
);
break;
}
case ActivityTypes.ADD_MEMBER_TO_CARD:
contentNode =
user.id === activity.data.user.id ? (
<Trans
i18nKey="common.userJoinedCard"
values={{
user: userName,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' joined '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
) : (
<Trans
i18nKey="common.userAddedUserToCard"
values={{
actorUser: userName,
addedUser: activity.data.user.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' added '}
{activity.data.user.name}
{' to '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
case ActivityTypes.REMOVE_MEMBER_FROM_CARD:
contentNode =
user.id === activity.data.user.id ? (
<Trans
i18nKey="common.userLeftCard"
values={{
user: userName,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' left '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
) : (
<Trans
i18nKey="common.userRemovedUserFromCard"
values={{
actorUser: userName,
removedUser: activity.data.user.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' removed '}
{activity.data.user.name}
{' from '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
case ActivityTypes.COMPLETE_TASK:
contentNode = (
<Trans
i18nKey="common.userCompletedTaskOnCard"
values={{
user: userName,
task: activity.data.task.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' completed '}
{activity.data.task.name}
{' on '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
case ActivityTypes.UNCOMPLETE_TASK:
contentNode = (
<Trans
i18nKey="common.userMarkedTaskIncompleteOnCard"
values={{
user: userName,
task: activity.data.task.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' marked '}
{activity.data.task.name}
{' incomplete on '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
default:
contentNode = null;
}
return (
<Comment>
<span className={styles.user}>
<UserAvatar id={activity.userId} />
</span>
<div className={styles.content}>
<div>{contentNode}</div>
<span className={styles.date}>
<TimeAgo date={activity.createdAt} />
</span>
</div>
</Comment>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
};
export default Item;

View file

@ -6,28 +6,22 @@
:global(#app) { :global(#app) {
.author { .author {
color: #17394d; color: #17394d;
display: inline-block;
font-weight: bold; font-weight: bold;
line-height: 20px;
} }
.content { .content {
border-bottom: 1px solid #092d4221; border-bottom: 1px solid #092d4221;
display: inline-block; display: inline-block;
line-height: 20px;
padding-bottom: 14px; padding-bottom: 14px;
vertical-align: top; vertical-align: top;
width: calc(100% - 40px); width: calc(100% - 40px);
word-wrap: break-word;
} }
.date { .date {
color: #6b808c; color: #6b808c;
display: inline-block;
font-size: 12px; font-size: 12px;
line-height: 20px;
}
.text {
line-height: 20px;
} }
.user { .user {

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import BoardActivitiesModal from './BoardActivitiesModal';
export default BoardActivitiesModal;

View file

@ -12,9 +12,9 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import Item from './Item'; import Item from './Item';
import styles from './Activities.module.scss'; import styles from './CardActivities.module.scss';
const Activities = React.memo(() => { const CardActivities = React.memo(() => {
const activityIds = useSelector(selectors.selectActivityIdsForCurrentCard); const activityIds = useSelector(selectors.selectActivityIdsForCurrentCard);
const { isActivitiesFetching, isAllActivitiesFetched } = useSelector(selectors.selectCurrentCard); const { isActivitiesFetching, isAllActivitiesFetched } = useSelector(selectors.selectCurrentCard);
@ -51,4 +51,4 @@ const Activities = React.memo(() => {
); );
}); });
export default Activities; export default CardActivities;

View file

@ -48,10 +48,8 @@ const Item = React.memo(({ id }) => {
}} }}
> >
<span className={styles.author}>{userName}</span> <span className={styles.author}>{userName}</span>
<span className={styles.text}> {' added this card to '}
{' added this card to '} {listName}
{listName}
</span>
</Trans> </Trans>
); );
@ -73,17 +71,105 @@ const Item = React.memo(({ id }) => {
}} }}
> >
<span className={styles.author}>{userName}</span> <span className={styles.author}>{userName}</span>
<span className={styles.text}> {' moved this card from '}
{' moved this card from '} {fromListName}
{fromListName} {' to '}
{' to '} {toListName}
{toListName}
</span>
</Trans> </Trans>
); );
break; break;
} }
case ActivityTypes.ADD_MEMBER_TO_CARD:
contentNode =
user.id === activity.data.user.id ? (
<Trans
i18nKey="common.userJoinedThisCard"
values={{
user: userName,
}}
>
<span className={styles.author}>{userName}</span>
{' joined this card'}
</Trans>
) : (
<Trans
i18nKey="common.userAddedUserToThisCard"
values={{
actorUser: userName,
addedUser: activity.data.user.name,
}}
>
<span className={styles.author}>{userName}</span>
{' added '}
{activity.data.user.name}
{' to this card'}
</Trans>
);
break;
case ActivityTypes.REMOVE_MEMBER_FROM_CARD:
contentNode =
user.id === activity.data.user.id ? (
<Trans
i18nKey="common.userLeftThisCard"
values={{
user: userName,
}}
>
<span className={styles.author}>{userName}</span>
{' left this card'}
</Trans>
) : (
<Trans
i18nKey="common.userRemovedUserFromThisCard"
values={{
actorUser: userName,
removedUser: activity.data.user.name,
}}
>
<span className={styles.author}>{userName}</span>
{' removed '}
{activity.data.user.name}
{' from this card'}
</Trans>
);
break;
case ActivityTypes.COMPLETE_TASK:
contentNode = (
<Trans
i18nKey="common.userCompletedTaskOnThisCard"
values={{
user: userName,
task: activity.data.task.name,
}}
>
<span className={styles.author}>{userName}</span>
{' completed '}
{activity.data.task.name}
{' on this card'}
</Trans>
);
break;
case ActivityTypes.UNCOMPLETE_TASK:
contentNode = (
<Trans
i18nKey="common.userMarkedTaskIncompleteOnThisCard"
values={{
user: userName,
task: activity.data.task.name,
}}
>
<span className={styles.author}>{userName}</span>
{' marked '}
{activity.data.task.name}
{' incomplete on this card'}
</Trans>
);
break;
default: default:
contentNode = null; contentNode = null;
} }

View file

@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.author {
color: #17394d;
font-weight: bold;
}
.content {
border-bottom: 1px solid #092d4221;
display: inline-block;
line-height: 20px;
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
word-wrap: break-word;
}
.date {
color: #6b808c;
font-size: 12px;
}
.user {
display: inline-block;
padding: 4px 8px 0 0;
vertical-align: top;
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import CardActivities from './CardActivities';
export default CardActivities;

View file

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

View file

@ -21,6 +21,11 @@
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
.tip { .tip {
opacity: 0.5; opacity: 0.5;
} }

View file

@ -0,0 +1,121 @@
/*!
* 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, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
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;
const CsvViewer = React.memo(({ src, className }) => {
const [rows, setRows] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const frameStyles = useMemo(
() => [
...Array.from(document.styleSheets).flatMap((styleSheet) =>
Array.from(styleSheet.cssRules).map((cssRule) => cssRule.cssText),
),
'body{background:rgb(248,248,248);min-width:fit-content;overflow-x:visible}',
'.frame-content{padding:40px}',
'.frame-content>pre{margin:0}',
'.hljs{padding:0}',
'::-webkit-scrollbar{height:10px}',
'.ui.pagination.menu{display:flex;justify-content:center;margin-top:20px;padding:10px 0}',
],
[],
);
const handlePageChange = useCallback((_, { activePage }) => {
setCurrentPage(activePage);
}, []);
useEffect(() => {
async function fetchFile() {
try {
const response = await fetch(src, {
credentials: 'include',
});
const text = await response.text();
Papa.parse(text, {
skipEmptyLines: true,
complete: ({ data }) => {
setRows(data);
},
});
} catch {
/* empty */
}
}
fetchFile();
}, [src]);
if (rows === null) {
return <Loader active size="big" />;
}
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);
return (
<Frame
head={<style>{frameStyles.join('')}</style>}
className={classNames(styles.wrapper, className)}
>
<div>
<Table celled compact>
<Table.Header>
<Table.Row>
{rows[0].map((cell) => (
<Table.HeaderCell key={cell}>{cell}</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>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
{totalPages > 1 && (
<Pagination
secondary
pointing
totalPages={totalPages}
activePage={currentPage}
firstItem={null}
lastItem={null}
onPageChange={handlePageChange}
/>
)}
</Frame>
);
});
CsvViewer.propTypes = {
src: PropTypes.string.isRequired,
className: PropTypes.string,
};
CsvViewer.defaultProps = {
className: undefined,
};
export default CsvViewer;

View file

@ -0,0 +1,14 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
background: #fff;
border: 0;
display: flex;
flex-direction: column;
height: 100%;
}
}

View file

@ -11,10 +11,13 @@ import { useTranslation } from 'react-i18next';
import { Item as GalleryItem } from 'react-photoswipe-gallery'; import { Item as GalleryItem } from 'react-photoswipe-gallery';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import Config from '../../../constants/Config';
import Encodings from '../../../constants/Encodings'; import Encodings from '../../../constants/Encodings';
import { AttachmentTypes } from '../../../constants/Enums'; import { AttachmentTypes } from '../../../constants/Enums';
import ItemContent from './ItemContent'; import ItemContent from './ItemContent';
import ContentViewer from './ContentViewer'; import ContentViewer from './ContentViewer';
import PdfViewer from './PdfViewer';
import CsvViewer from './CsvViewer';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
@ -38,10 +41,8 @@ const Item = React.memo(({ id, isVisible }) => {
switch (attachment.data.mimeType) { switch (attachment.data.mimeType) {
case 'application/pdf': case 'application/pdf':
content = ( content = (
// eslint-disable-next-line jsx-a11y/alt-text <PdfViewer
<object src={attachment.data.url}
data={attachment.data.url}
type={attachment.data.mimeType}
className={classNames(styles.content, styles.contentViewer)} className={classNames(styles.content, styles.contentViewer)}
/> />
); );
@ -58,6 +59,15 @@ const Item = React.memo(({ id, isVisible }) => {
<audio controls src={attachment.data.url} className={styles.content} /> <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; break;
case 'video/mp4': case 'video/mp4':
case 'video/ogg': case 'video/ogg':
@ -70,13 +80,21 @@ const Item = React.memo(({ id, isVisible }) => {
break; break;
default: default:
if (attachment.data.encoding === Encodings.UTF8) { if (attachment.data.encoding === Encodings.UTF8) {
content = ( if (attachment.data.sizeInBytes <= Config.MAX_SIZE_IN_BYTES_TO_DISPLAY_CONTENT) {
<ContentViewer content = (
src={attachment.data.url} <ContentViewer
filename={attachment.data.filename} src={attachment.data.url}
className={classNames(styles.content, styles.contentViewer)} filename={attachment.data.filename}
/> className={classNames(styles.content, styles.contentViewer)}
); />
);
} else {
content = (
<span className={classNames(styles.content, styles.contentError)}>
{t('common.contentOfThisAttachmentIsTooBigToDisplay')}
</span>
);
}
} else { } else {
content = ( content = (
<span className={classNames(styles.content, styles.contentError)}> <span className={classNames(styles.content, styles.contentError)}>

View file

@ -23,6 +23,7 @@
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
height: 20px; height: 20px;
text-align: center;
width: 470px; width: 470px;
} }
} }

View file

@ -54,6 +54,19 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
} }
}, [onOpen, attachment.data]); }, [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( const handleToggleCoverClick = useCallback(
(event) => { (event) => {
event.stopPropagation(); event.stopPropagation();
@ -114,25 +127,31 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
<span className={styles.information}> <span className={styles.information}>
<TimeAgo date={attachment.createdAt} /> <TimeAgo date={attachment.createdAt} />
</span> </span>
{attachment.type === AttachmentTypes.FILE && attachment.data.image && canEdit && ( {attachment.type === AttachmentTypes.FILE && (
<span className={styles.options}> <span className={styles.options}>
<button type="button" className={styles.option} onClick={handleToggleCoverClick}> <button type="button" className={styles.option} onClick={handleDownloadClick}>
<Icon <Icon name="download" size="small" className={styles.optionIcon} />
name="window maximize outline" <span className={styles.optionText}>Download</span>
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
context: 'title',
})}
</span>
</button> </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> </span>
)} )}
</div> </div>

View file

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

View file

@ -0,0 +1,26 @@
/*!
* 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

@ -4,7 +4,7 @@
*/ */
:global(#app) { :global(#app) {
.submitButton { .wrapper {
margin-top: 12px; border: 0;
} }
} }

View file

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

View file

@ -7,14 +7,17 @@ import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import ModalTypes from '../../../constants/ModalTypes';
import { BoardContexts, BoardViews } from '../../../constants/Enums'; import { BoardContexts, BoardViews } from '../../../constants/Enums';
import KanbanContent from './KanbanContent'; import KanbanContent from './KanbanContent';
import FiniteContent from './FiniteContent'; import FiniteContent from './FiniteContent';
import EndlessContent from './EndlessContent'; import EndlessContent from './EndlessContent';
import CardModal from '../../cards/CardModal'; import CardModal from '../../cards/CardModal';
import BoardActivitiesModal from '../../activities/BoardActivitiesModal';
const Board = React.memo(() => { const Board = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard); const board = useSelector(selectors.selectCurrentBoard);
const modal = useSelector(selectors.selectCurrentModal);
const isCardModalOpened = useSelector((state) => !!selectors.selectPath(state).cardId); const isCardModalOpened = useSelector((state) => !!selectors.selectPath(state).cardId);
let Content; let Content;
@ -35,10 +38,23 @@ const Board = React.memo(() => {
} }
} }
let modalNode = null;
if (isCardModalOpened) {
modalNode = <CardModal />;
} else if (modal) {
switch (modal.type) {
case ModalTypes.BOARD_ACTIVITIES:
modalNode = <BoardActivitiesModal />;
break;
default:
}
}
return ( return (
<> <>
<Content /> <Content />
{isCardModalOpened && <CardModal />} {modalNode}
</> </>
); );
}); });

View file

@ -21,14 +21,14 @@ import CustomFieldGroupsStep from '../../../custom-field-groups/CustomFieldGroup
import styles from './ActionsStep.module.scss'; import styles from './ActionsStep.module.scss';
const StepTypes = { const StepTypes = {
EMPTY_TRASH: 'EMPTY_TRASH',
CUSTOM_FIELD_GROUPS: 'CUSTOM_FIELD_GROUPS', CUSTOM_FIELD_GROUPS: 'CUSTOM_FIELD_GROUPS',
EMPTY_TRASH: 'EMPTY_TRASH',
}; };
const ActionsStep = React.memo(({ onClose }) => { const ActionsStep = React.memo(({ onClose }) => {
const board = useSelector(selectors.selectCurrentBoard); const board = useSelector(selectors.selectCurrentBoard);
const { withSubscribe, withTrashEmptier, withCustomFieldGroups } = useSelector((state) => { const { withSubscribe, withCustomFieldGroups, withTrashEmptier } = useSelector((state) => {
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state); const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
@ -42,8 +42,8 @@ const ActionsStep = React.memo(({ onClose }) => {
return { return {
withSubscribe: isMember, // TODO: rename? withSubscribe: isMember, // TODO: rename?
withTrashEmptier: board.context === BoardContexts.TRASH && (isManager || isEditor),
withCustomFieldGroups: isEditor, withCustomFieldGroups: isEditor,
withTrashEmptier: board.context === BoardContexts.TRASH && (isManager || isEditor),
}; };
}, shallowEqual); }, shallowEqual);
@ -69,21 +69,28 @@ const ActionsStep = React.memo(({ onClose }) => {
[onClose, dispatch], [onClose, dispatch],
); );
const handleActivitiesClick = useCallback(() => {
dispatch(entryActions.openBoardActivitiesModal());
onClose();
}, [onClose, dispatch]);
const handleEmptyTrashConfirm = useCallback(() => { const handleEmptyTrashConfirm = useCallback(() => {
dispatch(entryActions.clearTrashListInCurrentBoard()); dispatch(entryActions.clearTrashListInCurrentBoard());
onClose(); onClose();
}, [onClose, dispatch]); }, [onClose, dispatch]);
const handleEmptyTrashClick = useCallback(() => {
openStep(StepTypes.EMPTY_TRASH);
}, [openStep]);
const handleCustomFieldsClick = useCallback(() => { const handleCustomFieldsClick = useCallback(() => {
openStep(StepTypes.CUSTOM_FIELD_GROUPS); openStep(StepTypes.CUSTOM_FIELD_GROUPS);
}, [openStep]); }, [openStep]);
const handleEmptyTrashClick = useCallback(() => {
openStep(StepTypes.EMPTY_TRASH);
}, [openStep]);
if (step) { if (step) {
switch (step.type) { switch (step.type) {
case StepTypes.CUSTOM_FIELD_GROUPS:
return <CustomFieldGroupsStep onBack={handleBack} onClose={onClose} />;
case StepTypes.EMPTY_TRASH: case StepTypes.EMPTY_TRASH:
return ( return (
<ConfirmationStep <ConfirmationStep
@ -94,8 +101,6 @@ const ActionsStep = React.memo(({ onClose }) => {
onBack={handleBack} onBack={handleBack}
/> />
); );
case StepTypes.CUSTOM_FIELD_GROUPS:
return <CustomFieldGroupsStep onBack={handleBack} onClose={onClose} />;
default: default:
} }
} }
@ -128,9 +133,15 @@ const ActionsStep = React.memo(({ onClose }) => {
})} })}
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item className={styles.menuItem} onClick={handleActivitiesClick}>
<Icon name="list ul" className={styles.menuItemIcon} />
{t('common.actions', {
context: 'title',
})}
</Menu.Item>
{withTrashEmptier && ( {withTrashEmptier && (
<> <>
{(withSubscribe || withCustomFieldGroups) && <hr className={styles.divider} />} <hr className={styles.divider} />
<Menu.Item className={styles.menuItem} onClick={handleEmptyTrashClick}> <Menu.Item className={styles.menuItem} onClick={handleEmptyTrashClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} /> <Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.emptyTrash', { {t('action.emptyTrash', {
@ -140,7 +151,7 @@ const ActionsStep = React.memo(({ onClose }) => {
</> </>
)} )}
<> <>
{(withSubscribe || withTrashEmptier) && <hr className={styles.divider} />} <hr className={styles.divider} />
{[BoardContexts.BOARD, BoardContexts.ARCHIVE, BoardContexts.TRASH].map((context) => ( {[BoardContexts.BOARD, BoardContexts.ARCHIVE, BoardContexts.TRASH].map((context) => (
<Menu.Item <Menu.Item
key={context} key={context}

View file

@ -23,6 +23,6 @@
.menuItemIcon { .menuItemIcon {
float: left; float: left;
margin-right: 0.5em; margin: 0 0.5em 0 0;
} }
} }

View file

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

View file

@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui'; import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
@ -290,6 +290,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
{canEditName && ( {canEditName && (
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}> <Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
<Icon name="edit outline" className={styles.menuItemIcon} />
{t('action.editTitle', { {t('action.editTitle', {
context: 'title', context: 'title',
})} })}
@ -297,6 +298,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{!board.limitCardTypesToDefaultOne && canEditType && ( {!board.limitCardTypesToDefaultOne && canEditType && (
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}> <Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
<Icon name="map outline" className={styles.menuItemIcon} />
{t('action.editType', { {t('action.editType', {
context: 'title', context: 'title',
})} })}
@ -304,6 +306,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.PROJECT && canUseMembers && ( {card.type === CardTypes.PROJECT && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}> <Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
<Icon name="user outline" className={styles.menuItemIcon} />
{t('common.members', { {t('common.members', {
context: 'title', context: 'title',
})} })}
@ -311,6 +314,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canUseLabels && ( {canUseLabels && (
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}> <Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
<Icon name="bookmark outline" className={styles.menuItemIcon} />
{t('common.labels', { {t('common.labels', {
context: 'title', context: 'title',
})} })}
@ -318,6 +322,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.STORY && canUseMembers && ( {card.type === CardTypes.STORY && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}> <Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
<Icon name="user outline" className={styles.menuItemIcon} />
{t('common.members', { {t('common.members', {
context: 'title', context: 'title',
})} })}
@ -325,6 +330,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.PROJECT && canEditDueDate && ( {card.type === CardTypes.PROJECT && canEditDueDate && (
<Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}> <Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}>
<Icon name="calendar check outline" className={styles.menuItemIcon} />
{t('action.editDueDate', { {t('action.editDueDate', {
context: 'title', context: 'title',
})} })}
@ -332,6 +338,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.PROJECT && canEditStopwatch && ( {card.type === CardTypes.PROJECT && canEditStopwatch && (
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}> <Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
<Icon name="clock outline" className={styles.menuItemIcon} />
{t('action.editStopwatch', { {t('action.editStopwatch', {
context: 'title', context: 'title',
})} })}
@ -339,6 +346,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canDuplicate && ( {canDuplicate && (
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}> <Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />
{t('action.duplicateCard', { {t('action.duplicateCard', {
context: 'title', context: 'title',
})} })}
@ -346,6 +354,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canMove && ( {canMove && (
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}> <Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
<Icon name="share square outline" className={styles.menuItemIcon} />
{t('action.moveCard', { {t('action.moveCard', {
context: 'title', context: 'title',
})} })}
@ -353,6 +362,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{prevList && canRestore && ( {prevList && canRestore && (
<Menu.Item className={styles.menuItem} onClick={handleRestoreClick}> <Menu.Item className={styles.menuItem} onClick={handleRestoreClick}>
<Icon name="undo alternate" className={styles.menuItemIcon} />
{t('action.restoreToList', { {t('action.restoreToList', {
context: 'title', context: 'title',
list: prevList.name || t(`common.${prevList.type}`), list: prevList.name || t(`common.${prevList.type}`),
@ -361,6 +371,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{list.type !== ListTypes.ARCHIVE && canArchive && ( {list.type !== ListTypes.ARCHIVE && canArchive && (
<Menu.Item className={styles.menuItem} onClick={handleArchiveClick}> <Menu.Item className={styles.menuItem} onClick={handleArchiveClick}>
<Icon name="folder open outline" className={styles.menuItemIcon} />
{t('action.archiveCard', { {t('action.archiveCard', {
context: 'title', context: 'title',
})} })}
@ -368,6 +379,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canDelete && ( {canDelete && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{isInTrashList {isInTrashList
? t('action.deleteForever', { ? t('action.deleteForever', {
context: 'title', context: 'title',

View file

@ -13,4 +13,9 @@
margin: 0; margin: 0;
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
} }

View file

@ -3,18 +3,19 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback, useMemo, useState } from 'react'; import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Button, Icon } from 'semantic-ui-react'; import { Button, Icon } from 'semantic-ui-react';
import { push } from '../../../lib/redux-router'; import { push } from '../../../lib/redux-router';
import { usePopup } from '../../../lib/popup'; import { closePopup, usePopup } from '../../../lib/popup';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import Paths from '../../../constants/Paths'; import Paths from '../../../constants/Paths';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums'; import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
import ProjectContent from './ProjectContent'; import ProjectContent from './ProjectContent';
import StoryContent from './StoryContent'; import StoryContent from './StoryContent';
import InlineContent from './InlineContent'; import InlineContent from './InlineContent';
@ -22,6 +23,7 @@ import EditName from './EditName';
import ActionsStep from './ActionsStep'; import ActionsStep from './ActionsStep';
import styles from './Card.module.scss'; import styles from './Card.module.scss';
import globalStyles from '../../../styles.module.scss';
const Card = React.memo(({ id, isInline }) => { const Card = React.memo(({ id, isInline }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []); const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
@ -29,6 +31,7 @@ const Card = React.memo(({ id, isInline }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []); const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const card = useSelector((state) => selectCardById(state, id)); const card = useSelector((state) => selectCardById(state, id));
const list = useSelector((state) => selectListById(state, card.listId));
const isHighlightedAsRecent = useSelector((state) => { const isHighlightedAsRecent = useSelector((state) => {
const { turnOffRecentCardHighlighting } = selectors.selectCurrentUser(state); const { turnOffRecentCardHighlighting } = selectors.selectCurrentUser(state);
@ -40,28 +43,16 @@ const Card = React.memo(({ id, isInline }) => {
return selectIsCardWithIdRecent(state, id); return selectIsCardWithIdRecent(state, id);
}); });
const { isDisabled, canUseActions } = useSelector((state) => { const canUseActions = useSelector((state) => {
const list = selectListById(state, card.listId);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state); const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR; return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
if (isListArchiveOrTrash(list)) {
return {
isDisabled: false,
canUseActions: isEditor,
};
}
return {
isDisabled: list.type === ListTypes.CLOSED && !isEditor,
canUseActions: isEditor,
};
}, shallowEqual);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isEditNameOpened, setIsEditNameOpened] = useState(false); const [isEditNameOpened, setIsEditNameOpened] = useState(false);
const actionsPopupRef = useRef(null);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (document.activeElement) { if (document.activeElement) {
document.activeElement.blur(); document.activeElement.blur();
@ -70,6 +61,17 @@ const Card = React.memo(({ id, isInline }) => {
dispatch(push(Paths.CARDS.replace(':id', id))); dispatch(push(Paths.CARDS.replace(':id', id)));
}, [id, dispatch]); }, [id, dispatch]);
const handleContextMenu = useCallback((event) => {
if (!actionsPopupRef.current) {
return;
}
event.preventDefault();
closePopup();
actionsPopupRef.current.open();
}, []);
const handleNameEdit = useCallback(() => { const handleNameEdit = useCallback(() => {
setIsEditNameOpened(true); setIsEditNameOpened(true);
}, []); }, []);
@ -101,24 +103,33 @@ const Card = React.memo(({ id, isInline }) => {
} }
} }
return ( const colorLineNode = list.color && (
<div <div
className={classNames( className={classNames(
styles.wrapper, styles.colorLine,
isDisabled && styles.wrapperDisabled, globalStyles[`background${upperFirst(camelCase(list.color))}`],
isHighlightedAsRecent && styles.wrapperRecent,
'card',
)} )}
/>
);
return (
<div
className={classNames(styles.wrapper, isHighlightedAsRecent && styles.wrapperRecent, 'card')}
> >
{card.isPersisted ? ( {card.isPersisted ? (
<> <>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */} jsx-a11y/no-static-element-interactions */}
<div className={styles.content} onClick={handleClick}> <div
className={classNames(styles.content, card.isClosed && styles.contentDisabled)}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<Content cardId={id} /> <Content cardId={id} />
{colorLineNode}
</div> </div>
{canUseActions && ( {canUseActions && (
<ActionsPopup cardId={id} onNameEdit={handleNameEdit}> <ActionsPopup ref={actionsPopupRef} cardId={id} onNameEdit={handleNameEdit}>
<Button className={styles.actionsButton}> <Button className={styles.actionsButton}>
<Icon fitted name="pencil" size="small" /> <Icon fitted name="pencil" size="small" />
</Button> </Button>
@ -126,8 +137,9 @@ const Card = React.memo(({ id, isInline }) => {
)} )}
</> </>
) : ( ) : (
<span className={styles.content}> <span className={classNames(styles.content, card.isClosed && styles.contentDisabled)}>
<Content cardId={id} /> <Content cardId={id} />
{colorLineNode}
</span> </span>
)} )}
</div> </div>

View file

@ -28,6 +28,11 @@
} }
} }
.colorLine {
border-radius: 0 0 3px 3px;
height: 4px;
}
.content { .content {
cursor: pointer; cursor: pointer;
@ -38,6 +43,11 @@
} }
} }
.contentDisabled {
filter: saturate(0.5);
opacity: 0.64;
}
.wrapper { .wrapper {
background: #fff; background: #fff;
border-radius: 3px; border-radius: 3px;
@ -54,11 +64,6 @@
} }
} }
.wrapperDisabled {
filter: saturate(0.5);
opacity: 0.64;
}
.wrapperRecent:not(:hover) { .wrapperRecent:not(:hover) {
overflow: hidden; overflow: hidden;

View file

@ -96,7 +96,6 @@ const EditName = React.memo(({ cardId, onClose }) => {
maxLength={1024} maxLength={1024}
minRows={3} minRows={3}
maxRows={8} maxRows={8}
spellCheck={false}
className={styles.field} className={styles.field}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange} onChange={handleFieldChange}

View file

@ -28,6 +28,7 @@ const InlineContent = React.memo(({ cardId }) => {
); );
const card = useSelector((state) => selectCardById(state, cardId)); const card = useSelector((state) => selectCardById(state, cardId));
const list = useSelector((state) => selectListById(state, card.listId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId)); const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const notificationsTotal = useSelector((state) => const notificationsTotal = useSelector((state) =>
@ -35,8 +36,6 @@ const InlineContent = React.memo(({ cardId }) => {
); );
const listName = useSelector((state) => { const listName = useSelector((state) => {
const list = selectListById(state, card.listId);
if (!list.name) { if (!list.name) {
return null; return null;
} }
@ -88,7 +87,9 @@ const InlineContent = React.memo(({ cardId }) => {
))} ))}
</span> </span>
)} )}
<span className={classNames(styles.attachments, styles.name)}> <span
className={classNames(styles.attachments, styles.name, card.isClosed && styles.nameClosed)}
>
<div className={styles.hidable}>{card.name}</div> <div className={styles.hidable}>{card.name}</div>
</span> </span>
{descriptionText && ( {descriptionText && (

View file

@ -50,6 +50,10 @@
max-width: 30%; max-width: 30%;
} }
.nameClosed {
text-decoration: line-through;
}
.notification { .notification {
background: #eb5a46; background: #eb5a46;
color: #fff; color: #fff;

View file

@ -13,7 +13,7 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch'; import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch';
import { isListArchiveOrTrash } from '../../../utils/record-helpers'; import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { BoardMembershipRoles, BoardViews, ListTypes } from '../../../constants/Enums'; import { BoardMembershipRoles, BoardViews } from '../../../constants/Enums';
import TaskList from './TaskList'; import TaskList from './TaskList';
import DueDateChip from '../DueDateChip'; import DueDateChip from '../DueDateChip';
import StopwatchChip from '../StopwatchChip'; import StopwatchChip from '../StopwatchChip';
@ -114,6 +114,7 @@ const ProjectContent = React.memo(({ cardId }) => {
card.description || card.description ||
card.dueDate || card.dueDate ||
card.stopwatch || card.stopwatch ||
card.commentsTotal > 0 ||
attachmentsTotal > 0 || attachmentsTotal > 0 ||
notificationsTotal > 0 || notificationsTotal > 0 ||
listName; listName;
@ -144,7 +145,7 @@ const ProjectContent = React.memo(({ cardId }) => {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.name}>{card.name}</div> <div className={classNames(styles.name, card.isClosed && styles.nameClosed)}>{card.name}</div>
{coverUrl && ( {coverUrl && (
<div className={styles.coverWrapper}> <div className={styles.coverWrapper}>
<img src={coverUrl} alt="" className={styles.cover} /> <img src={coverUrl} alt="" className={styles.cover} />
@ -186,11 +187,7 @@ const ProjectContent = React.memo(({ cardId }) => {
)} )}
{card.dueDate && ( {card.dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}> <span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDateChip <DueDateChip value={card.dueDate} size="tiny" withStatus={!card.isClosed} />
value={card.dueDate}
size="tiny"
withStatus={list.type !== ListTypes.CLOSED && !isListArchiveOrTrash(list)}
/>
</span> </span>
)} )}
{card.stopwatch && ( {card.stopwatch && (
@ -226,6 +223,14 @@ const ProjectContent = React.memo(({ cardId }) => {
</span> </span>
</span> </span>
)} )}
{card.commentsTotal > 0 && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<span className={styles.attachmentContent}>
<Icon name="comment outline" />
{card.commentsTotal}
</span>
</span>
)}
</span> </span>
)} )}
{!isCompact && usersNode} {!isCompact && usersNode}

View file

@ -84,6 +84,10 @@
word-wrap: break-word; word-wrap: break-word;
} }
.nameClosed {
text-decoration: line-through;
}
.notification { .notification {
background: #eb5a46; background: #eb5a46;
color: #fff; color: #fff;
@ -101,6 +105,12 @@
.wrapper { .wrapper {
padding: 6px 8px 0; padding: 6px 8px 0;
&:after {
clear: both;
content: "";
display: table;
}
} }
:global(.card):hover { :global(.card):hover {

View file

@ -40,6 +40,7 @@ const StoryContent = React.memo(({ cardId }) => {
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []); const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
const card = useSelector((state) => selectCardById(state, cardId)); const card = useSelector((state) => selectCardById(state, cardId));
const list = useSelector((state) => selectListById(state, card.listId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId)); const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const attachmentsTotal = useSelector((state) => selectAttachmentsTotalByCardId(state, cardId)); const attachmentsTotal = useSelector((state) => selectAttachmentsTotalByCardId(state, cardId));
@ -52,8 +53,6 @@ const StoryContent = React.memo(({ cardId }) => {
); );
const listName = useSelector((state) => { const listName = useSelector((state) => {
const list = selectListById(state, card.listId);
if (!list.name) { if (!list.name) {
return null; return null;
} }
@ -106,7 +105,9 @@ const StoryContent = React.memo(({ cardId }) => {
))} ))}
</span> </span>
)} )}
<div className={styles.name}>{card.name}</div> <div className={classNames(styles.name, card.isClosed && styles.nameClosed)}>
{card.name}
</div>
{card.description && <div className={styles.descriptionText}>{descriptionText}</div>} {card.description && <div className={styles.descriptionText}>{descriptionText}</div>}
{(attachmentsTotal > 0 || notificationsTotal > 0 || listName) && ( {(attachmentsTotal > 0 || notificationsTotal > 0 || listName) && (
<span className={styles.attachments}> <span className={styles.attachments}>

View file

@ -74,6 +74,10 @@
word-wrap: break-word; word-wrap: break-word;
} }
.nameClosed {
text-decoration: line-through;
}
.notification { .notification {
background: #eb5a46; background: #eb5a46;
color: #fff; color: #fff;

View file

@ -3,24 +3,49 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../../selectors'; import selectors from '../../../../selectors';
import Paths from '../../../../constants/Paths';
import Linkify from '../../../common/Linkify'; import Linkify from '../../../common/Linkify';
import styles from './Task.module.scss'; import styles from './Task.module.scss';
const Task = React.memo(({ id }) => { const Task = React.memo(({ id }) => {
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []); const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
const selectLinkedCardById = useMemo(() => selectors.makeSelectCardById(), []);
const task = useSelector((state) => selectTaskById(state, id)); const task = useSelector((state) => selectTaskById(state, id));
const linkedCard = useSelector(
(state) => task.linkedCardId && selectLinkedCardById(state, task.linkedCardId),
);
const handleLinkClick = useCallback((event) => {
event.stopPropagation();
}, []);
return ( return (
<li className={classNames(styles.wrapper, task.isCompleted && styles.wrapperCompleted)}> <li className={styles.wrapper}>
<Linkify linkStopPropagation>{task.name}</Linkify> {task.linkedCardId ? (
<>
<Icon name="exchange" size="small" className={styles.icon} />
<span className={classNames(styles.name, task.isCompleted && styles.nameCompleted)}>
<Link to={Paths.CARDS.replace(':id', task.linkedCardId)} onClick={handleLinkClick}>
{linkedCard ? linkedCard.name : task.name}
</Link>
</span>
</>
) : (
<span className={classNames(styles.name, task.isCompleted && styles.nameCompleted)}>
<Linkify linkStopPropagation>{task.name}</Linkify>
</span>
)}
</li> </li>
); );
}); });

View file

@ -4,6 +4,21 @@
*/ */
:global(#app) { :global(#app) {
.icon {
color: rgba(9, 30, 66, 0.24);
}
.name {
a:hover {
text-decoration: underline;
}
}
.nameCompleted {
color: #aaa;
text-decoration: line-through;
}
.wrapper { .wrapper {
display: block; display: block;
font-size: 12px; font-size: 12px;
@ -18,9 +33,4 @@
left: 10px; left: 10px;
} }
} }
.wrapperCompleted {
color: #aaa;
text-decoration: line-through;
}
} }

View file

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Menu, Tab } from 'semantic-ui-react'; import { Menu, Tab } from 'semantic-ui-react';
import Comments from '../../comments/Comments'; import Comments from '../../comments/Comments';
import Activities from '../../activities/Activities'; import CardActivities from '../../activities/CardActivities';
import styles from './Communication.module.scss'; import styles from './Communication.module.scss';
@ -34,7 +34,7 @@ const Communication = React.memo(() => {
})} })}
</Menu.Item> </Menu.Item>
), ),
render: () => <Activities />, render: () => <CardActivities />,
}, },
]; ];

View file

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

View file

@ -82,7 +82,6 @@ const NameField = React.memo(({ defaultValue, size, onUpdate }) => {
as={TextareaAutosize} as={TextareaAutosize}
value={value} value={value}
maxLength={1024} maxLength={1024}
spellCheck={false}
className={classNames(styles.field, styles[`field${upperFirst(size)}`])} className={classNames(styles.field, styles[`field${upperFirst(size)}`])}
onFocus={handleFocus} onFocus={handleFocus}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View file

@ -30,7 +30,7 @@ import SelectCardTypeStep from '../SelectCardTypeStep';
import EditDueDateStep from '../EditDueDateStep'; import EditDueDateStep from '../EditDueDateStep';
import EditStopwatchStep from '../EditStopwatchStep'; import EditStopwatchStep from '../EditStopwatchStep';
import MoveCardStep from '../MoveCardStep'; import MoveCardStep from '../MoveCardStep';
import Markdown from '../../common/Markdown'; import ExpandableMarkdown from '../../common/ExpandableMarkdown';
import EditMarkdown from '../../common/EditMarkdown'; import EditMarkdown from '../../common/EditMarkdown';
import ConfirmationStep from '../../common/ConfirmationStep'; import ConfirmationStep from '../../common/ConfirmationStep';
import UserAvatar from '../../users/UserAvatar'; import UserAvatar from '../../users/UserAvatar';
@ -442,18 +442,14 @@ const ProjectContent = React.memo(({ onClose }) => {
<DueDateChip <DueDateChip
withStatusIcon withStatusIcon
value={card.dueDate} value={card.dueDate}
withStatus={ withStatus={!card.isClosed}
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
/> />
</EditDueDatePopup> </EditDueDatePopup>
) : ( ) : (
<DueDateChip <DueDateChip
withStatusIcon withStatusIcon
value={card.dueDate} value={card.dueDate}
withStatus={ withStatus={!card.isClosed}
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
/> />
)} )}
</span> </span>
@ -521,7 +517,7 @@ const ProjectContent = React.memo(({ onClose }) => {
<Button className={styles.editButton}> <Button className={styles.editButton}>
<Icon fitted name="pencil" size="small" /> <Icon fitted name="pencil" size="small" />
</Button> </Button>
<Markdown>{card.description}</Markdown> <ExpandableMarkdown>{card.description}</ExpandableMarkdown>
</div> </div>
) : ( ) : (
<button <button
@ -536,7 +532,7 @@ const ProjectContent = React.memo(({ onClose }) => {
))} ))}
</> </>
)} )}
{!canEditDescription && <Markdown>{card.description}</Markdown>} {!canEditDescription && <ExpandableMarkdown>{card.description}</ExpandableMarkdown>}
</div> </div>
</div> </div>
)} )}

View file

@ -195,6 +195,7 @@
font-size: 28px; font-size: 28px;
font-weight: bold; font-weight: bold;
line-height: 34px; line-height: 34px;
word-wrap: break-word;
} }
.headerTitleWrapper { .headerTitleWrapper {

View file

@ -3,16 +3,18 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState, useRef } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import { Mention, MentionsInput } from 'react-mentions';
import { Button, Form, TextArea } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks'; import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks'; import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
import { isModifierKeyPressed } from '../../../utils/event-helpers'; import { isModifierKeyPressed } from '../../../utils/event-helpers';
import UserAvatar from '../../users/UserAvatar';
import styles from './Add.module.scss'; import styles from './Add.module.scss';
@ -21,13 +23,17 @@ const DEFAULT_DATA = {
}; };
const Add = React.memo(() => { const Add = React.memo(() => {
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
const [data, , setData] = useForm(DEFAULT_DATA);
const [isOpened, setIsOpened] = useState(false); const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectTextFieldState, selectTextField] = useToggle(); const [selectTextFieldState, selectTextField] = useToggle();
const [textFieldRef, handleTextFieldRef] = useNestedRef(); const textFieldRef = useRef(null);
const textMentionsRef = useRef(null);
const textInputRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef(); const [buttonRef, handleButtonRef] = useNestedRef();
const submit = useCallback(() => { const submit = useCallback(() => {
@ -37,19 +43,24 @@ const Add = React.memo(() => {
}; };
if (!cleanData.text) { if (!cleanData.text) {
textFieldRef.current.select(); textInputRef.current.select();
return; return;
} }
dispatch(entryActions.createCommentInCurrentCard(cleanData)); dispatch(entryActions.createCommentInCurrentCard(cleanData));
setData(DEFAULT_DATA); setData(DEFAULT_DATA);
selectTextField(); selectTextField();
}, [dispatch, data, setData, selectTextField, textFieldRef]); }, [dispatch, data, setData, selectTextField]);
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
if (textMentionsRef.current.isOpened()) {
textMentionsRef.current.clearSuggestions();
return;
}
setIsOpened(false); setIsOpened(false);
textFieldRef.current.blur(); textInputRef.current.blur();
}, [textFieldRef]); }, []);
const [activateEscapeInterceptor, deactivateEscapeInterceptor] = const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
useEscapeInterceptor(handleEscape); useEscapeInterceptor(handleEscape);
@ -62,6 +73,15 @@ const Add = React.memo(() => {
setIsOpened(true); setIsOpened(true);
}, []); }, []);
const handleFieldChange = useCallback(
(_, text) => {
setData({
text,
});
},
[setData],
);
const handleFieldKeyDown = useCallback( const handleFieldKeyDown = useCallback(
(event) => { (event) => {
if (isModifierKeyPressed(event) && event.key === 'Enter') { if (isModifierKeyPressed(event) && event.key === 'Enter') {
@ -76,8 +96,8 @@ const Add = React.memo(() => {
}, []); }, []);
const handleClickAwayCancel = useCallback(() => { const handleClickAwayCancel = useCallback(() => {
textFieldRef.current.focus(); textInputRef.current.focus();
}, [textFieldRef]); }, []);
const clickAwayProps = useClickAwayListener( const clickAwayProps = useClickAwayListener(
[textFieldRef, buttonRef], [textFieldRef, buttonRef],
@ -85,6 +105,16 @@ const Add = React.memo(() => {
handleClickAwayCancel, handleClickAwayCancel,
); );
const suggestionRenderer = useCallback(
(entry, _, highlightedDisplay) => (
<div className={styles.suggestion}>
<UserAvatar id={entry.id} size="tiny" />
{highlightedDisplay}
</div>
),
[],
);
useDidUpdate(() => { useDidUpdate(() => {
if (isOpened) { if (isOpened) {
activateEscapeInterceptor(); activateEscapeInterceptor();
@ -94,26 +124,44 @@ const Add = React.memo(() => {
}, [isOpened]); }, [isOpened]);
useDidUpdate(() => { useDidUpdate(() => {
textFieldRef.current.focus(); textInputRef.current.focus();
}, [selectTextFieldState]); }, [selectTextFieldState]);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<TextArea <div ref={textFieldRef} className={styles.field}>
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading <MentionsInput
ref={handleTextFieldRef} {...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
as={TextareaAutosize} allowSpaceInQuery
name="text" allowSuggestionsAboveCursor
value={data.text} ref={textMentionsRef}
placeholder={t('common.writeComment')} inputRef={textInputRef}
maxLength={1048576} value={data.text}
minRows={isOpened ? 3 : 1} placeholder={t('common.writeComment')}
spellCheck={false} maxLength={1048576}
className={styles.field} rows={isOpened ? 3 : 1}
onFocus={handleFieldFocus} className="mentions-input"
onKeyDown={handleFieldKeyDown} style={{
onChange={handleFieldChange} control: {
/> minHeight: isOpened ? '79px' : '37px',
},
}}
onFocus={handleFieldFocus}
onChange={handleFieldChange}
onKeyDown={handleFieldKeyDown}
>
<Mention
appendSpaceOnAdd
data={boardMemberships.map(({ user }) => ({
id: user.id,
display: user.username || user.name,
}))}
displayTransform={(_, display) => `@${display}`}
renderSuggestion={suggestionRenderer}
className={styles.mention}
/>
</MentionsInput>
</div>
{isOpened && ( {isOpened && (
<div className={styles.controls}> <div className={styles.controls}>
<Button <Button

View file

@ -16,20 +16,34 @@
} }
.field { .field {
background: #fff; textarea {
border: 0; background: #fff !important;
box-sizing: border-box; border: 1px solid rgba(9, 30, 66, 0.13);
color: #333; border-radius: 3px;
display: block; box-sizing: border-box;
line-height: 1.5; color: #333;
font-size: 14px; display: block;
overflow: hidden; font-size: 14px;
padding: 8px 12px; line-height: 1.4;
resize: none; margin: 0 !important;
width: 100%; overflow: hidden;
padding: 8px 12px;
resize: none;
&:focus { &:focus {
outline: none; outline: none;
}
} }
} }
.mention {
background-color: #f1f8ff;
border-radius: 3px;
}
.suggestion {
align-items: center;
display: flex;
gap: 8px;
}
} }

View file

@ -4,12 +4,12 @@
*/ */
import { dequal } from 'dequal'; import { dequal } from 'dequal';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import { Mention, MentionsInput } from 'react-mentions';
import { Button, Form, TextArea } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { useClickAwayListener } from '../../../lib/hooks'; import { useClickAwayListener } from '../../../lib/hooks';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
@ -17,6 +17,7 @@ import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks'; import { useForm, useNestedRef } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers'; import { focusEnd } from '../../../utils/element-helpers';
import { isModifierKeyPressed } from '../../../utils/event-helpers'; import { isModifierKeyPressed } from '../../../utils/event-helpers';
import UserAvatar from '../../users/UserAvatar';
import styles from './Edit.module.scss'; import styles from './Edit.module.scss';
@ -24,6 +25,7 @@ const Edit = React.memo(({ commentId, onClose }) => {
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []); const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
const comment = useSelector((state) => selectCommentById(state, commentId)); const comment = useSelector((state) => selectCommentById(state, commentId));
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
@ -35,13 +37,16 @@ const Edit = React.memo(({ commentId, onClose }) => {
[comment.text], [comment.text],
); );
const [data, handleFieldChange] = useForm(() => ({ const [data, , setData] = useForm(() => ({
text: '', text: '',
...defaultData, ...defaultData,
})); }));
const [textFieldRef, handleTextFieldRef] = useNestedRef(); const textFieldRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef(); const textMentionsRef = useRef(null);
const textInputRef = useRef(null);
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
const [cancelButtonRef, handleCancelButtonRef] = useNestedRef();
const submit = useCallback(() => { const submit = useCallback(() => {
const cleanData = { const cleanData = {
@ -60,6 +65,15 @@ const Edit = React.memo(({ commentId, onClose }) => {
submit(); submit();
}, [submit]); }, [submit]);
const handleFieldChange = useCallback(
(_, text) => {
setData({
text,
});
},
[setData],
);
const handleFieldKeyDown = useCallback( const handleFieldKeyDown = useCallback(
(event) => { (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
@ -67,48 +81,92 @@ const Edit = React.memo(({ commentId, onClose }) => {
submit(); submit();
} }
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
if (textMentionsRef.current.isOpened()) {
textMentionsRef.current.clearSuggestions();
return;
}
onClose(); onClose();
} }
}, },
[onClose, submit], [onClose, submit],
); );
const handleCancelClick = useCallback(() => {
onClose();
}, [onClose]);
const handleClickAwayCancel = useCallback(() => { const handleClickAwayCancel = useCallback(() => {
textFieldRef.current.focus(); textInputRef.current.focus();
}, [textFieldRef]); }, []);
const clickAwayProps = useClickAwayListener( const clickAwayProps = useClickAwayListener(
[textFieldRef, buttonRef], [textFieldRef, submitButtonRef, cancelButtonRef],
submit, submit,
handleClickAwayCancel, handleClickAwayCancel,
); );
const suggestionRenderer = useCallback(
(entry, _, highlightedDisplay) => (
<div className={styles.suggestion}>
<UserAvatar id={entry.id} size="tiny" />
{highlightedDisplay}
</div>
),
[],
);
useEffect(() => { useEffect(() => {
focusEnd(textFieldRef.current); focusEnd(textInputRef.current);
}, [textFieldRef]); }, []);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<TextArea <div ref={textFieldRef} className={styles.field}>
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading <MentionsInput
ref={handleTextFieldRef} {...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
as={TextareaAutosize} allowSpaceInQuery
name="text" allowSuggestionsAboveCursor
value={data.text} ref={textMentionsRef}
maxLength={1048576} inputRef={textInputRef}
minRows={3} value={data.text}
spellCheck={false} maxLength={1048576}
className={styles.field} rows={3}
onKeyDown={handleFieldKeyDown} className="mentions-input"
onChange={handleFieldChange} style={{
/> control: {
minHeight: '79px',
},
}}
onChange={handleFieldChange}
onKeyDown={handleFieldKeyDown}
>
<Mention
appendSpaceOnAdd
data={boardMemberships.map(({ user }) => ({
id: user.id,
display: user.username || user.name,
}))}
displayTransform={(_, display) => `@${display}`}
renderSuggestion={suggestionRenderer}
className={styles.mention}
/>
</MentionsInput>
</div>
<div className={styles.controls}> <div className={styles.controls}>
<Button <Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading {...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
positive positive
ref={handleButtonRef} ref={handleSubmitButtonRef}
content={t('action.save')} content={t('action.save')}
/> />
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleCancelButtonRef}
type="button"
content={t('action.cancel')}
onClick={handleCancelClick}
/>
</div> </div>
</Form> </Form>
); );

View file

@ -10,22 +10,34 @@
} }
.field { .field {
background: #fff; textarea {
border: 1px solid rgba(9, 30, 66, 0.13); background: #fff !important;
border-radius: 3px; border: 1px solid rgba(9, 30, 66, 0.13);
box-sizing: border-box; border-radius: 3px;
color: #333; box-sizing: border-box;
display: block; color: #333;
line-height: 1.4; display: block;
font-size: 14px; font-size: 14px;
margin-bottom: 4px; line-height: 1.4;
overflow: hidden; margin: 0 !important;
padding: 8px 12px; overflow: hidden;
resize: none; padding: 8px 12px;
width: 100%; resize: none;
&:focus { &:focus {
outline: none; outline: none;
}
} }
} }
.mention {
background-color: #f1f8ff;
border-radius: 3px;
}
.suggestion {
align-items: center;
display: flex;
gap: 8px;
}
} }

View file

@ -12,6 +12,7 @@ import { Modal, Tab } from 'semantic-ui-react';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks'; import { useClosableModal } from '../../../hooks';
import UsersPane from './UsersPane'; import UsersPane from './UsersPane';
import WebhooksPane from './WebhooksPane';
import styles from './AdministrationModal.module.scss'; import styles from './AdministrationModal.module.scss';
@ -37,6 +38,12 @@ const AdministrationModal = React.memo(() => {
}), }),
render: () => <UsersPane />, render: () => <UsersPane />,
}, },
{
menuItem: t('common.webhooks', {
context: 'title',
}),
render: () => <WebhooksPane />,
},
]; ];
const isUsersPaneActive = activeTabIndex === 0; const isUsersPaneActive = activeTabIndex === 0;

View file

@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui'; import { Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors'; import selectors from '../../../../selectors';
@ -180,12 +180,14 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
<Popup.Content> <Popup.Content>
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditInformationClick}> <Menu.Item className={styles.menuItem} onClick={handleEditInformationClick}>
<Icon name="info" className={styles.menuItemIcon} />
{t('action.editInformation', { {t('action.editInformation', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
{!user.lockedFieldNames.includes('username') && ( {!user.lockedFieldNames.includes('username') && (
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}> <Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
<Icon name="at" className={styles.menuItemIcon} />
{t('action.editUsername', { {t('action.editUsername', {
context: 'title', context: 'title',
})} })}
@ -193,6 +195,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
)} )}
{!user.lockedFieldNames.includes('email') && ( {!user.lockedFieldNames.includes('email') && (
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}> <Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
<Icon name="mail outline" className={styles.menuItemIcon} />
{t('action.editEmail', { {t('action.editEmail', {
context: 'title', context: 'title',
})} })}
@ -200,6 +203,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
)} )}
{!user.lockedFieldNames.includes('password') && ( {!user.lockedFieldNames.includes('password') && (
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}> <Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
<Icon name="keyboard outline" className={styles.menuItemIcon} />
{t('action.editPassword', { {t('action.editPassword', {
context: 'title', context: 'title',
})} })}
@ -207,6 +211,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
)} )}
{!user.lockedFieldNames.includes('role') && ( {!user.lockedFieldNames.includes('role') && (
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}> <Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
<Icon name="sun outline" className={styles.menuItemIcon} />
{t('action.editRole', { {t('action.editRole', {
context: 'title', context: 'title',
})} })}
@ -221,6 +226,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
className={styles.menuItem} className={styles.menuItem}
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick} onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
> >
<Icon name={user.isDeactivated ? 'plus' : 'close'} className={styles.menuItemIcon} />
{user.isDeactivated {user.isDeactivated
? t('action.activateUser', { ? t('action.activateUser', {
context: 'title', context: 'title',
@ -231,6 +237,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
</Menu.Item> </Menu.Item>
{user.isDeactivated && !user.isDefaultAdmin && ( {user.isDeactivated && !user.isDefaultAdmin && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.deleteUser', { {t('action.deleteUser', {
context: 'title', context: 'title',
})} })}

View file

@ -13,4 +13,9 @@
margin: 0; margin: 0;
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
} }

View file

@ -117,7 +117,7 @@ const AddStep = React.memo(({ onClose }) => {
); );
const handleMessageDismiss = useCallback(() => { const handleMessageDismiss = useCallback(() => {
dispatch(entryActions.clearUserPasswordUpdateError()); dispatch(entryActions.clearUserCreateError());
}, [dispatch]); }, [dispatch]);
const handleSelectRoleClick = useCallback(() => { const handleSelectRoleClick = useCallback(() => {

View file

@ -0,0 +1,35 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import Webhooks from '../../webhooks/Webhooks';
import styles from './WebhooksPane.module.scss';
const WebhooksPane = React.memo(() => {
const webhookIds = useSelector(selectors.selectWebhookIds);
const dispatch = useDispatch();
const handleCreate = useCallback(
(data) => {
dispatch(entryActions.createWebhook(data));
},
[dispatch],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Webhooks ids={webhookIds} onCreate={handleCreate} />
</Tab.Pane>
);
});
export default WebhooksPane;

View file

@ -0,0 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
}

View file

@ -28,21 +28,26 @@ const ConfirmationStep = React.memo(
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef'); const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(
if (typeValue) { (event) => {
const cleanData = { event.stopPropagation();
...data,
typeValue: data.typeValue.trim(),
};
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) { if (typeValue) {
nameFieldRef.current.select(); const cleanData = {
return; ...data,
typeValue: data.typeValue.trim(),
};
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
nameFieldRef.current.select();
return;
}
} }
}
onConfirm(); onConfirm();
}, [typeValue, onConfirm, data, nameFieldRef]); },
[typeValue, onConfirm, data, nameFieldRef],
);
useEffect(() => { useEffect(() => {
if (typeValue) { if (typeValue) {

View file

@ -26,6 +26,7 @@ const Core = React.memo(() => {
const modal = useSelector(selectors.selectCurrentModal); const modal = useSelector(selectors.selectCurrentModal);
const project = useSelector(selectors.selectCurrentProject); const project = useSelector(selectors.selectCurrentProject);
const board = useSelector(selectors.selectCurrentBoard); const board = useSelector(selectors.selectCurrentBoard);
const currentUserId = useSelector(selectors.selectCurrentUserId);
// TODO: move to selector? // TODO: move to selector?
const isNewVersionAvailable = useSelector((state) => { const isNewVersionAvailable = useSelector((state) => {
@ -107,7 +108,7 @@ const Core = React.memo(() => {
return ( return (
<> <>
{isInitializing ? ( {isInitializing || !currentUserId ? (
<Loader active size="massive" /> <Loader active size="massive" />
) : ( ) : (
<> <>

View file

@ -0,0 +1,78 @@
/*!
* 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, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Icon } from 'semantic-ui-react';
import { useToggle } from '../../../lib/hooks';
import Markdown from '../Markdown';
import styles from './ExpandableMarkdown.module.scss';
const MAX_VISIBLE_PART_HEIGHT = 800;
const ExpandableMarkdown = React.memo(({ children }) => {
const [t] = useTranslation();
const [isOverflowing, setIsOverflowing] = useState(false);
const [isExpanded, toggleExpanded] = useToggle();
const contentRef = useRef(null);
const handleToggleExpandClick = useCallback(
(event) => {
event.stopPropagation();
event.target.blur();
toggleExpanded();
},
[toggleExpanded],
);
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
setIsOverflowing(contentRef.current.scrollHeight > MAX_VISIBLE_PART_HEIGHT);
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
const isPartHidden = isOverflowing && !isExpanded;
return (
<>
<div
className={classNames(
styles.wrapper,
isExpanded && styles.wrapperExpanded,
isPartHidden && styles.wrapperPartHidden,
)}
style={{ maxHeight: `${MAX_VISIBLE_PART_HEIGHT}px` }}
>
<div ref={contentRef}>
<Markdown>{children}</Markdown>
</div>
</div>
{isOverflowing && (
<Button fluid className={styles.toggleButton} onClick={handleToggleExpandClick}>
<Icon name={isExpanded ? 'angle up' : 'angle down'} />
{isExpanded ? t('action.showLess') : t('action.showMore')}
</Button>
)}
</>
);
});
ExpandableMarkdown.propTypes = {
children: PropTypes.string.isRequired,
};
export default ExpandableMarkdown;

View file

@ -0,0 +1,28 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.toggleButton {
box-shadow: none;
font-weight: normal;
margin-top: 8px;
padding: 6px 11px;
text-align: center;
}
.wrapper {
overflow: hidden;
}
.wrapperExpanded {
max-height: none !important;
overflow: visible;
}
.wrapperPartHidden {
mask-image: linear-gradient(180deg, #000 80%, transparent);
-webkit-mask-image: linear-gradient(180deg, #000 80%, transparent);
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ExpandableMarkdown from './ExpandableMarkdown';
export default ExpandableMarkdown;

View file

@ -1,75 +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 } from 'react';
import PropTypes from 'prop-types';
import LinkifyReact from 'linkify-react';
import history from '../../history';
const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => {
const handleLinkClick = useCallback(
(event) => {
if (linkStopPropagation) {
event.stopPropagation();
}
if (!event.target.getAttribute('target')) {
event.preventDefault();
history.push(event.target.href);
}
},
[linkStopPropagation],
);
const linkRenderer = useCallback(
({ attributes: { href, ...linkProps }, content }) => {
let url;
try {
url = new URL(href, window.location);
} catch {
/* empty */
}
const isSameSite = !!url && url.origin === window.location.origin;
return (
<a
{...linkProps} // eslint-disable-line react/jsx-props-no-spreading
href={href}
target={isSameSite ? undefined : '_blank'}
rel={isSameSite ? undefined : 'noreferrer'}
onClick={handleLinkClick}
>
{isSameSite ? url.pathname : content}
</a>
);
},
[handleLinkClick],
);
return (
<LinkifyReact
{...props} // eslint-disable-line react/jsx-props-no-spreading
options={{
defaultProtocol: 'https',
render: linkRenderer,
}}
>
{children}
</LinkifyReact>
);
});
Linkify.propTypes = {
children: PropTypes.string.isRequired,
linkStopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
linkStopPropagation: false,
};
export default Linkify;

View file

@ -0,0 +1,81 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import history from '../../../history';
import selectors from '../../../selectors';
import matchPaths from '../../../utils/match-paths';
import Paths from '../../../constants/Paths';
const Linkify = React.memo(({ href, content, stopPropagation, ...props }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const url = useMemo(() => {
try {
return new URL(href, window.location);
} catch {
return null;
}
}, [href]);
const isSameSite = !!url && url.origin === window.location.origin;
const cardsPathMatch = useMemo(() => {
if (!isSameSite) {
return null;
}
return matchPaths(url.pathname, [Paths.CARDS]);
}, [url.pathname, isSameSite]);
const card = useSelector((state) => {
if (!cardsPathMatch) {
return null;
}
return selectCardById(state, cardsPathMatch.params.id);
});
const handleClick = useCallback(
(event) => {
if (stopPropagation) {
event.stopPropagation();
}
if (isSameSite) {
event.preventDefault();
history.push(event.target.href);
}
},
[stopPropagation, isSameSite],
);
return (
<a
{...props} // eslint-disable-line react/jsx-props-no-spreading
href={href}
target={isSameSite ? undefined : '_blank'}
rel={isSameSite ? undefined : 'noreferrer'}
onClick={handleClick}
>
{card ? card.name : content}
</a>
);
});
Linkify.propTypes = {
href: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
stopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
stopPropagation: false,
};
export default Linkify;

View file

@ -0,0 +1,43 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import LinkifyReact from 'linkify-react';
import Link from './Link';
const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => {
const linkRenderer = useCallback(
({ attributes: { href, ...linkProps }, content }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<Link {...linkProps} href={href} content={content} stopPropagation={linkStopPropagation} />
),
[linkStopPropagation],
);
return (
<LinkifyReact
{...props} // eslint-disable-line react/jsx-props-no-spreading
options={{
defaultProtocol: 'https',
render: linkRenderer,
}}
>
{children}
</LinkifyReact>
);
});
Linkify.propTypes = {
children: PropTypes.string.isRequired,
linkStopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
linkStopPropagation: undefined,
};
export default Linkify;

View file

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

View file

@ -7,7 +7,7 @@ import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { Button, Divider, Form, Grid, Header, Message } from 'semantic-ui-react'; import { Button, Divider, Form, Grid, Header, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks'; import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import { Input } from '../../../lib/custom-ui'; import { Input } from '../../../lib/custom-ui';
@ -247,7 +247,14 @@ const Content = React.memo(() => {
/> />
)} )}
</div> </div>
<p className={styles.formFooter}>{t('common.poweredByPlanka')}</p> <p className={styles.formFooter}>
<Trans i18nKey="common.poweredByPlanka">
{'Powered by '}
<a href="https://github.com/plankanban/planka" target="_blank" rel="noreferrer">
PLANKA
</a>
</Trans>
</p>
</div> </div>
</Grid.Column> </Grid.Column>
<Grid.Column <Grid.Column

View file

@ -14,6 +14,7 @@
background: #fff; background: #fff;
} }
.g-md-markup-editor__toolbar,
.g-md-wysiwyg-editor__toolbar { .g-md-wysiwyg-editor__toolbar {
background: #f5f6f7; background: #f5f6f7;
z-index: 2000; z-index: 2000;

View file

@ -3,9 +3,10 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Button, Icon } from 'semantic-ui-react';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
@ -48,6 +49,7 @@ const CustomField = React.memo(({ id, customFieldGroupId }) => {
}); });
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isCopied, setIsCopied] = useState(false);
const handleValueUpdate = useCallback( const handleValueUpdate = useCallback(
(content) => { (content) => {
@ -64,18 +66,40 @@ const CustomField = React.memo(({ id, customFieldGroupId }) => {
[id, customFieldGroupId, cardId, dispatch], [id, customFieldGroupId, cardId, dispatch],
); );
const handleCopyClick = useCallback(() => {
if (isCopied) {
return;
}
navigator.clipboard.writeText(customFieldValue.content);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
}, [customFieldValue, isCopied]);
return ( return (
<div> <div>
<div className={styles.name}>{customField.name}</div> <div className={styles.name}>{customField.name}</div>
{canEdit ? ( <div className={styles.valueWrapper}>
<ValueField {canEdit ? (
defaultValue={customFieldValue && customFieldValue.content} <ValueField
disabled={!customField.isPersisted} defaultValue={customFieldValue && customFieldValue.content}
onUpdate={handleValueUpdate} disabled={!customField.isPersisted}
/> onUpdate={handleValueUpdate}
) : ( />
<div className={styles.value}>{customFieldValue ? customFieldValue.content : '\u00A0'}</div> ) : (
)} <div className={styles.value}>
{customFieldValue ? customFieldValue.content : '\u00A0'}
</div>
)}
{customFieldValue && customFieldValue.content && (
<Button className={styles.copyButton} onClick={handleCopyClick}>
<Icon fitted name={isCopied ? 'check' : 'copy'} />
</Button>
)}
</div>
</div> </div>
); );
}); });

View file

@ -4,6 +4,30 @@
*/ */
:global(#app) { :global(#app) {
.copyButton {
background: #ebeef0;
box-shadow: none;
border-radius: 3px;
box-sizing: content-box;
color: #516b7a;
display: none;
height: 30px;
margin: 0;
min-height: auto;
outline: none;
padding: 4px;
position: absolute;
right: 0;
top: 0;
transition: background 85ms ease;
width: 20px;
&:hover {
background: #dfe3e6;
color: #4c4c4c;
}
}
.name { .name {
color: #6b808c; color: #6b808c;
font-size: 13px; font-size: 13px;
@ -25,4 +49,14 @@
padding: 8px 12px; padding: 8px 12px;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.valueWrapper {
position: relative;
&:hover:not(:has(input:focus)) {
.copyButton {
display: block;
}
}
}
} }

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