Compare commits

..

130 Commits

Author SHA1 Message Date
Maksim Eltyshev
d5d3f1de44 chore: Update version 2026-02-23 18:44:06 +01:00
Maksim Eltyshev
4e9e842e3d chore: Update server dependencies 2026-02-23 18:12:14 +01:00
Fabian Reinold
605dcace54 fix(gravatar): Update hash algorithm to SHA-256 for improved security (#1550) 2026-02-23 16:59:55 +01:00
SnoozeFreddo
61753f08eb fix(helm): Add writable temp directory mounts when readOnlyRootFilesystem is enabled (#1542) 2026-02-20 16:18:30 +01:00
Maksim Eltyshev
52c96c6c8f fix(platform): Make app compatible with Windows 2026-02-19 20:15:28 +01:00
Maksim Eltyshev
2a1760393f fix(dropzone): Prevent dropzone from overflowing content 2026-02-19 17:09:21 +01:00
seals187
7758312e05 fix(backup): Improve backup/restore scripts, allow specifying backup directory (#1541) 2026-02-19 13:13:12 +01:00
Maksim Eltyshev
414418130d chore: Update version 2026-02-17 15:55:56 +01:00
Maksim Eltyshev
d83ea4b146 fix(terms): Display template notice, support custom terms loading
Closes #1523
2026-02-17 15:37:26 +01:00
Fabian Reinold
addad4378a feat(helm): Add image digest pinning support (#1531)
* feat(helm): add image digest field to values

- Add optional 'digest' field under image.repository configuration
- Allows users to pin container images by SHA256 digest
- Improves security through immutable image identification
- Fully backward compatible (digest is optional)

* feat(helm): implement image digest pinning in deployment template

- Add conditional logic to support SHA256 digest in image references
- When digest is set with tag: generates 'repository:tag@sha256:digest'
- When digest is set without tag: generates 'repository@sha256:digest'
- Preserves backward compatibility with tag-only deployments
- Validates tag presence to avoid invalid image references

* docs(helm): add image digest pinning documentation

- Add 'Image Digest Pinning' section under Advanced Configuration
- Include methods for finding image digests (docker inspect, skopeo)
- Document two usage options:
  - Option 1: Digest with tag (recommended) for reference + verification
  - Option 2: Digest only for minimalist configuration
- Explain security benefits (immutability, supply chain security, reproducibility)
- Provide complete helm and values.yaml examples
2026-02-16 14:25:47 +01:00
Maksim Eltyshev
b9967feeea fix(socket): Handle transport error during reconnection after idle 2026-02-16 10:50:09 +01:00
Maksim Eltyshev
dbcdd62bdf docs: Update readme 2026-02-12 16:03:53 +01:00
Maksim Eltyshev
ff4177f27a chore: Fix index view extension in ignore files 2026-02-11 23:00:45 +01:00
Maksim Eltyshev
f68e7d156e chore: Fix data path in docker-restore script 2026-02-11 22:58:40 +01:00
Maksim Eltyshev
bda32e0247 chore: Update version 2026-02-11 14:34:56 +01:00
Maksim Eltyshev
168776aef8 chore: Bump terms 2026-02-11 14:03:37 +01:00
Maksim Eltyshev
d84b615815 ci: Fix sed when updating Dockerfile to use prebuilt client 2026-02-11 13:08:06 +01:00
Maksim Eltyshev
16b228e54e fix: Prevent collisions when generating local ids 2026-02-11 11:59:57 +01:00
Maksim Eltyshev
0db1a5cf0e chore: Support multiple term types 2026-02-10 22:53:22 +01:00
Maksim Eltyshev
1d0b3e9af8 ci: Fix path to terms 2026-02-10 21:49:33 +01:00
Maksim Eltyshev
dabdedf6c4 ref: Little refactoring 2026-02-10 21:43:58 +01:00
Maksim Eltyshev
a82ae49fe4 chore: Unify term types 2026-02-10 21:14:56 +01:00
Hayden Welch
e7326303cd docs: Validate Swagger docs (#1486) 2026-02-10 19:35:39 +01:00
Maksim Eltyshev
b2e3aac314 chore: Upgrade data structure only if S3 is enabled 2026-02-10 18:52:25 +01:00
Maksim Eltyshev
8c4859fed5 chore: Sync upgrade script with latest changes 2026-02-10 18:34:26 +01:00
Maksim Eltyshev
e219e5be42 ci: Restore latest tag 2026-02-10 15:31:05 +01:00
Maksim Eltyshev
7d613dc171 docs: Bump last updates 2026-02-10 15:00:19 +01:00
Maksim Eltyshev
d63cc28f23 ci: Optimize Docker image build by prebuilding client 2026-02-09 21:48:44 +01:00
Maksim Eltyshev
8898a0f0a2 fix: Block unsupported local Apprise schemas 2026-02-09 13:35:04 +01:00
Maksim Eltyshev
538280d197 feat: Add configurable proxy for outgoing traffic to prevent SSRF 2026-02-09 13:33:27 +01:00
Maksim Eltyshev
aa3ebd5add feat: Apply color to entire list instead of card bottoms 2026-02-08 00:06:32 +01:00
Maksim Eltyshev
f8cd7474d1 chore: Update client dependencies 2026-02-04 17:52:04 +01:00
Maksim Eltyshev
450bd875c1 chore: Update server dependencies 2026-02-04 12:53:15 +01:00
Maksim Eltyshev
8df5a111bf fix: Forward headers correctly when reading from S3 2026-02-04 11:34:47 +01:00
Maksim Eltyshev
3c33161df6 fix: Fix server error when fetching custom field group
Closes #1507
2026-02-01 00:14:07 +01:00
Maksim Eltyshev
052edc9fb1 feat: Migrate file storage to unified data directory 2026-01-31 20:27:15 +01:00
Maksim Eltyshev
6335b3bd3c fix: Enhance response headers for file attachments 2026-01-30 21:53:53 +01:00
Maksim Eltyshev
db99227f32 feat: Re-stream static files from S3, introduce protected static files 2026-01-30 21:45:18 +01:00
Maksim Eltyshev
a5dc0a64ac build: Upgrade pip at build time, bump node-redis and apprise 2026-01-29 13:21:53 +01:00
Maksim Eltyshev
b37ca68d61 chore: Auto-update internal version for nightly build, improve version display in UI 2026-01-29 00:24:57 +01:00
Maksim Eltyshev
31dd816e30 docs: Clarify nested pagination cursor in Swagger 2026-01-28 16:53:33 +01:00
Maksim Eltyshev
d688a64e36 feat: Add OIDC debug mode 2026-01-27 22:34:08 +01:00
Maksim Eltyshev
2c4369159b fix: Hide unlinking SSO when OIDC is enforced 2026-01-26 22:21:27 +01:00
Maksim Eltyshev
b2da1a5e18 docs: Add missing details to Swagger 2026-01-26 21:44:00 +01:00
Maksim Eltyshev
2b699f77f4 feat: Add ability to unlink SSO from user 2026-01-26 21:18:32 +01:00
Maksim Eltyshev
ee917c545c chore: Update dependencies 2026-01-26 14:11:20 +01:00
Maksim Eltyshev
3b8ad26169 feat: Add ability to pre-fill login credentials in demo mode 2026-01-25 20:17:13 +01:00
Maksim Eltyshev
ffdb7254b3 fix: Enable favorites by default 2026-01-25 18:37:56 +01:00
Maksim Eltyshev
b52604045d fix: Correctly track password field modification in SMTP pane 2026-01-23 20:32:23 +01:00
Maksim Eltyshev
267fce0505 fix: Prevent deactivated users from receiving socket events when possible 2026-01-23 19:38:58 +01:00
Maksim Eltyshev
6ec0bafecb fix: Prevent sending notifications to deactivated users 2026-01-23 17:20:12 +01:00
Maksim Eltyshev
b2c4c530c6 fix: Fix variable name in seeder 2026-01-23 11:42:40 +01:00
Maksim Eltyshev
59da747e75 docs: Add Notes app testing invitation 2026-01-23 11:37:29 +01:00
Maksim Eltyshev
1264fd5715 feat: Add internal runtime configuration 2026-01-22 18:02:42 +01:00
JoeKer1
b852481850 feat: Add support for sidecar containers in Helm (#1494) 2026-01-22 10:14:02 +01:00
dependabot[bot]
c88fd340bc build(deps): Bump lodash-es from 4.17.22 to 4.17.23 in /client (#1495)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.22 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/commits/4.17.23)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:06:57 +01:00
dependabot[bot]
4200bfca3a build(deps): Bump lodash from 4.17.21 to 4.17.23 in /server (#1492)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:06:44 +01:00
dependabot[bot]
7b1be0db27 build(deps): Bump lodash from 4.17.21 to 4.17.23 in /client (#1491)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:06:36 +01:00
dependabot[bot]
3847b69f4c build(deps): Bump lodash from 4.17.21 to 4.17.23 (#1490)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:06:24 +01:00
dependabot[bot]
5f8fde1354 build(deps): Bump tar and bcrypt in /server (#1484)
Removes [tar](https://github.com/isaacs/node-tar). It's no longer used after updating ancestor dependency [bcrypt](https://github.com/kelektiv/node.bcrypt.js). These dependencies need to be updated together.


Removes `tar`

Updates `bcrypt` from 5.1.1 to 6.0.0
- [Release notes](https://github.com/kelektiv/node.bcrypt.js/releases)
- [Changelog](https://github.com/kelektiv/node.bcrypt.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kelektiv/node.bcrypt.js/compare/v5.1.1...v6.0.0)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 
  dependency-type: indirect
- dependency-name: bcrypt
  dependency-version: 6.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 20:45:50 +01:00
Maksim Eltyshev
1f4f439f4c feat: Add demo mode with restricted user actions 2026-01-14 14:11:21 +01:00
Maksim Eltyshev
5636c2bb79 chore: Update dependencies 2026-01-14 13:22:11 +01:00
Maksim Eltyshev
e3e55b2c26 fix: Disable fuzzy links in markdown editor
Closes #1480
2026-01-14 13:00:29 +01:00
Maksim Eltyshev
e1c1719414 chore: Update Apprise link 2026-01-13 18:39:27 +01:00
Maksim Eltyshev
a60f8e3c3e chore: Rename for consistency 2026-01-13 17:52:31 +01:00
dependabot[bot]
f4a24a3c1f build(deps): Bump @remix-run/router and react-router-dom in /client (#1479)
Bumps [@remix-run/router](https://github.com/remix-run/react-router/tree/HEAD/packages/router) to 1.23.2 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `@remix-run/router` from 1.23.1 to 1.23.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/@remix-run/router@1.23.2/packages/router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@remix-run/router@1.23.2/packages/router)

Updates `react-router-dom` from 6.30.2 to 6.30.3
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.30.3/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: "@remix-run/router"
  dependency-version: 1.23.2
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 6.30.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 05:42:39 +01:00
dependabot[bot]
cadb013527 build(deps): Bump qs from 6.14.0 to 6.14.1 in /client (#1469) 2026-01-02 07:04:51 +01:00
HUi
af8865327a feat: Add Vietnamese translation (#1459) 2025-12-19 20:17:34 +01:00
Maksim Eltyshev
208e61a272 feat: Optimize and parallel image processing 2025-12-19 19:11:02 +01:00
Maksim Eltyshev
fb5d5233bf feat: Improve mime type detection, limit image processing size 2025-12-19 17:24:22 +01:00
Maksim Eltyshev
9bf7e9de3d fix: Align markdown quote border color 2025-12-19 13:37:09 +01:00
Maksim Eltyshev
a783bfa5a7 fix: Align markdown colors 2025-12-19 13:08:40 +01:00
Maksim Eltyshev
bd10a7fa5a chore: Update dependencies 2025-12-19 12:32:10 +01:00
Maksim Eltyshev
4f5f5ddad4 feat: Display last updates in About modal 2025-12-17 15:49:33 +01:00
Maksim Eltyshev
27fd10ba3f fix: Rename Information menu item to About 2025-12-12 21:09:44 +01:00
Maksim Eltyshev
a47bdb9c8a feat: Move About and Terms into separate modal 2025-12-12 16:10:07 +01:00
Maksim Eltyshev
12f388fd35 fix: Fix attachment file handling when S3 is enabled 2025-12-11 19:26:55 +01:00
Maksim Eltyshev
869a6b4c00 fix: Complete missing markdown editor translations 2025-12-11 12:46:14 +01:00
Maksim Eltyshev
7f6978ae9e fix: Prevent attachment gallery content from going out of bounds
Closes #1349
2025-12-11 12:11:34 +01:00
Maksim Eltyshev
ae9517b37a fix: Prevent clearing linked tasks when soft deleting 2025-12-09 21:23:27 +01:00
Maksim Eltyshev
6da5c7aaca feat: Enhance card actions menu with separators and action bar 2025-12-09 20:30:05 +01:00
Maksim Eltyshev
9e6e38fcf7 feat: Add ability to copy/cut cards with shortcut support 2025-12-09 14:58:01 +01:00
Maksim Eltyshev
52acc9de90 feat: Allow setting customer panel URL via environment variables 2025-12-08 21:25:13 +01:00
Maksim Eltyshev
26200754f3 fix: Improve login page appearance 2025-12-08 21:08:51 +01:00
Maksim Eltyshev
95ae3affd9 Merge branch 'master' of github.com:plankanban/planka 2025-12-05 10:06:49 +01:00
Maksim Eltyshev
19c201cb1b chore: Bump apprise 2025-12-05 10:06:45 +01:00
dependabot[bot]
d472da7523 build(deps): Bump jws from 3.2.2 to 3.2.3 in /server (#1449)
Bumps [jws](https://github.com/brianloveswords/node-jws) from 3.2.2 to 3.2.3.
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.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-12-04 18:16:18 +01:00
Maksim Eltyshev
f030b78f82 feat: Add object-path support to OIDC attribute mapping
Closes #1359
2025-12-04 17:38:39 +01:00
dependabot[bot]
b94759d399 build(deps): Bump nodemailer from 7.0.10 to 7.0.11 in /server (#1448)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 7.0.10 to 7.0.11.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v7.0.10...v7.0.11)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 7.0.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 16:23:31 +01:00
Maksim Eltyshev
fedee157c7 build: Refine server build steps 2025-11-28 17:45:31 +01:00
Maksim Eltyshev
68e5c33418 fix: Improve browser caching for public files 2025-11-28 13:14:25 +01:00
Maksim Eltyshev
f7a09b2eca license: Improve clarity of commercial license terms 2025-11-28 00:18:29 +01:00
Maksim Eltyshev
c058df8fc4 fix: Rename GIN indexes 2025-11-27 19:31:11 +01:00
Maksim Eltyshev
0023c63be8 fix: Optimize query methods 2025-11-27 19:09:10 +01:00
Maksim Eltyshev
26b3cffdab fix: Rename getCards inputs for consistency 2025-11-27 18:41:50 +01:00
Maksim Eltyshev
bf2ab4649e fix: Create isolated i18n instances to prevent locale collision 2025-11-27 18:28:25 +01:00
Maksim Eltyshev
54e230d4c1 ref: Refactoring 2025-11-27 18:24:55 +01:00
Maksim Eltyshev
7be2343076 chore: Add Swagger generation script 2025-11-25 22:08:37 +01:00
Maksim Eltyshev
c646f0f5b3 fix: Increase max username length
Closes #1441
2025-11-25 15:03:35 +01:00
Maksim Eltyshev
8a288d1816 fix: Prevent shortcuts from triggering in actions popup 2025-11-24 21:46:32 +01:00
Maksim Eltyshev
197ebc16db chore: Bump package lock files 2025-11-24 19:48:30 +01:00
Maksim Eltyshev
5c787d65a9 fix: Properly prevent shortcut events, update dependencies
Closes #1440
2025-11-24 19:21:07 +01:00
Samar Khajuria
7e41a0167d feat: Add create-board button to open-board screen (#1438)
Closes #1435
2025-11-24 15:45:50 +01:00
Maksim Eltyshev
c68ab99bfe feat: Extend card action shortcuts 2025-11-24 15:02:26 +01:00
Serge Zaitsev
561d2c77d4 feat: Add basic shortcuts (#1436) 2025-11-24 12:35:31 +01:00
Maksim Eltyshev
6e44b2ad19 chore: Restore license header 2025-11-19 18:20:54 +01:00
Maksim Eltyshev
cd5685049e fix: Log errors when sending notifications via Apprise 2025-11-19 18:19:31 +01:00
dependabot[bot]
015562d8da build(deps): Bump glob from 10.4.5 to 10.5.0 in /client (#1432) 2025-11-18 21:23:34 +01:00
dependabot[bot]
7da8d8219b build(deps-dev): Bump js-yaml from 3.14.1 to 3.14.2 in /client (#1431)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.1 to 3.14.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 21:11:18 +01:00
dependabot[bot]
4ed0f02657 build(deps-dev): Bump js-yaml from 4.1.0 to 4.1.1 in /server (#1429)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 21:11:08 +01:00
Shanick
0736b8fea0 fix: Make date command cross-platform in backup script (#1409) 2025-11-18 21:10:24 +01:00
Roger Clotet
7a05bf2a30 feat: Add Catalan (ca-ES) translation (#1424) 2025-11-12 19:19:29 +01:00
Maksim Eltyshev
d7cb2087e5 fix: Adjust margins in menu-styled popups 2025-11-06 23:15:57 +01:00
Maksim Eltyshev
22baf5ab12 feat: Ghost error page for improved error display 2025-11-06 23:11:06 +01:00
Steve Váradi
33a7a6ccb1 chore: Add chart to feature request template (#1414) 2025-11-06 21:03:43 +01:00
Samuel
b4cbd32bf2 feat: Add API key authentication (#1254)
Closes #945
2025-11-06 20:56:48 +01:00
Steve Váradi
5a2564f575 fix: Add missing keys to chart values file (#1401) 2025-10-31 13:13:32 +01:00
Maksim Eltyshev
3edcc5ae61 fix: Update Russian translation 2025-10-31 13:00:30 +01:00
dependabot[bot]
d99e949d07 build(deps): Bump validator from 13.15.15 to 13.15.20 in /client (#1403)
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.15 to 13.15.20.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.15.15...13.15.20)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.20
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-31 12:57:38 +01:00
dependabot[bot]
f5000e8c40 build(deps): Bump validator from 13.15.15 to 13.15.20 in /server (#1402)
Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.15 to 13.15.20.
- [Release notes](https://github.com/validatorjs/validator.js/releases)
- [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/validatorjs/validator.js/compare/13.15.15...13.15.20)

---
updated-dependencies:
- dependency-name: validator
  dependency-version: 13.15.20
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-31 12:53:41 +01:00
dependabot[bot]
d385a1c238 build(deps): Bump vite from 6.3.6 to 6.4.1 in /client (#1395)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.6 to 6.4.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-31 12:53:31 +01:00
dependabot[bot]
0d8edbbbc6 build(deps-dev): Bump playwright and @playwright/test in /client (#1393)
Bumps [playwright](https://github.com/microsoft/playwright) and [@playwright/test](https://github.com/microsoft/playwright). These dependencies needed to be updated together.

Updates `playwright` from 1.55.0 to 1.56.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.55.0...v1.56.1)

Updates `@playwright/test` from 1.55.0 to 1.56.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.55.0...v1.56.1)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.56.1
  dependency-type: direct:development
- dependency-name: "@playwright/test"
  dependency-version: 1.56.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-31 12:53:22 +01:00
Maksim Eltyshev
04e5124dfd fix: Complete missing markdown editor translations 2025-10-30 20:17:59 +01:00
Maksim Eltyshev
3a84fb2d52 fix: Complete missing server translations 2025-10-30 19:10:33 +01:00
Maksim Eltyshev
232a760a3d fix: Prevent popup header text from wrapping to new line 2025-10-30 18:40:25 +01:00
Maksim Eltyshev
2f873dc82f fix: Correct copy_inline translations to use noun form 2025-10-30 18:03:27 +01:00
Maksim Eltyshev
c35e720265 fix: Complete missing client translations, improve consistency 2025-10-30 17:03:46 +01:00
Maksim Eltyshev
fee300908c fix: Set detected language on first login, remove auto-detect language option 2025-10-15 00:08:29 +02:00
Maksim Eltyshev
20d8f8ae53 fix: Make model-level username regex less strict
Closes #1370
2025-10-14 23:47:31 +02:00
Maksim Eltyshev
39f970689a chore: Little tidy up 2025-10-14 18:52:09 +02:00
475 changed files with 24011 additions and 12560 deletions

View File

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

View File

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

View File

@@ -21,6 +21,14 @@ jobs:
- name: Update npm
run: npm install npm --global
- name: Install server dependencies
run: npm install --omit=prod --ignore-scripts
working-directory: ./server
- name: Build server
run: npm run build
working-directory: ./server
- name: Install client dependencies
run: npm install --omit=dev
working-directory: ./client
@@ -29,24 +37,25 @@ jobs:
run: DISABLE_ESLINT_PLUGIN=true npm run build
working-directory: ./client
- name: Include server into dist
run: mv server dist
- name: Include licenses into dist
run: |
mv LICENSE.md server/dist
mv "LICENSES/PLANKA Community License DE.md" server/dist/LICENSE_DE.md
- name: Include built client into dist
run: |
mv dist/* ../dist/public
cp ../dist/public/index.html ../dist/views
working-directory: ./client
- name: Include LICENSE.md, README.md, SECURITY.md into dist
run: mv LICENSE.md README.md SECURITY.md dist
mv ../../client/dist/* public
cp public/index.html views
working-directory: ./server/dist
- name: Create release package
run: |
mv dist planka
zip -r planka-prebuild.zip planka
working-directory: ./server
- name: Publish release package
run: gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
working-directory: ./server

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ jobs:
- name: Seed database with terms signature
run: |
TERMS_SIGNATURE=$(sha256sum terms/en-US/extended.md | awk '{print $1}')
TERMS_SIGNATURE=$(sha256sum terms/_template/en-US.md | awk '{print $1}')
PGPASSWORD=$POSTGRES_PASSWORD psql -h localhost -U $POSTGRES_USERNAME -d $POSTGRES_DATABASE -c "UPDATE user_account SET terms_signature = '$TERMS_SIGNATURE';"
working-directory: ./server

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

@@ -68,7 +68,7 @@ helm install planka . --set secretkey=$SECRETKEY \
or create a values.yaml file like:
```yaml
````yaml
secretkey: "<InsertSecretKey>"
# The admin section needs to be present for new instances of PLANKA, after the first start you can remove the lines starting with admin_. If you want the admin user to be unchangeable admin_email: has to stay
# After changing the config you have to run ```helm upgrade planka . -f values.yaml```
@@ -89,11 +89,11 @@ ingress:
- path: /
pathType: ImplementationSpecific
# Needed for HTTPS
# Needed for HTTPS
tls:
- secretName: planka-tls # existing TLS secret in k8s
hosts:
- planka.example.dev
- secretName: planka-tls # existing TLS secret in k8s
hosts:
- planka.example.dev
```
```bash
@@ -114,7 +114,7 @@ Any questions or concerns, [raise an issue](https://github.com/Chris-Greaves/pla
### Extra Volume Mounts
The Helm chart supports mounting arbitrary ConfigMaps, Secrets, and Volumes to the Planka deployment using the `extraMounts` configuration. This is especially useful for scenarios like:
The Helm chart supports mounting arbitrary ConfigMaps, Secrets, and Volumes to the PLANKA deployment using the `extraMounts` configuration. This is especially useful for scenarios like:
- Mounting custom CA certificates for OIDC with self-hosted identity providers
- Adding custom configuration files
@@ -135,27 +135,27 @@ extraMounts:
subPath: ca.crt
readOnly: true
configMap:
name: ca-certificates # Must exist
name: ca-certificates # Must exist
# Mount TLS certificates from existing Secret
- name: tls-certs
mountPath: /etc/ssl/private
readOnly: true
secret:
secretName: planka-tls-secret # Must exist
secretName: planka-tls-secret # Must exist
items:
- key: tls.crt
path: server.crt
- key: tls.key
path: server.key
# Temporary storage
- name: temp-storage
mountPath: /tmp/planka-temp
readOnly: false
emptyDir:
sizeLimit: 1Gi
# Host path mount
- name: backup-storage
mountPath: /var/lib/planka-backups
@@ -163,7 +163,7 @@ extraMounts:
hostPath:
path: /var/lib/planka-backups
type: DirectoryOrCreate
# NFS mount
- name: nfs-storage
mountPath: /shared/data
@@ -175,14 +175,16 @@ extraMounts:
### OIDC with Self-Hosted Keycloak
A common use case is configuring OIDC with a self-hosted Keycloak instance that uses custom CA certificates.
A common use case is configuring OIDC with a self-hosted Keycloak instance that uses custom CA certificates.
First, create the CA certificate ConfigMap:
```bash
kubectl create configmap ca-certificates --from-file=ca.crt=/path/to/your/ca.crt
```
Then configure the chart:
```yaml
# Mount custom CA certificate from existing ConfigMap
extraMounts:
@@ -225,6 +227,67 @@ extraEnv:
key: api-key
```
### Image Digest Pinning
For enhanced security and reproducibility, you can pin the container image using its SHA256 digest instead of relying solely on tags. This ensures you always deploy the exact same image, preventing tag mutations or accidental updates.
#### Finding the Image Digest
You can find the digest of a specific image tag using:
```bash
docker inspect ghcr.io/plankanban/planka:latest --format='{{index .RepoDigests 0}}'
# Output: ghcr.io/plankanban/planka@sha256:abc123def456...
# Or with skopeo
skopeo inspect docker://ghcr.io/plankanban/planka:latest
```
#### Usage
You can use digest pinning in several ways:
**Option 1: Digest with tag (recommended)**
Includes the tag for reference while using the digest for verification:
```bash
helm install planka . --set secretkey=$SECRETKEY \
--set image.tag=latest \
--set image.digest=abc123def456... \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo"
```
Or in values.yaml:
```yaml
image:
repository: ghcr.io/plankanban/planka
tag: latest
digest: "abc123def456ab89cd12ef34ab56cd78ef90ab12cd34ef56ab78cd90ef12ab34"
```
**Option 2: Digest only**
If you prefer to pin only by digest without specifying a tag:
```yaml
image:
repository: ghcr.io/plankanban/planka
tag: "" # Empty - digest alone identifies the image
digest: "abc123def456ab89cd12ef34ab56cd78ef90ab12cd34ef56ab78cd90ef12ab34"
```
#### Security Benefits
- **Immutability**: Ensures you always deploy the exact same image
- **Supply Chain Security**: Protects against tag mutations or registry compromise
- **Reproducibility**: Makes deployments fully reproducible across environments
- **Audit Trail**: Provides clear image identity in deployment manifests
### Complete Example
See `values-example.yaml` for a comprehensive example that demonstrates all the advanced features including OIDC configuration with custom CA certificates.

View File

@@ -39,7 +39,16 @@ spec:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
{{- $imageTag := .Values.image.tag | default .Chart.AppVersion }}
{{- if .Values.image.digest }}
{{- if $imageTag }}
image: "{{ .Values.image.repository }}:{{ $imageTag }}@sha256:{{ .Values.image.digest }}"
{{- else }}
image: "{{ .Values.image.repository }}@sha256:{{ .Values.image.digest }}"
{{- end }}
{{- else }}
image: "{{ .Values.image.repository }}:{{ $imageTag }}"
{{- end }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
@@ -54,22 +63,19 @@ spec:
path: /
port: http
volumeMounts:
- mountPath: /app/public/favicons
subPath: favicons
name: planka
- mountPath: /app/public/user-avatars
subPath: user-avatars
name: planka
- mountPath: /app/public/background-images
subPath: background-images
name: planka
- mountPath: /app/private/attachments
subPath: attachments
- mountPath: /app/data
subPath: data
name: planka
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- mountPath: /app/logs
subPath: app-logs
name: emptydir
- mountPath: /app/.tmp
subPath: app-tmp
name: emptydir
- mountPath: /tmp
subPath: tmp
name: emptydir
{{- end }}
{{- /* Extra volume mounts */}}
{{- range .Values.extraMounts }}
@@ -190,6 +196,9 @@ spec:
value: {{ .Values.oidc.admin.ignoreRoles | quote }}
{{- end }}
{{- end }}
{{- if .Values.extraContainers -}}
{{ toYaml .Values.extraContainers | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}

View File

@@ -9,6 +9,10 @@ image:
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
# Optional: specify the image digest for pinning by SHA256
# When set, the image reference will include the digest for enhanced security
# Example: "abc123def456..." (without sha256: prefix)
digest: ""
imagePullSecrets: []
nameOverride: ""
@@ -27,6 +31,11 @@ existingSecretkeySecret: ""
## NOTE: When it's set, the `admin_username` and `admin_password` parameters are ignored
existingAdminCredsSecret: ""
admin_email: ""
admin_password: ""
admin_name: ""
admin_username: ""
# Base url for PLANKA. Will override `ingress.hosts[0].host`
# Defaults to `http://localhost:3000` if ingress is disabled.
baseUrl: ""
@@ -240,7 +249,7 @@ extraEnv: []
## value: "your_email@example.com"
## Extra volume mounts configuration
## Mount ConfigMaps, Secrets, and arbitrary volumes to the Planka container
## Mount ConfigMaps, Secrets, and arbitrary volumes to the PLANKA container
## This allows mounting any pre-existing ConfigMaps, Secrets, or other volume types
##
extraMounts: []
@@ -281,3 +290,23 @@ extraMounts: []
## extraEnv:
## - name: NODE_EXTRA_CA_CERTS
## value: "/etc/ssl/certs/keycloak-ca.crt"
extraContainers: []
## Extra sidecar containers
## Add additional containers to the PLANKA pod
##
## Example extraContainers:
## extraContainers:
## - name: nginx-sidecar
## image: nginx:latest
## ports:
## - containerPort: 8085
## name: nginx-http
## - name: log-collector
## image: busybox:latest
## command: ['sh', '-c', 'tail -f /var/log/app.log']
## volumeMounts:
## - name: planka
## mountPath: /var/log
## subPath: app-logs
##

6345
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js b/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
index 0e615c4..383338d 100644
index dec01f0..a80b857 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
@@ -101,7 +101,6 @@ export const BundlePreset = (builder, opts) => {
@@ -102,7 +102,6 @@ export const BundlePreset = (builder, opts) => {
enableNewImageSizeCalculation: opts.enableNewImageSizeCalculation,
...opts.imgSize,
},
@@ -10,10 +10,10 @@ index 0e615c4..383338d 100644
deflist: {
deflistTermPlaceholder: () => i18nPlaceholder('deflist_term'),
deflistDescPlaceholder: () => i18nPlaceholder('deflist_desc'),
@@ -118,11 +117,6 @@ export const BundlePreset = (builder, opts) => {
...opts.yfmNote,
@@ -123,11 +122,6 @@ export const BundlePreset = (builder, opts) => {
...opts.yfmTable,
controls: opts.mobile ? false : opts.yfmTable?.controls,
},
yfmTable: { yfmTableCellPlaceholder: () => i18nPlaceholder('table_cell'), ...opts.yfmTable },
- yfmFile: {
- fileUploadHandler: opts.fileUploadHandler,
- needToSetDimensionsForUploadedImages: opts.needToSetDimensionsForUploadedImages,
@@ -22,6 +22,20 @@ index 0e615c4..383338d 100644
yfmHeading: {
h1Key: f.toPM(A.Heading1),
h2Key: f.toPM(A.Heading2),
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js b/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js
index 8aefe20..99e59e3 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/core/ExtensionsManager.js
@@ -42,6 +42,9 @@ export class ExtensionsManager {
if (options.linkifyTlds) {
this.#mdForMarkup.linkify.tlds(options.linkifyTlds, true);
this.#mdForText.linkify.tlds(options.linkifyTlds, true);
+ } else if (options.linkifyTlds === null) {
+ this.#mdForMarkup.linkify.set({ fuzzyLink: false });
+ this.#mdForText.linkify.set({ fuzzyLink: false });
}
if (options.pmTransformers) {
this.#pmTransformers = options.pmTransformers;
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js b/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js
index 212c583..b709383 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js

View File

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

View File

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

17
client/src/actions/bootstrap.js vendored Normal file
View File

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

View File

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

View File

@@ -5,6 +5,7 @@
import router from './router';
import socket from './socket';
import bootstrap from './bootstrap';
import login from './login';
import core from './core';
import modals from './modals';
@@ -34,6 +35,7 @@ import notificationServices from './notification-services';
export default {
...router,
...socket,
...bootstrap,
...login,
...core,
...modals,

View File

@@ -54,6 +54,13 @@ authenticateWithOidc.failure = (error, terms) => ({
},
});
authenticateWithOidc.debug = (logs) => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE__DEBUG,
payload: {
logs,
},
});
const clearAuthenticateError = () => ({
type: ActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {},

View File

@@ -5,6 +5,13 @@
import ActionTypes from '../constants/ActionTypes';
const handleUsersReset = (users) => ({
type: ActionTypes.USERS_RESET_HANDLE,
payload: {
users,
},
});
const createUser = (data) => ({
type: ActionTypes.USER_CREATE,
payload: {
@@ -235,6 +242,58 @@ updateUserAvatar.failure = (id, error) => ({
},
});
const createUserApiKey = (id) => ({
type: ActionTypes.USER_API_KEY_CREATE,
payload: {
id,
},
});
createUserApiKey.success = (user, apiKey) => ({
type: ActionTypes.USER_API_KEY_CREATE__SUCCESS,
payload: {
user,
apiKey,
},
});
createUserApiKey.failure = (id, error) => ({
type: ActionTypes.USER_API_KEY_CREATE__FAILURE,
payload: {
id,
error,
},
});
const deleteUserApiKey = (id) => ({
type: ActionTypes.USER_API_KEY_DELETE,
payload: {
id,
},
});
deleteUserApiKey.success = (user) => ({
type: ActionTypes.USER_API_KEY_DELETE__SUCCESS,
payload: {
user,
},
});
deleteUserApiKey.failure = (id, error) => ({
type: ActionTypes.USER_API_KEY_DELETE__FAILURE,
payload: {
id,
error,
},
});
const clearUserApiKeyValue = (id) => ({
type: ActionTypes.USER_API_KEY_VALUE_CLEAR,
payload: {
id,
},
});
const deleteUser = (id) => ({
type: ActionTypes.USER_DELETE,
payload: {
@@ -347,6 +406,7 @@ const removeUserFromBoardFilter = (id, boardId, currentListId) => ({
});
export default {
handleUsersReset,
createUser,
handleUserCreate,
clearUserCreateError,
@@ -359,6 +419,9 @@ export default {
updateUserUsername,
clearUserUsernameUpdateError,
updateUserAvatar,
createUserApiKey,
deleteUserApiKey,
clearUserApiKeyValue,
deleteUser,
handleUserDelete,
addUserToCard,

View File

@@ -13,6 +13,8 @@ const createAccessToken = (data, headers) =>
const exchangeForAccessTokenWithOidc = (data, headers) =>
http.post('/access-tokens/exchange-with-oidc?withHttpOnlyToken=true', data, headers);
const debugOidc = (data, headers) => http.post('/access-tokens/debug-oidc', data, headers);
// TODO: rename?
const acceptTerms = (data, headers) => http.post('/access-tokens/accept-terms', data, headers);
@@ -24,6 +26,7 @@ const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', u
export default {
createAccessToken,
exchangeForAccessTokenWithOidc,
debugOidc,
acceptTerms,
revokePendingToken,
deleteCurrentAccessToken,

View File

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

View File

@@ -7,8 +7,8 @@ import http from './http';
/* Actions */
const getTerms = (type, language, headers) =>
http.get(`/terms/${type}${language ? `?language=${language}` : ''}`, undefined, headers);
const getTerms = (language, headers) =>
http.get(`/terms${language ? `?language=${language}` : ''}`, undefined, headers);
export default {
getTerms,

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
}
.menu {
margin: -7px -12px -5px;
margin: 0 -12px -5px;
width: calc(100% + 24px);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
}
.menu {
margin: -7px -12px -5px;
margin: 0 -12px -5px;
width: calc(100% + 24px);
}

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
}

View File

@@ -3,9 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -14,24 +12,23 @@ import { push } from '../../../lib/redux-router';
import { closePopup, usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
import { BoardShortcutsContext } from '../../../contexts';
import Paths from '../../../constants/Paths';
import ClipboardTypes from '../../../constants/ClipboardTypes';
import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
import ProjectContent from './ProjectContent';
import StoryContent from './StoryContent';
import InlineContent from './InlineContent';
import EditName from './EditName';
import ActionsStep from './ActionsStep';
import CardActionsStep from '../CardActionsStep';
import styles from './Card.module.scss';
import globalStyles from '../../../styles.module.scss';
const Card = React.memo(({ id, isInline }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectIsCardWithIdRecent = useMemo(() => selectors.makeSelectIsCardWithIdRecent(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const card = useSelector((state) => selectCardById(state, id));
const list = useSelector((state) => selectListById(state, card.listId));
const isHighlightedAsRecent = useSelector((state) => {
const { turnOffRecentCardHighlighting } = selectors.selectCurrentUser(state);
@@ -43,13 +40,25 @@ const Card = React.memo(({ id, isInline }) => {
return selectIsCardWithIdRecent(state, id);
});
const isCut = useSelector((state) => {
const clipboard = selectors.selectClipboard(state);
return clipboard && clipboard.type === ClipboardTypes.CUT && card.id === clipboard.cardId;
});
const canUseActions = useSelector((state) => {
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
if (isManager) {
return true;
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const dispatch = useDispatch();
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
const [, , handleCardMouseEnter, handleCardMouseLeave] = useContext(BoardShortcutsContext);
const actionsPopupRef = useRef(null);
@@ -61,6 +70,22 @@ const Card = React.memo(({ id, isInline }) => {
dispatch(push(Paths.CARDS.replace(':id', id)));
}, [id, dispatch]);
const handleMouseEnter = useCallback(() => {
handleCardMouseEnter(
id,
() => {
setIsEditNameOpened(true);
},
(step) => {
closePopup();
actionsPopupRef.current.open({
defaultStep: step,
});
},
);
}, [id, handleCardMouseEnter]);
const handleContextMenu = useCallback((event) => {
if (!actionsPopupRef.current) {
return;
@@ -80,7 +105,7 @@ const Card = React.memo(({ id, isInline }) => {
setIsEditNameOpened(false);
}, []);
const ActionsPopup = usePopup(ActionsStep);
const CardActionsPopup = usePopup(CardActionsStep);
if (isEditNameOpened) {
return <EditName cardId={id} onClose={handleEditNameClose} />;
@@ -103,15 +128,6 @@ const Card = React.memo(({ id, isInline }) => {
}
}
const colorLineNode = list.color && (
<div
className={classNames(
styles.colorLine,
globalStyles[`background${upperFirst(camelCase(list.color))}`],
)}
/>
);
return (
<div
className={classNames(styles.wrapper, isHighlightedAsRecent && styles.wrapperRecent, 'card')}
@@ -121,25 +137,29 @@ const Card = React.memo(({ id, isInline }) => {
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */}
<div
className={classNames(styles.content, card.isClosed && styles.contentDisabled)}
className={classNames(
styles.content,
card.isClosed && styles.contentDisabled,
isCut && styles.contentCut,
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleCardMouseLeave}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<Content cardId={id} />
{colorLineNode}
</div>
{canUseActions && (
<ActionsPopup ref={actionsPopupRef} cardId={id} onNameEdit={handleNameEdit}>
<CardActionsPopup ref={actionsPopupRef} cardId={id} onNameEdit={handleNameEdit}>
<Button className={styles.actionsButton}>
<Icon fitted name="pencil" size="small" />
</Button>
</ActionsPopup>
</CardActionsPopup>
)}
</>
) : (
<span className={classNames(styles.content, card.isClosed && styles.contentDisabled)}>
<Content cardId={id} />
{colorLineNode}
</span>
)}
</div>

View File

@@ -21,6 +21,7 @@
top: 2px;
transition: background 85ms ease;
width: 20px;
z-index: 1000;
&:hover {
background: #ebeef0;
@@ -28,11 +29,6 @@
}
}
.colorLine {
border-radius: 0 0 3px 3px;
height: 4px;
}
.content {
cursor: pointer;
@@ -43,6 +39,10 @@
}
}
.contentCut {
opacity: 0.5;
}
.contentDisabled {
filter: saturate(0.5);
opacity: 0.64;
@@ -52,6 +52,7 @@
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 0 #ccc;
overflow: hidden;
position: relative;
&:hover {

View File

@@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../../selectors';

View File

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

View File

@@ -0,0 +1,62 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.actionBar {
display: flex;
margin: -7px 0;
}
.actionBarItem {
color: rgba(0, 0, 0, 0.87);
cursor: pointer;
display: flex;
flex: 1;
flex-direction: column;
font-size: 13px;
padding: 6px;
row-gap: 6px;
user-select: none;
&:not(:last-child) {
border-right: 1px solid #eee;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.95);
}
}
.actionBarItemText {
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
overflow: hidden;
text-align: center;
-webkit-box-orient: vertical;
}
.divider {
background: #eee;
border: 0;
height: 1px;
}
.menu {
margin: 0 -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
}

View File

@@ -0,0 +1,3 @@
import CardActionsStep from './CardActionsStep';
export default CardActionsStep;

View File

@@ -132,7 +132,11 @@ const AddAttachmentZone = React.memo(({ children }) => {
<>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<div {...getRootProps()}>
{isDragActive && <div className={styles.dropzone}>{t('common.dropFileToUpload')}</div>}
{isDragActive && (
<div className={styles.dropzone}>
<div className={styles.dropzoneText}>{t('common.dropFileToUpload')}</div>
</div>
)}
{children}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<input {...getInputProps()} />

View File

@@ -8,13 +8,18 @@
background: white;
font-size: 20px;
font-weight: bold;
height: 100%;
inset: 0;
line-height: 30px;
opacity: 0.7;
padding: 200px 50px;
position: absolute;
text-align: center;
width: 100%;
z-index: 2001;
}
.dropzoneText {
left: 50%;
position: absolute;
top: min(200px, 50%);
transform: translateX(-50%) translateY(-50%);
}
}

View File

@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import { StaticUserIds } from '../../../constants/StaticUsers';
import { isUserStatic } from '../../../utils/record-helpers';
import TimeAgo from '../../common/TimeAgo';
import UserAvatar from '../../users/UserAvatar';
@@ -32,7 +32,7 @@ const CreationDetailsStep = React.memo(({ userId }) => {
</span>
<span className={styles.content}>
<div className={styles.name}>
{user.id === StaticUserIds.DELETED
{isUserStatic(user)
? t(`common.${user.name}`, {
context: 'title',
})

View File

@@ -34,17 +34,17 @@ const MoreActionsStep = React.memo(({ onClose }) => {
const { canEditType, canDuplicate, canMove } = useSelector((state) => {
const list = selectListById(state, card.listId);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
if (isListArchiveOrTrash(list)) {
return {
canEditType: false,
canDuplicate: false,
canMove: false,
canMove: isEditor,
};
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
return {
canEditType: isEditor,
canDuplicate: isEditor,
@@ -68,14 +68,8 @@ const MoreActionsStep = React.memo(({ onClose }) => {
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
}, [card.name, dispatch, t]);
dispatch(entryActions.duplicateCurrentCard());
}, [dispatch]);
const handleEditTypeClick = useCallback(() => {
openStep(StepTypes.EDIT_TYPE);

View File

@@ -5,7 +5,7 @@
:global(#app) {
.menu {
margin: -7px -12px -5px;
margin: 0 -12px -5px;
width: calc(100% + 24px);
}

View File

@@ -105,7 +105,7 @@ const ProjectContent = React.memo(() => {
canSubscribe: isMember,
canJoin: false,
canDuplicate: false,
canMove: false,
canMove: isEditor,
canRestore: isEditor,
canArchive: isEditor,
canDelete: isEditor,

View File

@@ -9,32 +9,32 @@ import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Gallery, Item as GalleryItem } from 'react-photoswipe-gallery';
import { Button, Grid, Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../../lib/hooks';
import { useDidUpdate } from '../../../lib/hooks';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { usePopupInClosableContext } from '../../../../hooks';
import { isUsableMarkdownElement } from '../../../../utils/element-helpers';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../../constants/Enums';
import { CardTypeIcons } from '../../../../constants/Icons';
import { ClosableContext } from '../../../../contexts';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { usePopupInClosableContext } from '../../../hooks';
import { isUsableMarkdownElement } from '../../../utils/element-helpers';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
import { CardTypeIcons } from '../../../constants/Icons';
import { ClosableContext } from '../../../contexts';
import NameField from './NameField';
import Thumbnail from './Thumbnail';
import NameField from '../NameField';
import CustomFieldGroups from '../CustomFieldGroups';
import Communication from '../Communication';
import CreationDetailsStep from '../CreationDetailsStep';
import MoreActionsStep from '../MoreActionsStep';
import Markdown from '../../../common/Markdown';
import EditMarkdown from '../../../common/EditMarkdown';
import ConfirmationStep from '../../../common/ConfirmationStep';
import UserAvatar from '../../../users/UserAvatar';
import BoardMembershipsStep from '../../../board-memberships/BoardMembershipsStep';
import LabelChip from '../../../labels/LabelChip';
import LabelsStep from '../../../labels/LabelsStep';
import ListsStep from '../../../lists/ListsStep';
import Attachments from '../../../attachments/Attachments';
import AddAttachmentStep from '../../../attachments/AddAttachmentStep';
import AddCustomFieldGroupStep from '../../../custom-field-groups/AddCustomFieldGroupStep';
import CustomFieldGroups from './CustomFieldGroups';
import Communication from './Communication';
import CreationDetailsStep from './CreationDetailsStep';
import MoreActionsStep from './MoreActionsStep';
import Markdown from '../../common/Markdown';
import EditMarkdown from '../../common/EditMarkdown';
import ConfirmationStep from '../../common/ConfirmationStep';
import UserAvatar from '../../users/UserAvatar';
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
import LabelChip from '../../labels/LabelChip';
import LabelsStep from '../../labels/LabelsStep';
import ListsStep from '../../lists/ListsStep';
import Attachments from '../../attachments/Attachments';
import AddAttachmentStep from '../../attachments/AddAttachmentStep';
import AddCustomFieldGroupStep from '../../custom-field-groups/AddCustomFieldGroupStep';
import styles from './StoryContent.module.scss';
@@ -104,7 +104,7 @@ const StoryContent = React.memo(() => {
canSubscribe: isMember,
canJoin: false,
canDuplicate: false,
canMove: false,
canMove: isEditor,
canRestore: isEditor,
canArchive: isEditor,
canDelete: isEditor,

View File

@@ -99,7 +99,7 @@
}
.coverWrapper {
padding-bottom: 20px;
padding-bottom: 10px;
}
.cursorPointer {
@@ -283,7 +283,7 @@
}
.moduleWrapperAttachments {
margin-bottom: 20px;
margin-bottom: 10px;
}
.moreActionsButton {

View File

@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { Item as GalleryItem } from 'react-photoswipe-gallery';
import selectors from '../../../../selectors';
import selectors from '../../../selectors';
import styles from './Thumbnail.module.scss';

View File

@@ -6,5 +6,6 @@
:global(#app) {
.wrapper {
cursor: auto;
outline: none;
}
}

View File

@@ -14,8 +14,7 @@ import { useDidUpdate } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { usePopupInClosableContext } from '../../../hooks';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { StaticUserIds } from '../../../constants/StaticUsers';
import { isListArchiveOrTrash, isUserStatic } from '../../../utils/record-helpers';
import { BoardMembershipRoles } from '../../../constants/Enums';
import { ClosableContext } from '../../../contexts';
import Edit from './Edit';
@@ -107,7 +106,7 @@ const Item = React.memo(({ id }) => {
) : (
<div className={classNames(styles.bubble, isCurrentUser && styles.bubbleRight)}>
<div className={styles.header}>
{user.id === StaticUserIds.DELETED
{isUserStatic(user)
? t(`common.${user.name}`, {
context: 'title',
})

View File

@@ -0,0 +1,65 @@
/*!
* 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 } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Tab } from 'semantic-ui-react';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import TermsPane from './TermsPane';
import AboutPane from './AboutPane';
import styles from './AboutModal.module.scss';
const AboutModal = React.memo(() => {
const dispatch = useDispatch();
const [t] = useTranslation();
const handleClose = useCallback(() => {
dispatch(entryActions.closeModal());
}, [dispatch]);
const [ClosableModal] = useClosableModal();
const panes = [
{
menuItem: t('common.aboutPlanka', {
context: 'title',
}),
render: () => <AboutPane />,
},
{
menuItem: t('common.termsOfService', {
context: 'title',
}),
render: () => <TermsPane />,
},
];
return (
<ClosableModal
open
closeIcon
size="small"
centered={false}
className={styles.wrapper}
onClose={handleClose}
>
<ClosableModal.Content>
<Tab
menu={{
secondary: true,
pointing: true,
}}
panes={panes}
/>
</ClosableModal.Content>
</ClosableModal>
);
});
export default AboutModal;

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 {
margin: 2rem auto;
@media (width < 926px) {
margin: 1rem auto;
}
}
}

View File

@@ -0,0 +1,60 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Divider, Header, Image, Loader, Tab } from 'semantic-ui-react';
import version from '../../../version';
import Markdown from '../Markdown';
import aboutLogo from '../../../assets/images/about-logo.png';
import whatsNewUrl from '../../../assets/docs/whats-new.md?url';
import styles from './AboutPane.module.scss';
const AboutPane = React.memo(() => {
const [t] = useTranslation();
const [whatsNew, setWhatsNew] = useState(null);
useEffect(() => {
async function fetchWhatsNew() {
let response;
try {
response = await fetch(whatsNewUrl);
} catch {
return;
}
const text = await response.text();
setWhatsNew(text);
}
fetchWhatsNew();
}, []);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<a href="https://github.com/plankanban/planka" target="_blank" rel="noreferrer">
<Image centered src={aboutLogo} size="large" />
</a>
<div className={styles.version}>Community v{version}</div>
<Divider horizontal>
<Header as="h4">
{t('common.whatsNew', {
context: 'title',
})}
</Header>
</Divider>
{whatsNew ? (
<Markdown>{whatsNew}</Markdown>
) : (
<Loader active inverted inline="centered" size="small" />
)}
</Tab.Pane>
);
});
export default AboutPane;

View File

@@ -6,6 +6,7 @@
:global(#app) {
.version {
font-weight: bold;
margin-bottom: 40px;
text-align: center;
}

View File

@@ -4,19 +4,15 @@
*/
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Loader, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import api from '../../../api';
import Markdown from '../../common/Markdown';
import Markdown from '../Markdown';
import styles from './TermsPane.module.scss';
const TermsPane = React.memo(() => {
const type = useSelector((state) => selectors.selectCurrentUser(state).termsType);
const { i18n } = useTranslation();
const [content, setContent] = useState(null);
@@ -24,7 +20,7 @@ const TermsPane = React.memo(() => {
async function fetchTerms() {
let terms;
try {
({ item: terms } = await api.getTerms(type, i18n.resolvedLanguage));
({ item: terms } = await api.getTerms(i18n.resolvedLanguage));
} catch {
return;
}
@@ -33,7 +29,7 @@ const TermsPane = React.memo(() => {
}
fetchTerms();
}, [type, i18n.resolvedLanguage]);
}, [i18n.resolvedLanguage]);
return (
<Tab.Pane attached={false} className={styles.wrapper}>

View File

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

View File

@@ -4,7 +4,8 @@
*/
import { dequal } from 'dequal';
import React, { useCallback, useMemo } from 'react';
import omit from 'lodash/omit';
import React, { useCallback, useMemo, useRef } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -20,12 +21,13 @@ import styles from './SmtpPane.module.scss';
const SmtpPane = React.memo(() => {
const config = useSelector(selectors.selectConfig);
const smtpTest = useSelector(selectors.selectSmtpTest);
const smtpTestState = useSelector(selectors.selectSmtpTestState);
const dispatch = useDispatch();
const [t] = useTranslation();
const [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef');
const isPasswordTouchedRef = useRef(false);
const defaultData = useMemo(
() => ({
@@ -44,7 +46,7 @@ const SmtpPane = React.memo(() => {
const [data, handleFieldChange] = useForm(() => ({
...defaultData,
smtpHost: defaultData.smtpHost || '',
smtpPort: defaultData.smtpPort || '',
smtpPort: defaultData.smtpPort === null ? '' : `${defaultData.smtpPort}`,
smtpName: defaultData.smtpName || '',
smtpSecure: defaultData.smtpSecure,
smtpTlsRejectUnauthorized: defaultData.smtpTlsRejectUnauthorized,
@@ -68,7 +70,15 @@ const SmtpPane = React.memo(() => {
[data, isPasswordSet],
);
const isModified = useMemo(() => {
const cleanDataToCheck = omit(cleanData, 'smtpPassword');
const defaultDataToCheck = omit(defaultData, 'smtpPassword');
return !dequal(cleanDataToCheck, defaultDataToCheck) || isPasswordTouchedRef.current;
}, [defaultData, cleanData]);
const handleSubmit = useCallback(() => {
isPasswordTouchedRef.current = false;
dispatch(entryActions.updateConfig(cleanData));
}, [dispatch, cleanData]);
@@ -86,7 +96,13 @@ const SmtpPane = React.memo(() => {
dispatch(entryActions.testSmtpConfig());
}, [dispatch]);
const isModified = !dequal(cleanData, defaultData);
const handlePasswordChange = useCallback(
(event, { value, ...props }) => {
isPasswordTouchedRef.current = value !== '';
handleFieldChange(event, { value, ...props });
},
[handleFieldChange],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
@@ -173,7 +189,7 @@ const SmtpPane = React.memo(() => {
maxLength={256}
className={styles.field}
onClear={!data.smtpPassword && isPasswordSet ? handlePasswordClear : undefined}
onChange={handleFieldChange}
onChange={handlePasswordChange}
/>
<div className={styles.text}>
{t('common.defaultFrom')} (
@@ -196,14 +212,14 @@ const SmtpPane = React.memo(() => {
<Button
type="button"
content={t('action.sendTestEmail')}
loading={smtpTest.isLoading}
disabled={smtpTest.isLoading}
loading={smtpTestState.isLoading}
disabled={smtpTestState.isLoading}
onClick={handleTestClick}
/>
)}
</div>
</Form>
{smtpTest.logs && (
{smtpTestState.logs && (
<>
<Divider horizontal>
<Header as="h4">
@@ -215,7 +231,7 @@ const SmtpPane = React.memo(() => {
<TextArea
readOnly
as={TextareaAutosize}
value={smtpTest.logs.join('\n')}
value={smtpTestState.logs.join('\n')}
className={styles.testLog}
/>
</>

View File

@@ -14,6 +14,7 @@ import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useSteps } from '../../../../hooks';
import SelectRoleStep from './SelectRoleStep';
import ApiKeyStep from './ApiKeyStep';
import ConfirmationStep from '../../ConfirmationStep';
import EditUserInformationStep from '../../../users/EditUserInformationStep';
import EditUserUsernameStep from '../../../users/EditUserUsernameStep';
@@ -28,6 +29,8 @@ const StepTypes = {
EDIT_EMAIL: 'EDIT_EMAIL',
EDIT_PASSWORD: 'EDIT_PASSWORD',
EDIT_ROLE: 'EDIT_ROLE',
API_KEY: 'API_KEY',
UNLINK_SSO: 'UNLINK_SSO',
ACTIVATE: 'ACTIVATE',
DEACTIVATE: 'DEACTIVATE',
DELETE: 'DELETE',
@@ -39,6 +42,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
const user = useSelector((state) => selectUserById(state, userId));
const isCurrentUser = useSelector((state) => user.id === selectors.selectCurrentUserId(state));
const dispatch = useDispatch();
const [t] = useTranslation();
@@ -55,6 +59,16 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
[userId, dispatch],
);
const handleUnlinkSsoConfirm = useCallback(() => {
dispatch(
entryActions.updateUser(userId, {
isSsoUser: false,
}),
);
onClose();
}, [userId, onClose, dispatch]);
const handleActivateConfirm = useCallback(() => {
dispatch(
entryActions.updateUser(userId, {
@@ -99,6 +113,14 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
openStep(StepTypes.EDIT_ROLE);
}, [openStep]);
const handleApiKeyClick = useCallback(() => {
openStep(StepTypes.API_KEY);
}, [openStep]);
const handleUnlinkSsoClick = useCallback(() => {
openStep(StepTypes.UNLINK_SSO);
}, [openStep]);
const handleActivateClick = useCallback(() => {
openStep(StepTypes.ACTIVATE);
}, [openStep]);
@@ -133,6 +155,18 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
onClose={onClose}
/>
);
case StepTypes.API_KEY:
return <ApiKeyStep userId={userId} onBack={handleBack} onClose={onClose} />;
case StepTypes.UNLINK_SSO:
return (
<ConfirmationStep
title="common.unlinkSso"
content="common.areYouSureYouWantToUnlinkSsoFromThisUser"
buttonContent="action.unlinkSso"
onConfirm={handleUnlinkSsoConfirm}
onBack={handleBack}
/>
);
case StepTypes.ACTIVATE:
return (
<ConfirmationStep
@@ -209,7 +243,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
})}
</Menu.Item>
)}
{!user.lockedFieldNames.includes('role') && (
{!user.lockedFieldNames.includes('role') && !isCurrentUser && (
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
<Icon name="sun outline" className={styles.menuItemIcon} />
{t('action.editRole', {
@@ -217,32 +251,53 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
})}
</Menu.Item>
)}
<Menu.Item
disabled={
user.isDeactivated &&
activeUsersLimit !== null &&
activeUsersTotal >= activeUsersLimit
}
className={styles.menuItem}
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
>
<Icon name={user.isDeactivated ? 'plus' : 'close'} className={styles.menuItemIcon} />
{user.isDeactivated
? t('action.activateUser', {
context: 'title',
})
: t('action.deactivateUser', {
context: 'title',
})}
<Menu.Item className={styles.menuItem} onClick={handleApiKeyClick}>
<Icon name="key" className={styles.menuItemIcon} />
{t('common.apiKey', {
context: 'title',
})}
</Menu.Item>
{user.isDeactivated && !user.isDefaultAdmin && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.deleteUser', {
{user.isSsoUser && !user.lockedFieldNames.includes('isSsoUser') && !isCurrentUser && (
<Menu.Item className={styles.menuItem} onClick={handleUnlinkSsoClick}>
<Icon name="unlink" className={styles.menuItemIcon} />
{t('action.unlinkSso', {
context: 'title',
})}
</Menu.Item>
)}
{!isCurrentUser && (
<>
<Menu.Item
disabled={
user.isDeactivated &&
activeUsersLimit !== null &&
activeUsersTotal >= activeUsersLimit
}
className={styles.menuItem}
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
>
<Icon
name={user.isDeactivated ? 'plus' : 'close'}
className={styles.menuItemIcon}
/>
{user.isDeactivated
? t('action.activateUser', {
context: 'title',
})
: t('action.deactivateUser', {
context: 'title',
})}
</Menu.Item>
{user.isDeactivated && !user.isDefaultAdmin && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.deleteUser', {
context: 'title',
})}
</Menu.Item>
)}
</>
)}
</Menu>
</Popup.Content>
</>

View File

@@ -5,7 +5,7 @@
:global(#app) {
.menu {
margin: -7px -12px -5px;
margin: 0 -12px -5px;
width: calc(100% + 24px);
}

View File

@@ -221,7 +221,7 @@ const AddStep = React.memo(({ onClose }) => {
ref={handleUsernameFieldRef}
name="username"
value={data.username}
maxLength={16}
maxLength={32}
readOnly={isSubmitting}
className={styles.field}
onChange={handleFieldChange}

View File

@@ -0,0 +1,169 @@
/*!
* Copyright (c) 2025 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Input, Message } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useSteps } from '../../../../hooks';
import ConfirmationStep from '../../ConfirmationStep';
import styles from './ApiKeyStep.module.scss';
const StepTypes = {
REGENERATE: 'REGENERATE',
DELETE: 'DELETE',
};
const ApiKeyStep = React.memo(({ userId, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const user = useSelector((state) => selectUserById(state, userId));
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const [isCopied, setIsCopied] = useState(false);
const handleGenerateClick = useCallback(() => {
if (user.apiKeyPrefix) {
openStep(StepTypes.REGENERATE);
} else {
dispatch(entryActions.createUserApiKey(userId));
}
}, [userId, user.apiKeyPrefix, dispatch, openStep]);
const handleRegenerateConfirm = useCallback(() => {
dispatch(entryActions.createUserApiKey(userId));
handleBack();
}, [userId, dispatch, handleBack]);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteUserApiKey(userId));
onClose();
}, [userId, onClose, dispatch]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleCopyClick = useCallback(() => {
if (isCopied) {
return;
}
navigator.clipboard.writeText(user.apiKeyState.value);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
}, [user.apiKeyState.value, isCopied]);
if (step) {
switch (step.type) {
case StepTypes.REGENERATE:
return (
<ConfirmationStep
title="common.regenerateApiKey"
content="common.areYouSureYouWantToRegenerateThisApiKey"
buttonContent="action.regenerateApiKey"
onConfirm={handleRegenerateConfirm}
onBack={handleBack}
/>
);
case StepTypes.DELETE:
return (
<ConfirmationStep
title="common.deleteApiKey"
content="common.areYouSureYouWantToDeleteThisApiKey"
buttonContent="action.deleteApiKey"
onConfirm={handleDeleteConfirm}
onBack={handleBack}
/>
);
default:
}
}
return (
<>
<Popup.Header onBack={onBack}>
{t('common.apiKey', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
{user.apiKeyPrefix ? (
<>
{!user.apiKeyState.isCreating &&
(user.apiKeyState.value ? (
<>
<Message
positive
header={t('common.apiKeyCreated', {
context: 'title',
})}
content={t('common.saveThisKeyItWillNotBeShownAgain')}
/>
<div className={styles.valueWrapper}>
<Input fluid readOnly value={user.apiKeyState.value} className={styles.value} />
<Button className={styles.copyButton} onClick={handleCopyClick}>
<Icon fitted name={isCopied ? 'check' : 'copy'} />
</Button>
</div>
</>
) : (
<Message
warning
header={`${user.apiKeyPrefix}_...`}
content={t('common.fullKeyIsHiddenForSecurityReasons')}
/>
))}
<Button
fluid
content={t('action.regenerateApiKey')}
loading={user.apiKeyState.isCreating}
disabled={user.apiKeyState.isCreating}
className={styles.actionButton}
onClick={handleGenerateClick}
/>
<Button
fluid
content={t('action.deleteApiKey')}
className={styles.actionButton}
onClick={handleDeleteClick}
/>
</>
) : (
<>
<div className={styles.content}>{t('common.noApiKeyCreated')}</div>
<Button
fluid
positive
content={t('action.createApiKey')}
loading={user.apiKeyState.isCreating}
disabled={user.apiKeyState.isCreating}
onClick={handleGenerateClick}
/>
</>
)}
</Popup.Content>
</>
);
});
ApiKeyStep.propTypes = {
userId: PropTypes.string.isRequired,
onBack: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ApiKeyStep;

View File

@@ -0,0 +1,65 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.actionButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 11px;
text-align: left;
text-decoration: underline;
transition: background 0.3s ease;
&:hover {
background: #e9e9e9;
}
}
.content {
margin-bottom: 6px;
}
.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;
}
}
.value input {
overflow: hidden;
text-overflow: ellipsis;
}
.valueWrapper {
position: relative;
&:hover:not(:has(input:focus)) {
.copyButton {
display: block;
}
}
}
}

View File

@@ -6,11 +6,13 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Icon, Table } from 'semantic-ui-react';
import { useEventCallback } from '../../../../lib/hooks';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { usePopupInClosableContext } from '../../../../hooks';
import { UserRoleIcons } from '../../../../constants/Icons';
import ActionsStep from './ActionsStep';
@@ -22,21 +24,62 @@ const Item = React.memo(({ id }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const user = useSelector((state) => selectUserById(state, id));
const currentUserId = useSelector(selectors.selectCurrentUserId);
const dispatch = useDispatch();
const [t] = useTranslation();
const ActionsPopup = usePopupInClosableContext(ActionsStep);
const handleActionsPopupClose = useEventCallback(() => {
if (user.apiKeyState.value) {
dispatch(entryActions.clearUserApiKeyValue(id));
}
}, [id, user.apiKeyState.value, dispatch]);
const ActionsPopup = usePopupInClosableContext(ActionsStep, {
onClose: handleActionsPopupClose,
});
return (
<Table.Row className={classNames(user.isDeactivated && styles.wrapperDeactivated)}>
<Table.Row
className={classNames(styles.wrapper, user.isDeactivated && styles.wrapperDeactivated)}
>
<Table.Cell>
<UserAvatar id={id} />
<div className={styles.user}>
<UserAvatar id={id} />
<div>
{user.name}
{user.id === currentUserId && (
<div className={styles.note}>{t('common.currentUser')}</div>
)}
</div>
</div>
</Table.Cell>
<Table.Cell>
{user.email}
{user.username && <div className={styles.note}>@{user.username}</div>}
</Table.Cell>
<Table.Cell>
{user.phone && (
<div className={styles.information}>
<Icon name="phone" className={styles.icon} />
{user.phone}
</div>
)}
{user.organization && (
<div className={styles.information}>
<Icon name="building" className={styles.icon} />
{user.organization}
</div>
)}
{user.apiKeyPrefix && (
<div className={classNames(styles.information, styles.informationApiKey)}>
<Icon name="key" className={styles.icon} />
{user.apiKeyPrefix}_...
</div>
)}
</Table.Cell>
<Table.Cell>{user.name}</Table.Cell>
<Table.Cell>{user.username || '-'}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell className={styles.roleCell}>
<Icon name={UserRoleIcons[user.role]} className={styles.roleIcon} />
<Icon name={UserRoleIcons[user.role]} className={styles.icon} />
{t(`common.${user.role}`)}
</Table.Cell>
<Table.Cell textAlign="right">

View File

@@ -9,12 +9,43 @@
margin-right: 0;
}
.icon {
color: #888888;
margin: 0 0.35714286em 0 0;
}
.information {
font-size: 13px;
&:not(:last-of-type) {
margin-bottom: 4px;
}
}
.informationApiKey {
color: #cf513d;
.icon {
color: inherit;
}
}
.note {
color: #888888;
}
.roleCell {
white-space: nowrap;
}
.roleIcon {
margin: 0 0.35714286em 0 0;
.user {
align-items: center;
display: flex;
gap: 8px;
}
.wrapper {
color: #212121;
}
.wrapperDeactivated {

View File

@@ -18,8 +18,8 @@ import styles from './UsersPane.module.scss';
const UsersPane = React.memo(() => {
const activeUsersLimit = useSelector(selectors.selectActiveUsersLimit);
const users = useSelector(selectors.selectUsersExceptCurrent);
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
const users = useSelector(selectors.selectUsers);
const canAdd = useSelector((state) => {
const oidcBootstrap = selectors.selectOidcBootstrap(state);
@@ -48,7 +48,9 @@ const UsersPane = React.memo(() => {
return (
user.email.includes(cleanSearch) ||
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch))
(user.username && user.username.includes(cleanSearch)) ||
(user.organization && user.organization.toLowerCase().includes(cleanSearch)) ||
(user.apiKeyPrefix && user.apiKeyPrefix.toLowerCase().includes(cleanSearch))
);
}),
[users, isDeactivatedVisible, cleanSearch],
@@ -81,9 +83,8 @@ const UsersPane = React.memo(() => {
<Table.Header>
<Table.Row>
<Table.HeaderCell />
<Table.HeaderCell width={4}>{t('common.name')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.username')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.email')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.identity')}</Table.HeaderCell>
<Table.HeaderCell width={4}>{t('common.information')}</Table.HeaderCell>
<Table.HeaderCell>{t('common.role')}</Table.HeaderCell>
<Table.HeaderCell />
</Table.Row>
@@ -101,7 +102,6 @@ const UsersPane = React.memo(() => {
className={styles.toggleDeactivatedButton}
onClick={handleToggleDeactivatedClick}
/>
{canAdd && (
<AddPopup>
<Button

View File

@@ -16,6 +16,7 @@ import Toaster from '../Toaster';
import Fixed from '../Fixed';
import Static from '../Static';
import AdministrationModal from '../AdministrationModal';
import AboutModal from '../AboutModal';
import UserSettingsModal from '../../users/UserSettingsModal';
import ProjectBackground from '../../projects/ProjectBackground';
import AddProjectModal from '../../projects/AddProjectModal';
@@ -61,6 +62,10 @@ const Core = React.memo(() => {
case ModalTypes.ADMINISTRATION:
modalNode = <AdministrationModal />;
break;
case ModalTypes.ABOUT:
modalNode = <AboutModal />;
break;
case ModalTypes.USER_SETTINGS:
modalNode = <UserSettingsModal />;

View File

@@ -0,0 +1,134 @@
/*!
* 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 } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Icon } from 'semantic-ui-react';
import history from '../../../history';
import Paths from '../../../constants/Paths';
import styles from './GhostError.module.scss';
const GhostError = React.memo(({ message }) => {
const [t] = useTranslation();
const eyesRef = useRef(null);
const handleBackClick = useCallback(() => {
history.back();
}, []);
const handleHomeClick = useCallback(() => {
history.push(Paths.ROOT);
}, []);
useEffect(() => {
let pageX = document.documentElement.clientWidth;
let pageY = document.documentElement.clientHeight;
const handleMouseMove = (event) => {
if (!eyesRef.current) {
return;
}
// Vertical axis
const mouseY = event.pageY;
const yAxis = ((pageY / 2 - mouseY) / pageY) * -300;
// Horizontal axis
const mouseX = event.pageX / -pageX;
const xAxis = -mouseX * 100 - 100;
// Apply transform to eyes
eyesRef.current.style.transform = `translate(${xAxis}%, ${yAxis}%)`;
};
const handleResize = () => {
pageX = document.documentElement.clientWidth;
pageY = document.documentElement.clientHeight;
};
document.addEventListener('mousemove', handleMouseMove);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<div className={styles.wrapper}>
<div className={styles.symbols}>
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
<div className={styles.symbol} />
</div>
<div className={styles.ghost}>
<div ref={eyesRef} className={styles.eyes}>
<div className={styles.eyeLeft} />
<div className={styles.eyeRight} />
</div>
<div className={styles.bottom}>
<div />
<div />
<div />
<div />
<div />
</div>
</div>
<div className={styles.shadow} />
<div className={styles.message}>
<h1 className={styles.title}>
{t('common.whoops', {
context: 'title',
})}
</h1>
<div className={styles.text}>
{t(message, {
context: 'title',
})}
</div>
{window.history.length > 1 && (
<Button
basic
color="yellow"
size="large"
className={styles.button}
onClick={handleBackClick}
>
<Icon name="arrow left" />
{t('action.goBack')}
</Button>
)}
<Button
basic
color="olive"
size="large"
className={styles.button}
onClick={handleHomeClick}
>
<Icon name="home" />
{t('action.goHome')}
</Button>
</div>
</div>
);
});
GhostError.propTypes = {
message: PropTypes.string,
};
GhostError.defaultProps = {
message: 'common.pageNotFound',
};
export default GhostError;

View File

@@ -0,0 +1,298 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.bottom {
display: flex;
left: 0;
position: absolute;
right: 0;
top: 100%;
div {
background-color: #fff;
border-radius: 100%;
flex-grow: 1;
height: 20px;
position: relative;
top: -10px;
&:nth-child(2n) {
background: transparent;
border-top: 15px solid #22252a;
top: -12px;
}
}
}
.button {
padding: 0.78571429em 1.5em 0.78571429em;
}
.eyeLeft {
background: #22252a;
border-radius: 50%;
height: 12px;
left: 10px;
position: absolute;
width: 12px;
}
.eyeRight {
background: #22252a;
border-radius: 50%;
height: 12px;
position: absolute;
right: 10px;
width: 12px;
}
.eyes {
height: 12px;
left: 50%;
position: absolute;
top: 45%;
transform: translateX(-50%);
width: 70px;
}
.ghost {
animation: upndown 3s ease-in-out infinite;
background: #fff;
border-radius: 100px 100px 0 0;
height: 100px;
margin: 0 auto 20px;
position: relative;
width: 100px;
z-index: 1;
}
.message {
max-width: 400px;
text-align: center;
z-index: 1;
}
.shadow {
animation: smallnbig 3s ease-in-out infinite;
background: #000;
border-radius: 50%;
height: 20px;
margin: 0 auto 30px;
width: 80px;
}
.symbol {
&:nth-child(1) {
animation: shine 4s ease-in-out 3s infinite;
left: 20px;
opacity: 0.3;
position: absolute;
top: 20px;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 8px;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
}
&:nth-child(2) {
animation: shine 4s ease-in-out 1.3s infinite;
border: 2px solid;
border-color: #6b808c;
border-radius: 50%;
height: 12px;
opacity: 0.3;
position: absolute;
right: 30px;
top: 40px;
width: 12px;
}
&:nth-child(3) {
animation: shine 3s ease-in-out 0.5s infinite;
bottom: 30px;
left: 30px;
opacity: 0.3;
position: absolute;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 8px;
}
&:before {
transform: rotate(90deg);
}
&:after {
transform: rotate(180deg);
}
}
&:nth-child(4) {
animation: shine 6s ease-in-out 1.6s infinite;
opacity: 0.3;
position: absolute;
right: 30px;
top: 10px;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 10px;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
}
&:nth-child(5) {
animation: shine 1.7s ease-in-out 7s infinite;
border: 2px solid;
border-color: #6b808c;
border-radius: 50%;
height: 8px;
opacity: 0.3;
position: absolute;
right: 5px;
top: 60px;
width: 8px;
}
&:nth-child(6) {
animation: shine 2s ease-in-out 6s infinite;
bottom: 10px;
opacity: 0.3;
position: absolute;
right: 10px;
&:before,
&:after {
background: #6b808c;
border-radius: 3px;
content: "";
height: 3px;
position: absolute;
width: 10px;
}
&:before {
transform: rotate(90deg);
}
&:after {
transform: rotate(180deg);
}
}
}
.symbols {
height: 200px;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 200px;
}
.text {
color: #6b808c;
font-size: 18px;
line-height: 1.4;
margin-bottom: 30px;
}
.title {
font-size: 32px;
letter-spacing: 0.5px;
}
.wrapper {
align-items: center;
color: #fff;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
min-height: 60vh;
padding: 40px 20px;
position: relative;
width: 100%;
}
@keyframes shine {
0% {
opacity: 0.3;
}
25% {
opacity: 0.1;
}
50% {
opacity: 0.3;
}
100% {
opacity: 0.3;
}
}
@keyframes smallnbig {
0% {
width: 80px;
}
50% {
width: 90px;
}
100% {
width: 80px;
}
}
@keyframes upndown {
0% {
transform: translateY(5px);
}
50% {
transform: translateY(15px);
}
100% {
transform: translateY(5px);
}
}
}

View File

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

View File

@@ -6,7 +6,7 @@
import React, { useCallback } from 'react';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { Button, Icon, Menu } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
@@ -15,7 +15,7 @@ import entryActions from '../../../entry-actions';
import Paths from '../../../constants/Paths';
import { BoardMembershipRoles, BoardViews, UserRoles } from '../../../constants/Enums';
import UserAvatar from '../../users/UserAvatar';
import UserStep from '../../users/UserStep';
import UserActionsStep from '../../users/UserActionsStep';
import NotificationsStep from '../../notifications/NotificationsStep';
import styles from './Header.module.scss';
@@ -90,7 +90,7 @@ const Header = React.memo(() => {
}, [canEditProject, dispatch]);
const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS);
const UserPopup = usePopup(UserStep, POPUP_PROPS);
const UserActionsPopup = usePopup(UserActionsStep, POPUP_PROPS);
return (
<div className={styles.wrapper}>
@@ -152,12 +152,12 @@ const Header = React.memo(() => {
)}
</Menu.Item>
</NotificationsPopup>
<UserPopup>
<UserActionsPopup>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
<span className={styles.userName}>{user.name}</span>
<UserAvatar id={user.id} size="small" />
</Menu.Item>
</UserPopup>
</UserActionsPopup>
</Menu.Menu>
</Menu>
</div>

View File

@@ -5,7 +5,7 @@
:global(#app) {
.menu {
margin: -7px -12px -5px;
margin: 0 -12px -5px;
width: calc(100% + 24px);
}

View File

@@ -12,7 +12,7 @@ import selectors from '../../../selectors';
import matchPaths from '../../../utils/match-paths';
import Paths from '../../../constants/Paths';
const Linkify = React.memo(({ href, content, stopPropagation, ...props }) => {
const Link = React.memo(({ href, content, stopPropagation, ...props }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const url = useMemo(() => {
@@ -68,14 +68,14 @@ const Linkify = React.memo(({ href, content, stopPropagation, ...props }) => {
);
});
Linkify.propTypes = {
Link.propTypes = {
href: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
stopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
Link.defaultProps = {
stopPropagation: false,
};
export default Linkify;
export default Link;

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