diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 5b621fac0..2392f33c6 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,3 +1,4 @@
# These are supported funding model platforms
+github: wekan
custom: ['https://wekan.fi/commercial-support/']
diff --git a/.github/workflows/depsreview.yaml b/.github/workflows/depsreview.yaml
index 8461b453c..ae5ae5989 100644
--- a/.github/workflows/depsreview.yaml
+++ b/.github/workflows/depsreview.yaml
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 54af974ce..febfde53f 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
@@ -48,7 +48,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
- uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
diff --git a/.github/workflows/dockerimage.yml b/.github/workflows/dockerimage.yml
index 14f8dfe01..0f85c0d96 100644
--- a/.github/workflows/dockerimage.yml
+++ b/.github/workflows/dockerimage.yml
@@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Build the Docker image
run: docker build . --file Dockerfile --tag wekan:$(date +%s)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9d93b7588..0c05c85e6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
with:
fetch-depth: 0
diff --git a/.github/workflows/test_suite.yml b/.github/workflows/test_suite.yml
index b3498f613..1b3effdac 100644
--- a/.github/workflows/test_suite.yml
+++ b/.github/workflows/test_suite.yml
@@ -18,7 +18,7 @@ jobs:
# runs-on: ubuntu-latest
# steps:
# - name: checkout
-# uses: actions/checkout@v5
+# uses: actions/checkout@v6
#
# - name: setup node
# uses: actions/setup-node@v1
@@ -42,7 +42,7 @@ jobs:
# needs: [lintcode]
# steps:
# - name: checkout
-# uses: actions/checkout@v5
+# uses: actions/checkout@v6
#
# - name: setup node
# uses: actions/setup-node@v1
@@ -65,7 +65,7 @@ jobs:
# needs: [lintcode,lintstyle]
# steps:
# - name: checkout
-# uses: actions/checkout@v5
+# uses: actions/checkout@v6
#
# - name: setup node
# uses: actions/setup-node@v1
@@ -90,12 +90,12 @@ jobs:
# CHECKOUTS
- name: Checkout
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
# CACHING
- name: Install Meteor
id: cache-meteor-install
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.meteor
key: v1-meteor-${{ hashFiles('.meteor/versions') }}
@@ -104,7 +104,7 @@ jobs:
- name: Cache NPM dependencies
id: cache-meteor-npm
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.npm
key: v1-npm-${{ hashFiles('package-lock.json') }}
@@ -113,7 +113,7 @@ jobs:
- name: Cache Meteor build
id: cache-meteor-build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
.meteor/local/resolver-result-cache.json
@@ -136,7 +136,7 @@ jobs:
run: sh ./test-wekan.sh -cv
- name: Upload coverage
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: coverage-folder
path: .coverage/
@@ -147,10 +147,10 @@ jobs:
needs: [tests]
steps:
- name: Checkout
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Download coverage
- uses: actions/download-artifact@v5
+ uses: actions/download-artifact@v7
with:
name: coverage-folder
path: .coverage/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc452b057..8b27c474d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,243 @@ Fixing other platforms In Progress.
[Upgrade WeKan](https://wekan.fi/upgrade/)
+WeKan 8.00-8.06 had wrong raw database directory setting /var/snap/wekan/common/wekan and some cards were not visible.
+Those are fixed at WeKan 8.07 where database directory is back to /var/snap/wekan/common and all cards are visible.
+
+# Upcoming WeKan ® release
+
+This release adds the following updates:
+
+- [Update GitHub docker/metadata-action from 5.8.0 to 5.9.0](https://github.com/wekan/wekan/pull/6012).
+ Thanks to dependabot.
+- [Updated security.md](https://github.com/wekan/wekan/commit/7ff1649d8909917cae590c68def6eecac0442f91).
+ Thanks to xet7.
+- [Updated build script for Linux arm64 bundle](https://github.com/wekan/wekan/commit/3db1305e58168f7417023ccd8d54995026844b18).
+ Thanks to xet7.
+
+and fixes the following bugs:
+
+- [Fix Broken Strikethroughs in Markdown to HTML conversion](https://github.com/wekan/wekan/pull/6009).
+ Thanks to brlin-tw.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.17 2025-11-06 WeKan ® release
+
+This release adds the following new feature:
+
+- [Feature: Workspaces, at All Boards page](https://github.com/wekan/wekan/commit/0afbdc95b49537e06b4f9cf98f51a669ef249384).
+ Thanks to xet7.
+
+and fixes the following bugs:
+
+- [Fix 8.16: Switching Board View fails with 403 error](https://github.com/wekan/wekan/commit/550d87ac6cb3ec946600616485afdbd242983ab4).
+ Thanks to xet7.
+- [Moved migrations from opening board to right sidebar / Migrations](https://github.com/wekan/wekan/commit/1b25d1d5720d4f486a10d2acce37e315cf9b6057).
+ Thanks to xet7.
+- [Fix 8.16 Lists with no items are deleted every time when board is opened. Moved migrations to right sidebar](https://github.com/wekan/wekan/commit/7713e613b431e44dc13cee72e7a1e5f031473fa6).
+ Thanks to xet7.
+- [Remove old translations and code not in use anymore](https://github.com/wekan/wekan/commit/ba49d4d140bc0d4cfb5a96db9ab077bc85db58f1).
+ Thanks to xet7.
+- [Fixed sidebar migrations to be per-board, not global. Clarified translations](https://github.com/wekan/wekan/commit/e4638d5fbcbe004ac393462331805cac3ba25097).
+ Thanks to xet7.
+- [Fix star board](https://github.com/wekan/wekan/commit/8711b476be30496b96b845529b5717bb6e685c27).
+ Thanks to xet7.
+- [Fix Card emoji issues](https://github.com/wekan/wekan/commit/e5e711c938edcca23c974c3eec97296898bcf24e).
+ Thanks to xet7.
+- [Try to fix Edit Custom Fields button not working. Removed duplicate option from Boards Settings](https://github.com/wekan/wekan/commit/20af0a2ef55b11e7205845859ee92a929616ce91).
+ Thanks to xet7.
+- [Fix Regression - calendar popup to set due date has gone](https://github.com/wekan/wekan/commit/581733d605b7e0494e72229c45947cff134f6dd6).
+ Thanks to xet7.
+- [Remove not working Bookmark menu option](https://github.com/wekan/wekan/commit/c829c073cf822e48b7cd84bbfb79d42867412517).
+ Thanks to xet7.
+- [Fix Workspaces at All Boards to have correct count of remaining etc, while starred also at Starred/Favorites](https://github.com/wekan/wekan/commit/6244657ca53a54646ec01e702851a51d89bd0d55).
+ Thanks to xet7.
+- [Fix Worker Permissions does not allow for cards to be moved. - v8.15. Removed buttons Worker should not use](https://github.com/wekan/wekan/commit/18003900c2d497c129793d1653d4d9872a2f19da).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.16 2025-11-02 WeKan ® release
+
+This release fixes SpaceBleed that is the following CRITICAL SECURITY ISSUES:
+
+- [Fix SECURITY ISSUE 1: File Attachments enables stored XSS (High)](https://github.com/wekan/wekan/commit/e9a727301d7b4f1689a703503df668c0f4f4cab8).
+ Thanks to Siam Thanat Hack (STH) and xet7.
+- [Fix SECURITY ISSUE 2: Access to boards of any Orgs/Teams, and avatar permissions](https://github.com/wekan/wekan/commit/f26d58201855e861bab1cd1fda4d62c664efdb81).
+ Thanks to Siam Thanat Hack (STH) and xet7.
+- [Fix SECURITY ISSUE 3: Unauthenticated (or any) user can update board sort](https://github.com/wekan/wekan/commit/ea310d7508b344512e5de0dfbc9bdfd38145c5c5).
+ Thanks to Siam Thanat Hack (STH) and xet7.
+- [Fix SECURITY ISSUE 4: Members can forge others’ votes (Low). Bonus: Similar fixes to planning poker too done by xet7](https://github.com/wekan/wekan/commit/0a1a075f3153e71d9a858576f1c68d2925230d9c).
+ Thanks to Siam Thanat Hack (STH) and xet7.
+- [Fix SECURITY ISSUE 5: Attachment API uses bearer value as userId and DoS (Low)](https://github.com/wekan/wekan/commit/ccd90343394f433b287733ad0a33c08e0a71f53c).
+ Thanks to Siam Thanat Hack (STH) and xet7.
+
+and adds the following new features:
+
+- [List menu / More / Delete duplicate lists that do not have any cards](https://github.com/wekan/wekan/commit/91b846e2cdee9154b045d11b4b4c1a7ae1d79016).
+ Thanks to xet7.
+- [Disabled migrations that happen when opening board. Defaulting to per-swimlane lists and drag drop list to same or different swimlane](https://github.com/wekan/wekan/commit/034dc08269520ca31c780cce64e0150969e9228e).
+ Thanks to xet7.
+
+and fixes the following bugs:
+
+- [Fix changing swimlane color to not reload webpage](https://github.com/wekan/wekan/commit/ecf2418347cae4329deb292b534f68eb099d3f90).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.15 2025-10-23 WeKan ® release
+
+This release fixes the following bugs:
+
+- Fix drag lists did not work
+ [Part 1](https://github.com/wekan/wekan/commit/8662c96d1c8d4fa76ce7b31eb06678ad59c3ebe1),
+ [Part 2](https://github.com/wekan/wekan/commit/0cebd8aa4dbe0bf2418b814716744ab806b671c2).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.14 2025-10-23 WeKan ® release
+
+This release fixes the following bugs:
+
+- [Fix board reloading page every second](https://github.com/wekan/wekan/commit/b4b598f542d0cefc5f2d5d6c7286f0a312cf6a55).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.12 2025-10-23 WeKan ® release
+
+This release fixes the following bugs:
+
+- [Fix Regression - unable to view cards by due date v8.11](https://github.com/wekan/wekan/commit/ae11e80bde79d9ad412d185f20e5a7f802685260).
+ Thanks to xet7.
+- [Fix Regression - unable to rearrange tasks within a checklist - v8.11](https://github.com/wekan/wekan/commit/544b24ceb1687e5b568d8c7b74403a5a2e3f6bc6).
+ Thanks to xet7.
+- [Fix unable to add members to board](https://github.com/wekan/wekan/commit/c6d46006837a29fb311e444f94fa65f236e23bc7).
+ Thanks to xet7.
+- [Removed not needed | at left side of minicard badges](https://github.com/wekan/wekan/commit/a0c30c35ed57113df041ef1020d3e9e5449f35e4).
+ Thanks to xet7.
+- [Fix opened card Date Format to be used at dates popups](https://github.com/wekan/wekan/commit/7ca81285b14d1ec60d6e7e9c191d1194950f18c8).
+ Thanks to xet7.
+- [Fix UI issues of Right Sidebar / Subtasks Settings and Card Settings](https://github.com/wekan/wekan/commit/45537ede870eca59ad72cd7ad013a12f60032df4).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.11 2025-10-21 WeKan ® release
+
+This release fixes the following bugs:
+
+- [Fix due dates to use colors: red = overdue, amber = due soon, no shade = not due yet](https://github.com/wekan/wekan/commit/1aa0d849775fbd0dfc83fa8e4cdca84d22a15042).
+ Thanks to xet7.
+- [Fix My Due Cards to be sorted by due date, oldest first](https://github.com/wekan/wekan/commit/a540b12895520f398bce10bd244f733d221975d4).
+ Thanks to xet7.
+- [Verify that due background colors are correct also at My Due Cards](https://github.com/wekan/wekan/commit/665c9b5e522e73115a1515ced066037110db84e1).
+ Thanks to xet7.
+- [Fix Regression - due date taking a while to load all cards v8.06](https://github.com/wekan/wekan/commit/347fa9e5cd89d064ebb8ab544e20a41f52206db6).
+ Thanks to xet7.
+- Fix duplicated lists.
+ [Part 1](https://github.com/wekan/wekan/commit/b6e7b258e0e8caecafc553dceb5771985992a0f9),
+ [Part 2](https://github.com/wekan/wekan/commit/b7ca2310b2cdec7db204229b2d5b9f95b6da8c7d),
+ [Part 3](https://github.com/wekan/wekan/commit/58df525b4915a99d0f603cc2536fd1fad1d20b29).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.10 2025-10-21 WeKan ® release
+
+This release fixes the following bugs:
+
+- [Prevent opened board re-migrating and reloading every 5 seconds](https://github.com/wekan/wekan/commit/4987a95d8e35fc4cd30010fd17722ee94037d7f2).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.09 2025-10-21 WeKan ® release
+
+This release fixes the following bugs:
+
+- [Fix Admin Panel / People editing and layout](https://github.com/wekan/wekan/commit/7a585a3dfb080af51f88669ea5928f715779cee4).
+ Thanks to xet7.
+- [Fix upgrade to 8.08 duplicates lists](https://github.com/wekan/wekan/commit/c3a405222782a4a91eb8725faaa8309f0926dcc4).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.08 2025-10-21 WeKan ® release
+
+This release fixes the following bugs:
+
+- [Fix opening board migration of Shared Lists to Per-Swimlane lists to use ReactiveCache correctly without errors](https://github.com/wekan/wekan/commit/9536e60bd1c77c8a22e89d2eb2968e11da3a28cd).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.07 2025-10-20 WeKan ® release
+
+This release fixes the following bugs:
+
+- [Fix Snap Candidate WeKan 8.00-8.06 commit ae01ea5 database directory from /var/snap/wekan/common/wekan back to 8.07 /var/snap/wekan/common](https://github.com/wekan/wekan/commit/98f141d62f3b6d4371d024c72eae6688d0f4e516).
+ Thanks to xet7.
+- [When opening board, add missing lists](https://github.com/wekan/wekan/commit/80777b46638ed15b8194105751499ada4b066d19).
+ Thanks to xet7.
+- [If Snap Candidate MongoDB raw database files were at SNAP_COMMON/wekan, migrate them back to SNAP_COMMON](https://github.com/wekan/wekan/commit/f2019b1059c8d6f4cd9a46c3db7e004c4928cebb).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
+# v8.06 2025-10-20 WeKan ® release
+
+This release adds the following new features:
+
+- [At Public Board, drag resize list width and swimlane height. For logged in users, fix adding labels](https://github.com/wekan/wekan/commit/351433524708e9a7ccb4795d9ca31a78904943ea).
+ Thanks to xet7.
+- [When opening board, migrate from Shared Lists to Per-Swimlane Lists](https://github.com/wekan/wekan/commit/1e6252de7f26f3af14a99fb63b5dac27ba0576f3).
+ Thanks to xet7.
+- [Added Date Format setting to Opened Card](https://github.com/wekan/wekan/commit/2dd3916f7ee3df10bd88643cf2c796cb166b3044).
+ Thanks to xet7.
+
+and fixes the following bugs:
+
+- [Fix add and drag drop attachments to minicards and card](https://github.com/wekan/wekan/commit/b06daff4c7e63453643459f7d8798fde97e3200c).
+ Thanks to xet7.
+- [Fix starred, archive and clone icons](https://github.com/wekan/wekan/pull/5953).
+ Thanks to helioguardabaxo.
+- Fix Due dates to be color coded and have unicode icons.
+ [Part 1](https://github.com/wekan/wekan/commit/d965faa3174dc81636106e6f81435b2750b0625f),
+ [Part 2](https://github.com/wekan/wekan/commit/101048339bdd1e45f876aeb1aa5ec32ceda28139).
+ Thanks to xet7.
+- [Fix unable to see My Due Cards](https://github.com/wekan/wekan/commit/66b444e2b0c9b2ed5f98cd1ff0cd9222b2d0c624).
+ Thanks to xet7.
+- Fix drag drop lists.
+ [Part 1](https://github.com/wekan/wekan/commit/324f3f7794aace800022a24deb5fd5fb36ebd384),
+ [Part 2](https://github.com/wekan/wekan/commit/ff516ec696ef499f11b04b30053eeb9d3f96d8d1).
+ Thanks to xet7.
+- [Removed extra pipe characters](https://github.com/wekan/wekan/commit/caa6e615ff3c3681bf2b470a625eb39c6009b825).
+ Thanks to xet7.
+- [Fix syntax error at migrations](https://github.com/wekan/wekan/commit/eb6b42c4c9f99894fd93e62c9b3fceda3429c96c).
+ Thanks to xet7.
+- [Fix opened card attachments button text to be at tooltip, not at opened card](https://github.com/wekan/wekan/commit/1e53125499ef563ca3c65f786ac3525e5f50274c).
+ Thanks to xet7.
+- [Fix Broken Hyperlinks in Markdown to HTML conversion](https://github.com/wekan/wekan/commit/973a49526fdf22c143468d3d9db64269b1defa7d).
+ Thanks to xet7.
+- [Fix migrations](https://github.com/wekan/wekan/commit/0acbf30b0346f49c0ee8f5161fb00b4eca8e1a0c).
+ Thanks to xet7.
+- [Fix card popup to use HTML date, not anymore JQuery date](https://github.com/wekan/wekan/commit/2d44881619d78e8ef4c5060d17e9035f5babd778).
+ Thanks to xet7.
+- [Fix Bug: Scale of Minicard icons is linked to horizontal screensize](https://github.com/wekan/wekan/commit/b6b0c5fe6d7dbd37926c662f96f2e3653cabd867).
+ Thanks to xet7.
+- [Fix Bug Member settings drops to the second line and overlaps when many boards are starred as favourites](https://github.com/wekan/wekan/commit/46d46e313cbb8d9c3e4a976ec27b5141c266050f).
+ Thanks to xet7.
+- [Some mobile view fixes](https://github.com/wekan/wekan/commit/c4af4d03acc02f3e54e91f2a65bce2f88742b1a6).
+ Thanks to xet7.
+- [Have all iPhone use mobile view by default, while still having possibility to use mobile/desktop switch button for desktop mode](https://github.com/wekan/wekan/commit/5df4efd7ba06e618e454f068df05885306283bb1).
+ Thanks to xet7.
+
+Thanks to above GitHub users for their contributions and translators for their translations.
+
# v8.05 2025-10-17 WeKan ® release
This release fixes the following bugs:
diff --git a/Dockerfile b/Dockerfile
index fa9662261..aedb88c5b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -249,9 +249,9 @@ cd /home/wekan/app
# Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc.
#rm -rf /home/wekan/app_build/bundle/programs/web.browser.legacy
#mv /home/wekan/app_build/bundle /build
-wget "https://github.com/wekan/wekan/releases/download/v8.05/wekan-8.05-amd64.zip"
-unzip wekan-8.05-amd64.zip
-rm wekan-8.05-amd64.zip
+wget "https://github.com/wekan/wekan/releases/download/v8.17/wekan-8.17-amd64.zip"
+unzip wekan-8.17-amd64.zip
+rm wekan-8.17-amd64.zip
mv /home/wekan/app/bundle /build
# Put back the original tar
diff --git a/SECURITY.md b/SECURITY.md
index aadecbf6e..5cde5926b 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,12 +1,20 @@
-About money, see [CONTRIBUTING.md](CONTRIBUTING.md)
-Security is very important to us. If you discover any issue regarding security, please disclose
-the information responsibly by sending an email from Protonmail to security@wekan.fi
-that is Protomail email address, or by using this PGP key
-[security-at-wekan.fi.asc](security-at-wekan.fi.asc) to security@wekan.fi
-and not by creating a GitHub issue. We will respond swiftly to fix verifiable security issues.
+## Responsible Security Disclosure
-We thank you with a place at our hall of fame page, that is at https://wekan.fi/hall-of-fame
+- To send email, use [ProtonMail](https://proton.me) email address or use PGP key [security-at-wekan.fi.asc](security-at-wekan.fi.asc)
+- Send info about security issue ONLY to security@wekan.fi (that is Protomail email address). NOT TO ANYWHERE ELSE. NO CC, NO BCC.
+- Wait for new WeKan release that fixes security issue
+- If you approve, we thank you by adding you to Hall of Fame: https://wekan.fi/hall-of-fame/
+
+## Bonus Points
+
+- If you include code for fixing security issue
+
+## Losing Points
+
+- If you ask about [bounty](CONTRIBUTING.md). There is no bounty. WeKan is NOT Big Tech. WeKan is FLOSS.
+- If you forget to include vulnerability details.
+- If you send info about security issue to somewhere else than security@wekan.fi
## How should reports be formatted?
@@ -26,7 +34,7 @@ CWSS (optional): %cwss
Anyone who reports a unique security issue in scope and does not disclose it to
a third party before we have patched and updated may be upon their approval
-added to the Wekan Hall of Fame.
+added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
## Which domains are in scope?
@@ -63,11 +71,6 @@ and by by companies that have 30k users.
- If you are thinking about TLS MITM, look at https://github.com/caddyserver/caddy/issues/2530
- Let's Encrypt TLS requires publicly accessible webserver, that Let's Encrypt TLS validation servers check.
- If firewall limits to only allowed IP addresses, you may need non-Let's Encrypt TLS cert.
-- For On Premise:
- - https://caddyserver.com/docs/automatic-https#local-https
- - https://github.com/wekan/wekan/wiki/Caddy-Webserver-Config
- - https://github.com/wekan/wekan/wiki/Azure
- - https://github.com/wekan/wekan/wiki/Traefik-and-self-signed-SSL-certs
## XSS
@@ -172,6 +175,57 @@ Meteor.startup(() => {
- https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
- https://wekan.github.io/hall-of-fame/filebleed/
+### Attachments: Forced download to prevent stored XSS
+
+- To prevent browser-side execution of uploaded content under the app origin, all attachment downloads are served with safe headers:
+ - `Content-Type: application/octet-stream`
+ - `Content-Disposition: attachment`
+ - `X-Content-Type-Options: nosniff`
+ - A restrictive `Content-Security-Policy` with `sandbox`
+- This means attachments are downloaded instead of rendered inline by default. This mitigates HTML/JS/SVG based stored XSS vectors.
+- Avatars and inline images remain supported but SVG uploads are blocked and never rendered inline.
+
+## Users: Client update restrictions
+
+- Client-side updates to user documents are limited to safe fields only:
+ - `username`
+ - `profile.*`
+- Sensitive fields are blocked from any client updates and can only be modified by server methods with authorization:
+ - `orgs`, `teams`, `roles`, `isAdmin`, `createdThroughApi`, `loginDisabled`, `authenticationMethod`, `services.*`, `emails.*`, `sessionData.*`
+- Attempts to update forbidden fields from the client are denied.
+- Admin operations like managing org/team membership or toggling flags must use server methods that check permissions.
+
+## Voting: integrity and authorization
+
+- Client updates to card `vote` fields are blocked to prevent forged votes and inconsistent policy enforcement.
+- Voting is performed via a server method that enforces:
+ - Authentication and board membership, or an explicit per-card flag allowing non-members to vote.
+ - Only the caller's own userId is added/removed from `vote.positive`/`vote.negative`.
+- This prevents members from fabricating other users' votes and ensures non-members cannot vote unless explicitly allowed.
+
+## Planning Poker: integrity and authorization
+
+- Client updates to card `poker` fields are blocked. All poker actions go through server methods that enforce:
+ - Authentication and board membership for configuration and results.
+ - For casting a poker vote, either board membership or an explicit per-card flag allowing non-members to participate.
+ - Only the caller's own userId is added/removed from the selected estimation bucket (e.g., one, two, five, etc.).
+- Methods cover setting/unsetting poker question/end, casting votes, replaying, and setting final estimation.
+
+## Attachment API: authentication and DoS prevention
+
+- The attachment API (`/api/attachment/*`) requires proper authentication using `X-User-Id` and `X-Auth-Token` headers.
+- Authentication validates tokens by hashing with `Accounts._hashLoginToken` and matching against stored login tokens, preventing identity spoofing.
+- Request handlers implement:
+ - 30-second timeout to prevent hanging connections.
+ - Request body size limits (50MB for uploads, 10MB for metadata operations).
+ - Proper error handling and guaranteed response completion.
+ - Request error event handlers to clean up failed connections.
+- This prevents:
+ - DoS attacks via concurrent unauthenticated or malformed requests.
+ - Identity spoofing by using arbitrary bearer tokens or user IDs.
+ - Resource exhaustion from hanging connections or excessive payloads.
+- Access control: all attachment operations verify board membership before allowing access.
+
## Brute force login protection
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d
@@ -218,9 +272,4 @@ Typical already known or "no impact" bugs such as:
- Email spoofing, SPF, DMARC & DKIM. Wekan does not include email server.
Wekan is Open Source with MIT license, and free to use also for commercial use.
-We welcome all fixes to improve security by email to security@wekan.team
-
-## Bonus Points
-
-If your Responsible Security Disclosure includes code for fixing security issue,
-you get bonus points, as seen on [Hall of Fame](https://wekan.github.io/hall-of-fame).
+We welcome all fixes to improve security by email to security@wekan.fi
diff --git a/Stackerfile.yml b/Stackerfile.yml
index 0c0e85269..e0529f92f 100644
--- a/Stackerfile.yml
+++ b/Stackerfile.yml
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
-appVersion: "v8.05.0"
+appVersion: "v8.17.0"
files:
userUploads:
- README.md
diff --git a/client/00-startup.js b/client/00-startup.js
index a6f049322..52a1c536c 100644
--- a/client/00-startup.js
+++ b/client/00-startup.js
@@ -15,3 +15,50 @@ import '/client/components/migrationProgress';
// Import cron settings
import '/client/components/settings/cronSettings';
+
+// Mirror Meteor login token into a cookie for server-side file route auth
+// This enables cookie-based auth for /cdn/storage/* without leaking ROOT_URL
+// Token already lives in localStorage; cookie adds same-origin send-on-request semantics
+Meteor.startup(() => {
+ const COOKIE_NAME = 'meteor_login_token';
+ const cookieAttrs = () => {
+ const attrs = ['Path=/', 'SameSite=Lax'];
+ try {
+ if (window.location && window.location.protocol === 'https:') {
+ attrs.push('Secure');
+ }
+ } catch (_) {}
+ return attrs.join('; ');
+ };
+
+ const setCookie = (name, value) => {
+ if (!value) return;
+ document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; ${cookieAttrs()}`;
+ };
+ const clearCookie = (name) => {
+ document.cookie = `${encodeURIComponent(name)}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; ${cookieAttrs()}`;
+ };
+
+ const syncCookie = () => {
+ try {
+ const token = Accounts && typeof Accounts._storedLoginToken === 'function' ? Accounts._storedLoginToken() : null;
+ if (token) setCookie(COOKIE_NAME, token); else clearCookie(COOKIE_NAME);
+ } catch (e) {
+ // ignore
+ }
+ };
+
+ // Initial sync on startup
+ syncCookie();
+
+ // Keep cookie in sync on login/logout
+ if (Accounts && typeof Accounts.onLogin === 'function') Accounts.onLogin(syncCookie);
+ if (Accounts && typeof Accounts.onLogout === 'function') Accounts.onLogout(syncCookie);
+
+ // Sync across tabs/windows when localStorage changes
+ window.addEventListener('storage', (ev) => {
+ if (ev && typeof ev.key === 'string' && ev.key.indexOf('Meteor.loginToken') !== -1) {
+ syncCookie();
+ }
+ });
+});
diff --git a/client/components/boards/boardBody.css b/client/components/boards/boardBody.css
index 32770eda6..f65cbaffc 100644
--- a/client/components/boards/boardBody.css
+++ b/client/components/boards/boardBody.css
@@ -269,56 +269,71 @@
}
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
.board-wrapper.mobile-view {
- width: 100% !important;
- min-width: 100% !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
}
.board-wrapper.mobile-view .board-canvas {
- width: 100% !important;
- min-width: 100% !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
}
.board-wrapper.mobile-view .board-canvas.mobile-view .swimlane {
border-bottom: 1px solid #ccc;
- display: flex;
+ display: block !important;
flex-direction: column;
margin: 0;
padding: 0;
- overflow-x: hidden;
+ overflow-x: hidden !important;
overflow-y: auto;
- width: 100%;
- min-width: 100%;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
}
-@media screen and (max-width: 800px) {
+@media screen and (max-width: 800px),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.board-wrapper {
- width: 100% !important;
- min-width: 100% !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
}
.board-wrapper .board-canvas {
- width: 100% !important;
- min-width: 100% !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
}
.board-wrapper .board-canvas .swimlane {
border-bottom: 1px solid #ccc;
- display: flex;
+ display: block !important;
flex-direction: column;
margin: 0;
padding: 0;
- overflow-x: hidden;
+ overflow-x: hidden !important;
overflow-y: auto;
- width: 100%;
- min-width: 100%;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
}
}
.calendar-event-green {
@@ -496,3 +511,10 @@
font-size: 25px;
cursor: pointer;
}
+
+/* Global file drag over state for board canvas */
+.board-canvas.file-drag-over {
+ background-color: rgba(0, 123, 255, 0.05) !important;
+ border: 2px dashed #007bff !important;
+ transition: all 0.2s ease;
+}
diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js
index c7d77eb93..b0af16e43 100644
--- a/client/components/boards/boardBody.js
+++ b/client/components/boards/boardBody.js
@@ -4,6 +4,7 @@ import dragscroll from '@wekanteam/dragscroll';
import { boardConverter } from '/client/lib/boardConverter';
import { migrationManager } from '/client/lib/migrationManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
+import { migrationProgressManager } from '/client/components/migrationProgress';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
@@ -17,6 +18,8 @@ BlazeComponent.extendComponent({
this.isConverting = new ReactiveVar(false);
this.isMigrating = new ReactiveVar(false);
this._swimlaneCreated = new Set(); // Track boards where we've created swimlanes
+ this._boardProcessed = false; // Track if board has been processed
+ this._lastProcessedBoardId = null; // Track last processed board ID
// The pattern we use to manually handle data loading is described here:
// https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management/using-subs-manager
@@ -28,21 +31,33 @@ BlazeComponent.extendComponent({
const handle = subManager.subscribe('board', currentBoardId, false);
- Tracker.nonreactive(() => {
- Tracker.autorun(() => {
- if (handle.ready()) {
+ // Use a separate autorun for subscription ready state to avoid reactive loops
+ this.subscriptionReadyAutorun = Tracker.autorun(() => {
+ if (handle.ready()) {
+ // Only run conversion/migration logic once per board
+ if (!this._boardProcessed || this._lastProcessedBoardId !== currentBoardId) {
+ this._boardProcessed = true;
+ this._lastProcessedBoardId = currentBoardId;
+
// Ensure default swimlane exists (only once per board)
this.ensureDefaultSwimlane(currentBoardId);
// Check if board needs conversion
this.checkAndConvertBoard(currentBoardId);
- } else {
- this.isBoardReady.set(false);
}
- });
+ } else {
+ this.isBoardReady.set(false);
+ }
});
});
},
+ onDestroyed() {
+ // Clean up the subscription ready autorun to prevent memory leaks
+ if (this.subscriptionReadyAutorun) {
+ this.subscriptionReadyAutorun.stop();
+ }
+ },
+
ensureDefaultSwimlane(boardId) {
// Only create swimlane once per board
if (this._swimlaneCreated.has(boardId)) {
@@ -56,10 +71,17 @@ BlazeComponent.extendComponent({
const swimlanes = board.swimlanes();
if (swimlanes.length === 0) {
- const swimlaneId = Swimlanes.insert({
- title: 'Default',
- boardId: boardId,
- });
+ // Check if any swimlane exists in the database to avoid race conditions
+ const existingSwimlanes = ReactiveCache.getSwimlanes({ boardId });
+ if (existingSwimlanes.length === 0) {
+ const swimlaneId = Swimlanes.insert({
+ title: 'Default',
+ boardId: boardId,
+ });
+ if (process.env.DEBUG === 'true') {
+ console.log(`Created default swimlane ${swimlaneId} for board ${boardId}`);
+ }
+ }
this._swimlaneCreated.add(boardId);
} else {
this._swimlaneCreated.add(boardId);
@@ -77,43 +99,10 @@ BlazeComponent.extendComponent({
return;
}
- // Check if board needs migration based on migration version
- const needsMigration = !board.migrationVersion || board.migrationVersion < 1;
-
- if (needsMigration) {
- // Start background migration for old boards
- this.isMigrating.set(true);
- await this.startBackgroundMigration(boardId);
- this.isMigrating.set(false);
- }
+ // Automatic migration disabled - migrations must be run manually from sidebar
+ // Board admins can run migrations from the sidebar Migrations menu
+ this.isBoardReady.set(true);
- // Check if board needs conversion (for old structure)
- if (boardConverter.isBoardConverted(boardId)) {
- if (process.env.DEBUG === 'true') {
- console.log(`Board ${boardId} has already been converted, skipping conversion`);
- }
- this.isBoardReady.set(true);
- } else {
- const needsConversion = boardConverter.needsConversion(boardId);
-
- if (needsConversion) {
- this.isConverting.set(true);
- const success = await boardConverter.convertBoard(boardId);
- this.isConverting.set(false);
-
- if (success) {
- this.isBoardReady.set(true);
- } else {
- console.error('Board conversion failed, setting ready to true anyway');
- this.isBoardReady.set(true); // Still show board even if conversion failed
- }
- } else {
- this.isBoardReady.set(true);
- }
- }
-
- // Start attachment migration in background if needed
- this.startAttachmentMigrationIfNeeded(boardId);
} catch (error) {
console.error('Error during board conversion check:', error);
this.isConverting.set(false);
@@ -122,6 +111,136 @@ BlazeComponent.extendComponent({
}
},
+ /**
+ * Check if board needs comprehensive migration
+ */
+ async checkComprehensiveMigration(boardId) {
+ try {
+ return new Promise((resolve, reject) => {
+ Meteor.call('comprehensiveBoardMigration.needsMigration', boardId, (error, result) => {
+ if (error) {
+ console.error('Error checking comprehensive migration:', error);
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+ } catch (error) {
+ console.error('Error checking comprehensive migration:', error);
+ return false;
+ }
+ },
+
+ /**
+ * Execute comprehensive migration for a board
+ */
+ async executeComprehensiveMigration(boardId) {
+ try {
+ // Start progress tracking
+ migrationProgressManager.startMigration();
+
+ // Simulate progress updates since we can't easily pass callbacks through Meteor methods
+ const progressSteps = [
+ { step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 1000 },
+ { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 },
+ { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 },
+ { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 },
+ { step: 'validate_migration', name: 'Validate Migration', duration: 1000 },
+ { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 },
+ { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 }
+ ];
+
+ // Start the actual migration
+ const migrationPromise = new Promise((resolve, reject) => {
+ Meteor.call('comprehensiveBoardMigration.execute', boardId, (error, result) => {
+ if (error) {
+ console.error('Error executing comprehensive migration:', error);
+ migrationProgressManager.failMigration(error);
+ reject(error);
+ } else {
+ if (process.env.DEBUG === 'true') {
+ console.log('Comprehensive migration completed for board:', boardId, result);
+ }
+ resolve(result.success);
+ }
+ });
+ });
+
+ // Simulate progress updates
+ const progressPromise = this.simulateMigrationProgress(progressSteps);
+
+ // Wait for both to complete
+ const [migrationResult] = await Promise.all([migrationPromise, progressPromise]);
+
+ migrationProgressManager.completeMigration();
+ return migrationResult;
+
+ } catch (error) {
+ console.error('Error executing comprehensive migration:', error);
+ migrationProgressManager.failMigration(error);
+ return false;
+ }
+ },
+
+ /**
+ * Simulate migration progress updates
+ */
+ async simulateMigrationProgress(progressSteps) {
+ const totalSteps = progressSteps.length;
+
+ for (let i = 0; i < progressSteps.length; i++) {
+ const step = progressSteps[i];
+ const stepProgress = Math.round(((i + 1) / totalSteps) * 100);
+
+ // Update progress for this step
+ migrationProgressManager.updateProgress({
+ overallProgress: stepProgress,
+ currentStep: i + 1,
+ totalSteps,
+ stepName: step.step,
+ stepProgress: 0,
+ stepStatus: `Starting ${step.name}...`,
+ stepDetails: null,
+ boardId: Session.get('currentBoard')
+ });
+
+ // Simulate step progress
+ const stepDuration = step.duration;
+ const updateInterval = 100; // Update every 100ms
+ const totalUpdates = stepDuration / updateInterval;
+
+ for (let j = 0; j < totalUpdates; j++) {
+ const stepStepProgress = Math.round(((j + 1) / totalUpdates) * 100);
+
+ migrationProgressManager.updateProgress({
+ overallProgress: stepProgress,
+ currentStep: i + 1,
+ totalSteps,
+ stepName: step.step,
+ stepProgress: stepStepProgress,
+ stepStatus: `Processing ${step.name}...`,
+ stepDetails: { progress: `${stepStepProgress}%` },
+ boardId: Session.get('currentBoard')
+ });
+
+ await new Promise(resolve => setTimeout(resolve, updateInterval));
+ }
+
+ // Complete the step
+ migrationProgressManager.updateProgress({
+ overallProgress: stepProgress,
+ currentStep: i + 1,
+ totalSteps,
+ stepName: step.step,
+ stepProgress: 100,
+ stepStatus: `${step.name} completed`,
+ stepDetails: { status: 'completed' },
+ boardId: Session.get('currentBoard')
+ });
+ }
+ },
+
async startBackgroundMigration(boardId) {
try {
// Start background migration using the cron system
@@ -139,6 +258,227 @@ BlazeComponent.extendComponent({
}
},
+ async convertSharedListsToPerSwimlane(boardId) {
+ try {
+ const board = ReactiveCache.getBoard(boardId);
+ if (!board) return;
+
+ // Check if board has already been processed for shared lists conversion
+ if (board.hasSharedListsConverted) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Board ${boardId} has already been processed for shared lists conversion`);
+ }
+ return;
+ }
+
+ // Get all lists for this board
+ const allLists = board.lists();
+ const swimlanes = board.swimlanes();
+
+ if (swimlanes.length === 0) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Board ${boardId} has no swimlanes, skipping shared lists conversion`);
+ }
+ return;
+ }
+
+ // Find shared lists (lists with empty swimlaneId or null swimlaneId)
+ const sharedLists = allLists.filter(list => !list.swimlaneId || list.swimlaneId === '');
+
+ if (sharedLists.length === 0) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Board ${boardId} has no shared lists to convert`);
+ }
+ // Mark as processed even if no shared lists
+ Boards.update(boardId, { $set: { hasSharedListsConverted: true } });
+ return;
+ }
+
+ if (process.env.DEBUG === 'true') {
+ console.log(`Converting ${sharedLists.length} shared lists to per-swimlane lists for board ${boardId}`);
+ }
+
+ // Convert each shared list to per-swimlane lists
+ for (const sharedList of sharedLists) {
+ // Create a copy of the list for each swimlane
+ for (const swimlane of swimlanes) {
+ // Check if this list already exists in this swimlane
+ const existingList = Lists.findOne({
+ boardId: boardId,
+ swimlaneId: swimlane._id,
+ title: sharedList.title
+ });
+
+ if (!existingList) {
+ // Double-check to avoid race conditions
+ const doubleCheckList = ReactiveCache.getList({
+ boardId: boardId,
+ swimlaneId: swimlane._id,
+ title: sharedList.title
+ });
+
+ if (!doubleCheckList) {
+ // Create a new list in this swimlane
+ const newListData = {
+ title: sharedList.title,
+ boardId: boardId,
+ swimlaneId: swimlane._id,
+ sort: sharedList.sort || 0,
+ archived: sharedList.archived || false, // Preserve archived state from original list
+ createdAt: new Date(),
+ modifiedAt: new Date()
+ };
+
+ // Copy other properties if they exist
+ if (sharedList.color) newListData.color = sharedList.color;
+ if (sharedList.wipLimit) newListData.wipLimit = sharedList.wipLimit;
+ if (sharedList.wipLimitEnabled) newListData.wipLimitEnabled = sharedList.wipLimitEnabled;
+ if (sharedList.wipLimitSoft) newListData.wipLimitSoft = sharedList.wipLimitSoft;
+
+ Lists.insert(newListData);
+
+ if (process.env.DEBUG === 'true') {
+ const archivedStatus = sharedList.archived ? ' (archived)' : ' (active)';
+ console.log(`Created list "${sharedList.title}"${archivedStatus} for swimlane ${swimlane.title || swimlane._id}`);
+ }
+ } else {
+ if (process.env.DEBUG === 'true') {
+ console.log(`List "${sharedList.title}" already exists in swimlane ${swimlane.title || swimlane._id} (double-check), skipping`);
+ }
+ }
+ } else {
+ if (process.env.DEBUG === 'true') {
+ console.log(`List "${sharedList.title}" already exists in swimlane ${swimlane.title || swimlane._id}, skipping`);
+ }
+ }
+ }
+
+ // Remove the original shared list completely
+ Lists.remove(sharedList._id);
+
+ if (process.env.DEBUG === 'true') {
+ console.log(`Removed shared list "${sharedList.title}"`);
+ }
+ }
+
+ // Mark board as processed
+ Boards.update(boardId, { $set: { hasSharedListsConverted: true } });
+
+ if (process.env.DEBUG === 'true') {
+ console.log(`Successfully converted ${sharedLists.length} shared lists to per-swimlane lists for board ${boardId}`);
+ }
+
+ } catch (error) {
+ console.error('Error converting shared lists to per-swimlane:', error);
+ }
+ },
+
+ async fixMissingLists(boardId) {
+ try {
+ const board = ReactiveCache.getBoard(boardId);
+ if (!board) return;
+
+ // Check if board has already been processed for missing lists fix
+ if (board.fixMissingListsCompleted) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Board ${boardId} has already been processed for missing lists fix`);
+ }
+ return;
+ }
+
+ // Check if migration is needed
+ const needsMigration = await new Promise((resolve, reject) => {
+ Meteor.call('fixMissingListsMigration.needsMigration', boardId, (error, result) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+
+ if (!needsMigration) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Board ${boardId} does not need missing lists fix`);
+ }
+ return;
+ }
+
+ if (process.env.DEBUG === 'true') {
+ console.log(`Starting fix missing lists migration for board ${boardId}`);
+ }
+
+ // Execute the migration
+ const result = await new Promise((resolve, reject) => {
+ Meteor.call('fixMissingListsMigration.execute', boardId, (error, result) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+
+ if (result && result.success) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Successfully fixed missing lists for board ${boardId}: created ${result.createdLists} lists, updated ${result.updatedCards} cards`);
+ }
+ }
+
+ } catch (error) {
+ console.error('Error fixing missing lists:', error);
+ }
+ },
+
+ async fixDuplicateLists(boardId) {
+ try {
+ const board = ReactiveCache.getBoard(boardId);
+ if (!board) return;
+
+ // Check if board has already been processed for duplicate lists fix
+ if (board.fixDuplicateListsCompleted) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Board ${boardId} has already been processed for duplicate lists fix`);
+ }
+ return;
+ }
+
+ if (process.env.DEBUG === 'true') {
+ console.log(`Starting duplicate lists fix for board ${boardId}`);
+ }
+
+ // Execute the duplicate lists fix
+ const result = await new Promise((resolve, reject) => {
+ Meteor.call('fixDuplicateLists.fixBoard', boardId, (error, result) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+
+ if (result && result.fixed > 0) {
+ if (process.env.DEBUG === 'true') {
+ console.log(`Successfully fixed ${result.fixed} duplicate lists for board ${boardId}: ${result.fixedSwimlanes} swimlanes, ${result.fixedLists} lists`);
+ }
+
+ // Mark board as processed
+ Boards.update(boardId, { $set: { fixDuplicateListsCompleted: true } });
+ } else if (process.env.DEBUG === 'true') {
+ console.log(`No duplicate lists found for board ${boardId}`);
+ // Still mark as processed to avoid repeated checks
+ Boards.update(boardId, { $set: { fixDuplicateListsCompleted: true } });
+ } else {
+ // Still mark as processed to avoid repeated checks
+ Boards.update(boardId, { $set: { fixDuplicateListsCompleted: true } });
+ }
+
+ } catch (error) {
+ console.error('Error fixing duplicate lists:', error);
+ }
+ },
+
async startAttachmentMigrationIfNeeded(boardId) {
try {
// Check if board has already been migrated
@@ -204,39 +544,50 @@ BlazeComponent.extendComponent({
this._isDragging = false;
// Used to set the overlay
this.mouseHasEnterCardDetails = false;
+ this._sortFieldsFixed = new Set(); // Track which boards have had sort fields fixed
// fix swimlanes sort field if there are null values
const currentBoardData = Utils.getCurrentBoard();
if (currentBoardData && Swimlanes) {
- const nullSortSwimlanes = currentBoardData.nullSortSwimlanes();
- if (nullSortSwimlanes.length > 0) {
- const swimlanes = currentBoardData.swimlanes();
- let count = 0;
- swimlanes.forEach(s => {
- Swimlanes.update(s._id, {
- $set: {
- sort: count,
- },
+ const boardId = currentBoardData._id;
+ // Only fix sort fields once per board to prevent reactive loops
+ if (!this._sortFieldsFixed.has(`swimlanes-${boardId}`)) {
+ const nullSortSwimlanes = currentBoardData.nullSortSwimlanes();
+ if (nullSortSwimlanes.length > 0) {
+ const swimlanes = currentBoardData.swimlanes();
+ let count = 0;
+ swimlanes.forEach(s => {
+ Swimlanes.update(s._id, {
+ $set: {
+ sort: count,
+ },
+ });
+ count += 1;
});
- count += 1;
- });
+ }
+ this._sortFieldsFixed.add(`swimlanes-${boardId}`);
}
}
// fix lists sort field if there are null values
if (currentBoardData && Lists) {
- const nullSortLists = currentBoardData.nullSortLists();
- if (nullSortLists.length > 0) {
- const lists = currentBoardData.lists();
- let count = 0;
- lists.forEach(l => {
- Lists.update(l._id, {
- $set: {
- sort: count,
- },
+ const boardId = currentBoardData._id;
+ // Only fix sort fields once per board to prevent reactive loops
+ if (!this._sortFieldsFixed.has(`lists-${boardId}`)) {
+ const nullSortLists = currentBoardData.nullSortLists();
+ if (nullSortLists.length > 0) {
+ const lists = currentBoardData.lists();
+ let count = 0;
+ lists.forEach(l => {
+ Lists.update(l._id, {
+ $set: {
+ sort: count,
+ },
+ });
+ count += 1;
});
- count += 1;
- });
+ }
+ this._sortFieldsFixed.add(`lists-${boardId}`);
}
}
},
@@ -550,22 +901,20 @@ BlazeComponent.extendComponent({
// Always reset dragscroll on view switch
dragscroll.reset();
- if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
- $swimlanesDom.sortable({
- handle: '.js-swimlane-header-handle',
- });
- } else {
- $swimlanesDom.sortable({
- handle: '.swimlane-header',
- });
- }
+ if ($swimlanesDom.data('uiSortable') || $swimlanesDom.data('sortable')) {
+ if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
+ $swimlanesDom.sortable('option', 'handle', '.js-swimlane-header-handle');
+ } else {
+ $swimlanesDom.sortable('option', 'handle', '.swimlane-header');
+ }
- // Disable drag-dropping if the current user is not a board member
- $swimlanesDom.sortable(
- 'option',
- 'disabled',
- !ReactiveCache.getCurrentUser()?.isBoardAdmin(),
- );
+ // Disable drag-dropping if the current user is not a board member
+ $swimlanesDom.sortable(
+ 'option',
+ 'disabled',
+ !ReactiveCache.getCurrentUser()?.isBoardAdmin(),
+ );
+ }
});
// If there is no data in the board (ie, no lists) we autofocus the list
@@ -721,6 +1070,31 @@ BlazeComponent.extendComponent({
}
},
'click .js-empty-board-add-swimlane': Popup.open('swimlaneAdd'),
+ // Global drag and drop file upload handlers for better visual feedback
+ 'dragover .board-canvas'(event) {
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ // Add visual indicator that files can be dropped
+ $('.board-canvas').addClass('file-drag-over');
+ }
+ },
+ 'dragleave .board-canvas'(event) {
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ // Only remove class if we're leaving the board canvas entirely
+ if (!event.currentTarget.contains(event.relatedTarget)) {
+ $('.board-canvas').removeClass('file-drag-over');
+ }
+ }
+ },
+ 'drop .board-canvas'(event) {
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ $('.board-canvas').removeClass('file-drag-over');
+ }
+ },
},
];
},
@@ -997,9 +1371,13 @@ BlazeComponent.extendComponent({
const firstSwimlane = currentBoard.swimlanes()[0];
Meteor.call('createCardWithDueDate', currentBoard._id, firstList._id, myTitle, startDate.toDate(), firstSwimlane._id, function(error, result) {
if (error) {
- console.log(error);
+ if (process.env.DEBUG === 'true') {
+ console.log(error);
+ }
} else {
- console.log("Card Created", result);
+ if (process.env.DEBUG === 'true') {
+ console.log("Card Created", result);
+ }
}
});
closeModal();
diff --git a/client/components/boards/boardHeader.css b/client/components/boards/boardHeader.css
index f3cb652e7..faf20e2f5 100644
--- a/client/components/boards/boardHeader.css
+++ b/client/components/boards/boardHeader.css
@@ -505,73 +505,73 @@
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
- width: 100% !important;
- max-width: 100% !important;
- min-width: 100% !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
-.mobile-mode .swimlane {
- display: block !important;
- width: 100% !important;
- max-width: 100% !important;
- min-width: 100% !important;
- margin: 0 0 2rem 0 !important;
- padding: 0 !important;
- float: none !important;
- clear: both !important;
-}
+ .mobile-mode .swimlane {
+ display: block !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
+ margin: 0 0 2rem 0 !important;
+ padding: 0 !important;
+ float: none !important;
+ clear: both !important;
+ }
-.mobile-mode .swimlane .swimlane-header {
- display: block !important;
- width: 100% !important;
- max-width: 100% !important;
- min-width: 100% !important;
- margin: 0 0 1rem 0 !important;
- padding: 1rem !important;
- font-size: clamp(18px, 2.5vw, 32px) !important;
- font-weight: bold !important;
- border-bottom: 2px solid #ccc !important;
-}
+ .mobile-mode .swimlane .swimlane-header {
+ display: block !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
+ margin: 0 0 1rem 0 !important;
+ padding: 1rem !important;
+ font-size: clamp(18px, 2.5vw, 32px) !important;
+ font-weight: bold !important;
+ border-bottom: 2px solid #ccc !important;
+ }
-.mobile-mode .swimlane .lists {
- display: block !important;
- width: 100% !important;
- max-width: 100% !important;
- min-width: 100% !important;
- margin: 0 !important;
- padding: 0 !important;
- flex-direction: column !important;
- flex-wrap: nowrap !important;
- align-items: stretch !important;
- justify-content: flex-start !important;
-}
+ .mobile-mode .swimlane .lists {
+ display: block !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ flex-direction: column !important;
+ flex-wrap: nowrap !important;
+ align-items: stretch !important;
+ justify-content: flex-start !important;
+ }
-.mobile-mode .list {
- display: block !important;
- width: 100% !important;
- max-width: 100% !important;
- min-width: 100% !important;
- margin: 0 0 2rem 0 !important;
- padding: 0 !important;
- float: none !important;
- clear: both !important;
- border-left: none !important;
- border-right: none !important;
- border-top: none !important;
- border-bottom: 2px solid #ccc !important;
- flex: none !important;
- flex-basis: auto !important;
- flex-grow: 0 !important;
- flex-shrink: 0 !important;
- position: static !important;
- left: auto !important;
- right: auto !important;
- top: auto !important;
- bottom: auto !important;
- transform: none !important;
-}
+ .mobile-mode .list {
+ display: block !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
+ margin: 0 0 2rem 0 !important;
+ padding: 0 !important;
+ float: none !important;
+ clear: both !important;
+ border-left: none !important;
+ border-right: none !important;
+ border-top: none !important;
+ border-bottom: 2px solid #ccc !important;
+ flex: none !important;
+ flex-basis: auto !important;
+ flex-grow: 0 !important;
+ flex-shrink: 0 !important;
+ position: static !important;
+ left: auto !important;
+ right: auto !important;
+ top: auto !important;
+ bottom: auto !important;
+ transform: none !important;
+ }
.mobile-mode .list:first-child {
margin-left: 0 !important;
@@ -667,9 +667,9 @@
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
- width: 100% !important;
- max-width: 100% !important;
- min-width: 100% !important;
+ width: 100vw !important;
+ max-width: 100vw !important;
+ min-width: 100vw !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade
index 208753243..bac4216ed 100644
--- a/client/components/boards/boardHeader.jade
+++ b/client/components/boards/boardHeader.jade
@@ -16,13 +16,6 @@ template(name="boardHeaderBar")
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
| ✏️
- a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
- title="{{#if isStarred}}{{_ 'star-board-short-unstar'}}{{else}}{{_ 'star-board-short-star'}}{{/if}}" aria-label="{{#if isStarred}}{{_ 'star-board-short-unstar'}}{{else}}{{_ 'star-board-short-star'}}{{/if}}")
- | {{#if isStarred}}⭐{{else}}☆{{/if}}
- if showStarCounter
- span
- = currentBoard.stars
-
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
@@ -38,6 +31,13 @@ template(name="boardHeaderBar")
if $eq watchLevel "muted"
| 🔕
span {{_ watchLevel}}
+ a.board-header-btn.js-star-board(title="{{_ 'star-board'}}")
+ if isStarred
+ | ⭐
+ else
+ | ☆
+ if showStarCounter
+ span.board-star-counter {{currentBoard.stars}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
| {{sortCardsIcon}}
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
@@ -61,10 +61,6 @@ template(name="boardHeaderBar")
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
| ✏️
- a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
- title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
- | {{#if isStarred}}⭐{{else}}☆{{/if}}
-
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
@@ -78,6 +74,11 @@ template(name="boardHeaderBar")
| 🔔
if $eq watchLevel "muted"
| 🔕
+ a.board-header-btn.js-star-board(title="{{_ 'star-board'}}")
+ if isStarred
+ | ⭐
+ else
+ | ☆
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
| {{sortCardsIcon}}
if isSortActive
@@ -237,6 +238,65 @@ template(name="createBoard")
| /
a.js-board-template {{_ 'template'}}
+template(name="createBoardPopup")
+ form
+ label
+ | {{_ 'title'}}
+ input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
+ if visibilityMenuIsOpen.get
+ +boardVisibilityList
+ else
+ p.quiet
+ if $eq visibility.get 'public'
+ span 🌐
+ = " "
+ | {{{_ 'board-public-info'}}}
+ else
+ span 🔒
+ = " "
+ | {{{_ 'board-private-info'}}}
+ a.js-change-visibility {{_ 'change'}}.
+ a.flex.js-toggle-add-template-container
+ .materialCheckBox#add-template-container
+ span {{_ 'add-template-container'}}
+ input.primary.wide(type="submit" value="{{_ 'create'}}")
+ span.quiet
+ | {{_ 'or'}}
+ a.js-import-board {{_ 'import'}}
+ span.quiet
+ | /
+ a.js-board-template {{_ 'template'}}
+
+// New popup for Template Container creation; shares the same form content
+template(name="createTemplateContainerPopup")
+ form
+ label
+ | {{_ 'title'}}
+ input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
+ if visibilityMenuIsOpen.get
+ +boardVisibilityList
+ else
+ p.quiet
+ if $eq visibility.get 'public'
+ span 🌐
+ = " "
+ | {{{_ 'board-public-info'}}}
+ else
+ span 🔒
+ = " "
+ | {{{_ 'board-private-info'}}}
+ a.js-change-visibility {{_ 'change'}}.
+ a.flex.js-toggle-add-template-container
+ .materialCheckBox#add-template-container
+ span {{_ 'add-template-container'}}
+ input.primary.wide(type="submit" value="{{_ 'create'}}")
+ span.quiet
+ | {{_ 'or'}}
+ a.js-import-board {{_ 'import'}}
+ span.quiet
+ | /
+ a.js-board-template {{_ 'template'}}
+
//template(name="listsortPopup")
// h2
// | {{_ 'list-sort-by'}}
diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js
index d11857f3e..c84b593c6 100644
--- a/client/components/boards/boardHeader.js
+++ b/client/components/boards/boardHeader.js
@@ -72,7 +72,10 @@ BlazeComponent.extendComponent({
{
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
'click .js-star-board'() {
- ReactiveCache.getCurrentUser().toggleBoardStar(Session.get('currentBoard'));
+ const boardId = Session.get('currentBoard');
+ if (boardId) {
+ Meteor.call('toggleBoardStar', boardId);
+ }
},
'click .js-open-board-menu': Popup.open('boardMenu'),
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
@@ -82,18 +85,26 @@ BlazeComponent.extendComponent({
},
'click .js-toggle-board-view': Popup.open('boardChangeView'),
'click .js-toggle-sidebar'() {
- console.log('Hamburger menu clicked');
+ if (process.env.DEBUG === 'true') {
+ console.log('Hamburger menu clicked');
+ }
// Use the same approach as keyboard shortcuts
if (typeof Sidebar !== 'undefined' && Sidebar && typeof Sidebar.toggle === 'function') {
- console.log('Using Sidebar.toggle()');
+ if (process.env.DEBUG === 'true') {
+ console.log('Using Sidebar.toggle()');
+ }
Sidebar.toggle();
} else {
- console.warn('Sidebar not available, trying alternative approach');
+ if (process.env.DEBUG === 'true') {
+ console.warn('Sidebar not available, trying alternative approach');
+ }
// Try to trigger the sidebar through the global Blaze helper
if (typeof Blaze !== 'undefined' && Blaze._globalHelpers && Blaze._globalHelpers.Sidebar) {
const sidebar = Blaze._globalHelpers.Sidebar();
if (sidebar && typeof sidebar.toggle === 'function') {
- console.log('Using Blaze helper Sidebar.toggle()');
+ if (process.env.DEBUG === 'true') {
+ console.log('Using Blaze helper Sidebar.toggle()');
+ }
sidebar.toggle();
}
}
@@ -155,6 +166,7 @@ BlazeComponent.extendComponent({
},
];
},
+
}).register('boardHeaderBar');
Template.boardHeaderBar.helpers({
@@ -282,6 +294,15 @@ const CreateBoard = BlazeComponent.extendComponent({
},
);
+ // Assign to space if one was selected
+ const spaceId = Session.get('createBoardInWorkspace');
+ if (spaceId) {
+ Meteor.call('assignBoardToWorkspace', this.boardId.get(), spaceId, (err) => {
+ if (err) console.error('Error assigning board to space:', err);
+ });
+ Session.set('createBoardInWorkspace', null); // Clear after use
+ }
+
Utils.goBoardId(this.boardId.get());
} else {
@@ -300,6 +321,15 @@ const CreateBoard = BlazeComponent.extendComponent({
boardId: this.boardId.get(),
});
+ // Assign to space if one was selected
+ const spaceId = Session.get('createBoardInWorkspace');
+ if (spaceId) {
+ Meteor.call('assignBoardToWorkspace', this.boardId.get(), spaceId, (err) => {
+ if (err) console.error('Error assigning board to space:', err);
+ });
+ Session.set('createBoardInWorkspace', null); // Clear after use
+ }
+
Utils.goBoardId(this.boardId.get());
}
},
@@ -321,6 +351,13 @@ const CreateBoard = BlazeComponent.extendComponent({
},
}).register('createBoardPopup');
+(class CreateTemplateContainerPopup extends CreateBoard {
+ onRendered() {
+ // Always pre-check the template container checkbox for this popup
+ $('#add-template-container').addClass('is-checked');
+ }
+}).register('createTemplateContainerPopup');
+
(class HeaderBarCreateBoard extends CreateBoard {
onSubmit(event) {
super.onSubmit(event);
diff --git a/client/components/boards/boardsList.css b/client/components/boards/boardsList.css
index 995b22445..dc7efdd66 100644
--- a/client/components/boards/boardsList.css
+++ b/client/components/boards/boardsList.css
@@ -8,6 +8,273 @@
padding: 1vh 0;
}
+/* Two-column layout for All Boards */
+.boards-layout {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ gap: 16px;
+}
+
+.boards-left-menu {
+ border-right: 1px solid #e0e0e0;
+ padding-right: 12px;
+}
+
+.boards-left-menu ul.menu {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 12px 0;
+}
+
+.boards-left-menu .menu-item {
+ margin: 4px 0;
+}
+.boards-left-menu .menu-item a {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+.boards-left-menu .menu-item .menu-label {
+ flex: 1;
+}
+.boards-left-menu .menu-item .menu-count {
+ background: #ddd;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: bold;
+ margin-left: 8px;
+}
+.boards-left-menu .menu-item.active a,
+.boards-left-menu .menu-item a:hover {
+ background: #f0f0f0;
+}
+.boards-left-menu .menu-item.active .menu-count {
+ background: #bbb;
+}
+
+/* Drag-over state for menu items (for dropping boards on Remaining) */
+.boards-left-menu .menu-item a.drag-over {
+ background: #d0e8ff;
+ border: 2px dashed #2196F3;
+}
+
+.workspaces-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-weight: bold;
+ margin-top: 12px;
+}
+.workspaces-header .js-add-space {
+ text-decoration: none;
+ font-weight: bold;
+ border: 1px solid #ccc;
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+
+.workspace-tree {
+ list-style: none;
+ padding-left: 10px;
+}
+
+.workspace-node {
+ margin: 2px 0;
+ position: relative;
+}
+
+.workspace-node-content {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border-radius: 4px;
+ transition: background-color 0.2s;
+}
+
+.workspace-node.dragging > .workspace-node-content {
+ opacity: 0.5;
+ background: #e0e0e0;
+}
+
+.workspace-node.drag-over > .workspace-node-content {
+ background: #d0e8ff;
+ border: 2px dashed #2196F3;
+}
+
+.workspace-drag-handle {
+ cursor: grab;
+ color: #999;
+ font-size: 14px;
+ padding: 0 4px;
+ user-select: none;
+}
+
+.workspace-drag-handle:active {
+ cursor: grabbing;
+}
+
+.workspace-node .js-select-space {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ flex: 1;
+ text-decoration: none;
+}
+
+.workspace-node .workspace-icon {
+ font-size: 16px;
+ line-height: 1;
+}
+
+.workspace-node .workspace-name {
+ flex: 1;
+}
+
+.workspace-node .workspace-count {
+ background: #ddd;
+ padding: 2px 6px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: bold;
+ min-width: 20px;
+ text-align: center;
+}
+
+.workspace-node .js-edit-space,
+.workspace-node .js-add-subspace {
+ padding: 2px 6px;
+ border-radius: 3px;
+ cursor: pointer;
+ text-decoration: none;
+ font-size: 14px;
+ opacity: 0.6;
+ transition: opacity 0.2s;
+}
+
+.workspace-node .js-edit-space:hover,
+.workspace-node .js-add-subspace:hover {
+ opacity: 1;
+ background: #e0e0e0;
+}
+
+.workspace-node.active > .workspace-node-content .js-select-space,
+.workspace-node > .workspace-node-content:hover .js-select-space {
+ background: #f0f0f0;
+}
+
+.workspace-node.active .workspace-count {
+ background: #bbb;
+}
+
+.boards-right-grid {
+ min-height: 200px;
+}
+
+.boards-path-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 12px 16px;
+ margin-bottom: 16px;
+ background: #f5f5f5;
+ border-radius: 6px;
+ font-size: 16px;
+ font-weight: 500;
+}
+
+.boards-path-header .path-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+}
+
+.boards-path-header .multiselection-hint {
+ background: #FFF3CD;
+ color: #856404;
+ padding: 4px 12px;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: normal;
+ border: 1px solid #FFE69C;
+ animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+
+.boards-path-header .path-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.boards-path-header .path-icon {
+ font-size: 18px;
+}
+
+.boards-path-header .path-text {
+ color: #333;
+}
+
+.boards-path-header .board-header-btn {
+ padding: 6px 12px;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 14px;
+ transition: all 0.2s;
+}
+
+.boards-path-header .board-header-btn:hover {
+ background: #f0f0f0;
+ border-color: #bbb;
+}
+
+.boards-path-header .board-header-btn.emphasis {
+ background: #2196F3;
+ color: #fff;
+ border-color: #2196F3;
+ font-weight: bold;
+ box-shadow: 0 2px 8px rgba(33, 150, 243, 0.5);
+ transform: scale(1.05);
+}
+
+.boards-path-header .board-header-btn.emphasis:hover {
+ background: #1976D2;
+ box-shadow: 0 3px 12px rgba(33, 150, 243, 0.7);
+}
+
+.boards-path-header .board-header-btn-close {
+ padding: 4px 10px;
+ background: #f44336;
+ color: #000;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 16px;
+ margin-left: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
+}
+
+.boards-path-header .board-header-btn-close:hover {
+ background: #d32f2f;
+}
+
.zoom-controls {
display: flex;
align-items: center;
@@ -103,26 +370,38 @@
transform: rotate(4deg);
display: block !important;
}
-.board-list li.starred .fa-star,
-.board-list li.starred .fa-star-o {
+.board-list li.starred .is-star-active,
+.board-list li.starred .is-not-star-active {
+ opacity: 1;
+ color: #ffd700;
+}
+/* Show star icon on hover even for non-starred boards */
+.board-list li:hover .is-star-active,
+.board-list li:hover .is-not-star-active {
opacity: 1;
}
.board-list .board-list-item {
overflow: hidden;
- background-color: #999;
+ background-color: inherit; /* Inherit board color from parent li.js-board */
color: #f6f6f6;
min-height: 100px;
font-size: 16px;
line-height: 22px;
- border-radius: 3px;
+ border-radius: 0; /* No border-radius - parent .js-board has it */
display: block;
font-weight: 700;
- padding: 8px;
- margin: 8px;
+ padding: 36px 8px 32px 8px; /* Top padding for drag handle, bottom for checkbox */
+ margin: 0; /* No margin - moved to parent .js-board */
position: relative;
text-decoration: none;
word-wrap: break-word;
}
+
+.board-list .board-list-item > .js-open-board {
+ text-decoration: none;
+ color: inherit;
+ display: block;
+}
.board-list .board-list-item.template-container {
border: 4px solid #fff;
}
@@ -150,13 +429,20 @@
.board-list .js-add-board .label {
font-weight: normal;
line-height: 56px;
+ min-height: 100px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: #999; /* Darker background for better text contrast */
+ border-radius: 3px;
+ padding: 36px 8px 32px 8px;
}
-.board-list .js-add-board :hover {
- background-color: #939393;
+.board-list .js-add-board .label:hover {
+ background-color: #808080; /* Even darker on hover */
}
-.board-list .fa-star,
-.board-list .fa-star-o {
- bottom: 0;
+.board-list .is-star-active,
+.board-list .is-not-star-active {
+ top: 0;
font-size: 14px;
height: 18px;
line-height: 18px;
@@ -164,7 +450,6 @@
padding: 9px 9px;
position: absolute;
right: 0;
- top: 0;
transition-duration: 0.15s;
transition-property: color, font-size, background;
}
@@ -212,32 +497,121 @@
transition-duration: 0.15s;
transition-property: color, font-size, background;
}
-.board-list li:hover a:hover .fa-star,
+.board-list li:hover a:hover .is-star-active,
.board-list li:hover a:hover .fa-clone,
.board-list li:hover a:hover .fa-archive,
-.board-list li:hover a:hover .fa-star-o {
+.board-list li:hover a:hover .is-not-star-active {
color: #fff;
}
-.board-list li:hover a .fa-star,
+.board-list li:hover a .is-star-active,
.board-list li:hover a .fa-clone,
.board-list li:hover a .fa-archive,
-.board-list li:hover a .fa-star-o {
+.board-list li:hover a .is-not-star-active {
color: #fff;
opacity: 0.75;
}
-.board-list li:hover a .fa-star:hover,
+.board-list li:hover a .is-star-active:hover,
.board-list li:hover a .fa-clone:hover,
.board-list li:hover a .fa-archive:hover,
-.board-list li:hover a .fa-star-o:hover {
+.board-list li:hover a .is-not-star-active:hover {
font-size: 18px;
opacity: 1;
}
-.board-list li:hover a .fa-star.is-star-active,
-.board-list li:hover a .fa-clone.is-star-active,
-.board-list li:hover a .fa-archive.is-star-active,
-.board-list li:hover a .fa-star-o.is-star-active {
+.board-list li:hover a .is-star-active,
+.board-list li:hover a .fa-clone,
+.board-list li:hover a .fa-archive,
+.board-list li:hover a .is-not-star-active {
opacity: 1;
}
+
+/* Board drag handle - always visible and positioned at top */
+.board-list .board-handle {
+ position: absolute;
+ padding: 4px 6px;
+ top: 4px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 14px;
+ color: #fff;
+ background: rgba(0,0,0,0.4);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+ transition: background-color 0.2s ease;
+ cursor: grab;
+ opacity: 1;
+ user-select: none;
+}
+
+.board-list .board-handle:active {
+ cursor: grabbing;
+}
+
+.board-list .board-handle:hover {
+ background: rgba(255, 255, 0, 0.8) !important;
+ color: #000;
+}
+
+/* Multiselection checkbox on board items */
+.board-list .board-list-item .multi-selection-checkbox {
+ position: absolute !important;
+ bottom: 4px !important;
+ left: 4px !important;
+ top: auto !important;
+ width: 24px;
+ height: 24px;
+ border: 3px solid #fff;
+ background: rgba(0,0,0,0.5);
+ border-radius: 4px;
+ cursor: pointer;
+ z-index: 11;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
+ transform: none !important;
+ margin: 0 !important;
+}
+
+.board-list .board-list-item .multi-selection-checkbox:hover {
+ background: rgba(0,0,0,0.7);
+ transform: scale(1.15) !important;
+ box-shadow: 0 3px 6px rgba(0,0,0,0.5);
+}
+
+.board-list .board-list-item .multi-selection-checkbox.is-checked {
+ background: #2196F3;
+ border-color: #2196F3;
+ box-shadow: 0 2px 8px rgba(33, 150, 243, 0.6);
+ width: 24px !important;
+ height: 24px !important;
+ top: auto !important;
+ left: 4px !important;
+ transform: none !important;
+ border-radius: 4px !important;
+}
+
+.board-list .board-list-item .multi-selection-checkbox.is-checked::after {
+ content: '✓';
+ color: #fff;
+ font-size: 16px;
+ font-weight: bold;
+}
+
+.board-list.is-multiselection-active .js-board.is-checked {
+ outline: 4px solid #2196F3;
+ outline-offset: -4px;
+ box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4);
+}
+
+/* Visual hint when multiselection is active */
+.board-list.is-multiselection-active .board-list-item {
+ border: 2px dashed rgba(33, 150, 243, 0.3);
+}
+
.board-backgrounds-list .board-background-select {
box-sizing: border-box;
display: block;
@@ -361,6 +735,18 @@
min-height: 100vh; /* Force content to be tall enough to scroll */
}
+/* Hide archive and clone board buttons in mobile view */
+.board-list.mobile-view .js-archive-board,
+.board-list.mobile-view .js-clone-board {
+ display: none !important;
+}
+
+/* Change board drag handle to up-down arrow in mobile view */
+.board-list.mobile-view .board-handle.fa-arrows::before {
+ content: "↕️" !important;
+ font-family: inherit !important;
+}
+
.board-list.mobile-view::after {
content: '';
display: block;
@@ -371,7 +757,8 @@
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
screen and (max-width: 800px) and (orientation: portrait),
- screen and (max-width: 800px) and (orientation: landscape) {
+ screen and (max-width: 800px) and (orientation: landscape),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.board-list {
height: 100%;
overflow-y: auto;
@@ -457,7 +844,8 @@
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
screen and (max-width: 800px) and (orientation: portrait),
- screen and (max-width: 800px) and (orientation: landscape) {
+ screen and (max-width: 800px) and (orientation: landscape),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.wrapper {
font-size: 2em !important; /* 2x bigger base font size for All Boards page */
}
@@ -725,9 +1113,62 @@
#resetBtn {
display: inline;
}
+
+#resetBtn.filter-reset-btn {
+ background: #f44336;
+ color: #000;
+ border: none;
+ border-radius: 4px;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: 14px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition: background 0.2s;
+}
+
+#resetBtn.filter-reset-btn:hover {
+ background: #d32f2f;
+}
+
+#resetBtn.filter-reset-btn .reset-icon {
+ font-size: 14px;
+}
+
.js-board {
display: block;
+ background-color: #999; /* Default gray background if no color class is applied */
+ border-radius: 3px; /* Rounded corners for board items */
+ overflow: hidden; /* Ensure children respect rounded corners */
+ margin: 8px; /* Space between board items */
}
+
+/* Reset background for add-board button */
+.js-add-board {
+ background-color: transparent !important;
+ margin: 8px !important; /* Keep margin for add-board */
+}
+
+/* Apply board colors to li.js-board parent instead of just the link */
+.board-list .board-color-nephritis { background-color: #27ae60; }
+.board-list .board-color-pomegranate { background-color: #c0392b; }
+.board-list .board-color-belize { background-color: #2980b9; }
+.board-list .board-color-wisteria { background-color: #8e44ad; }
+.board-list .board-color-midnight { background-color: #2c3e50; }
+.board-list .board-color-pumpkin { background-color: #e67e22; }
+.board-list .board-color-moderatepink { background-color: #cd5a91; }
+.board-list .board-color-strongcyan { background-color: #00aecc; }
+.board-list .board-color-limegreen { background-color: #4bbf6b; }
+.board-list .board-color-dark { background-color: #2c3e51; }
+.board-list .board-color-relax { background-color: #27ae61; }
+.board-list .board-color-corteza { background-color: #568ba2; }
+.board-list .board-color-clearblue { background-color: #3498db; }
+.board-list .board-color-natural { background-color: #596557; }
+.board-list .board-color-modern { background-color: #2a80b8; }
+.board-list .board-color-moderndark { background-color: #2a2a2a; }
+.board-list .board-color-exodark { background-color: #222; }
+
.minicard-members {
padding: 6px 0 6px 8px;
width: 100%;
@@ -757,7 +1198,8 @@
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
screen and (max-width: 800px) and (orientation: portrait),
- screen and (max-width: 800px) and (orientation: landscape) {
+ screen and (max-width: 800px) and (orientation: landscape),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.wrapper {
overflow: hidden;
height: 100vh;
@@ -824,5 +1266,17 @@
#content {
overflow: hidden;
}
+
+ /* Hide archive and clone board buttons in mobile view */
+ .board-list .js-archive-board,
+ .board-list .js-clone-board {
+ display: none !important;
+ }
+
+ /* Change board drag handle to up-down arrow in mobile view */
+ .board-list .board-handle.fa-arrows::before {
+ content: "↕️" !important;
+ font-family: inherit !important;
+ }
}
diff --git a/client/components/boards/boardsList.jade b/client/components/boards/boardsList.jade
index f5fb4522e..5cf488c55 100644
--- a/client/components/boards/boardsList.jade
+++ b/client/components/boards/boardsList.jade
@@ -2,148 +2,160 @@ template(name="boardList")
.wrapper
.board-list-header
- ul.AllBoardTeamsOrgs
- li.AllBoardTeams
- if userHasTeams
- select.js-AllBoardTeams#jsAllBoardTeams("multiple")
- option(value="-1") {{_ 'teams'}} :
- each teamsDatas
- option(value="{{teamId}}") {{_ teamDisplayName}}
+ .boards-layout
+ // Left menu
+ .boards-left-menu
+ ul.menu
+ li(class="menu-item {{#if isSelectedMenu 'starred'}}active{{/if}}")
+ a.js-select-menu(data-type="starred")
+ span.menu-label ⭐ {{_ 'allboards.starred'}}
+ span.menu-count {{menuItemCount 'starred'}}
+ li(class="menu-item {{#if isSelectedMenu 'templates'}}active{{/if}}")
+ a.js-select-menu(data-type="templates")
+ span.menu-label 📋 {{_ 'allboards.templates'}}
+ span.menu-count {{menuItemCount 'templates'}}
+ li(class="menu-item {{#if isSelectedMenu 'remaining'}}active{{/if}}")
+ a.js-select-menu(data-type="remaining")
+ span.menu-label 📂 {{_ 'allboards.remaining'}}
+ span.menu-count {{menuItemCount 'remaining'}}
+ .workspaces-header
+ span 🗂️ {{_ 'allboards.workspaces'}}
+ a.js-add-workspace(title="{{_ 'allboards.add-workspace'}}") +
+ // Workspaces tree
+ +workspaceTree(nodes=workspacesTree selectedWorkspaceId=selectedWorkspaceId)
- li.AllBoardOrgs
- if userHasOrgs
- select.js-AllBoardOrgs#jsAllBoardOrgs("multiple")
- option(value="-1") {{_ 'organizations'}} :
- each orgsDatas
- option(value="{{orgId}}") {{orgDisplayName}}
+ // Existing filter by orgs/teams (kept)
+ ul.AllBoardTeamsOrgs
+ li.AllBoardTeams
+ if userHasTeams
+ select.js-AllBoardTeams#jsAllBoardTeams("multiple")
+ option(value="-1") {{_ 'teams'}} :
+ each teamsDatas
+ option(value="{{teamId}}") {{_ teamDisplayName}}
- //li.AllBoardTemplates
- // if userHasTemplates
- // select.js-AllBoardTemplates#jsAllBoardTemplates("multiple")
- // option(value="-1") {{_ 'templates'}} :
- // each templatesDatas
- // option(value="{{templateId}}") {{_ templateDisplayName}}
+ li.AllBoardOrgs
+ if userHasOrgs
+ select.js-AllBoardOrgs#jsAllBoardOrgs("multiple")
+ option(value="-1") {{_ 'organizations'}} :
+ each orgsDatas
+ option(value="{{orgId}}") {{orgDisplayName}}
- li.AllBoardBtns
- div.AllBoardButtonsContainer
- if userHasOrgsOrTeams
- i.fa.fa-filter
- input#filterBtn(type="button" value="{{_ 'filter'}}")
- input#resetBtn(type="button" value="{{_ 'filter-clear'}}")
+ li.AllBoardBtns
+ div.AllBoardButtonsContainer
+ if userHasOrgsOrTeams
+ span 🔍
+ input#filterBtn(type="button" value="{{_ 'filter'}}")
+ button#resetBtn.filter-reset-btn
+ span.reset-icon ❌
+ span {{_ 'filter-clear'}}
- ul.board-list.clearfix.js-boards(class="{{#if isMiniScreen}}mobile-view{{/if}}")
- li.js-add-board
- a.board-list-item.label(title="{{_ 'add-board'}}")
- | {{_ 'add-board'}}
- each boards
- li(class="{{_id}}" class="{{#if isStarred}}starred{{/if}}" class=colorClass).js-board
- if isInvited
- .board-list-item
- span.details
- span.board-list-item-name= title
- i.fa.js-star-board(
- class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
- title="{{_ 'star-board-title'}}")
- p.board-list-item-desc {{_ 'just-invited'}}
- button.js-accept-invite.primary {{_ 'accept'}}
- button.js-decline-invite {{_ 'decline'}}
- else
- if $eq type "template-container"
- a.js-open-board.template-container.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}")
- span.details
- span.board-list-item-name(title="{{_ 'template-container'}}")
- +viewer
- = title
- i.fa.js-star-board(
- class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
- title="{{_ 'star-board-title'}}")
- p.board-list-item-desc
- +viewer
- = description
- if hasSpentTimeCards
- i.fa.js-has-spenttime-cards(
- class="fa-circle{{#if hasOvertimeCards}} has-overtime-card-active{{else}} no-overtime-card-active{{/if}}"
- title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
- if isTouchScreenOrShowDesktopDragHandles
- i.fa.board-handle(
- class="fa-arrows"
- title="{{_ 'drag-board'}}")
- else
- if isSandstorm
- i.fa.js-clone-board(
- class="fa-clone"
- title="{{_ 'duplicate-board'}}")
- i.fa.js-archive-board(
- class="fa-archive"
- title="{{_ 'archive-board'}}")
- else if isAdministrable
- i.fa.js-clone-board(
- class="fa-clone"
- title="{{_ 'duplicate-board'}}")
- i.fa.js-archive-board(
- class="fa-archive"
- title="{{_ 'archive-board'}}")
- else if currentUser.isAdmin
- i.fa.js-clone-board(
- class="fa-clone"
- title="{{_ 'duplicate-board'}}")
- i.fa.js-archive-board(
- class="fa-archive"
- title="{{_ 'archive-board'}}")
+ // Right boards grid
+ .boards-right-grid
+ .boards-path-header
+ .path-left
+ span.path-icon {{currentMenuPath.icon}}
+ span.path-text {{currentMenuPath.text}}
+ if BoardMultiSelection.isActive
+ span.multiselection-hint 📌 {{_ 'multi-selection-active'}}
+ .path-right
+ if canModifyBoards
+ if hasBoardsSelected
+ button.js-archive-selected-boards.board-header-btn
+ span 📦
+ span {{_ 'archive-board'}}
+ button.js-duplicate-selected-boards.board-header-btn
+ span 📋
+ span {{_ 'duplicate-board'}}
+ a.board-header-btn.js-multiselection-activate(
+ title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
+ class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
+ | ☑️
+ if BoardMultiSelection.isActive
+ a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
+ | ✖
+ ul.board-list.clearfix.js-boards(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
+ li.js-add-board
+ if isSelectedMenu 'templates'
+ a.board-list-item.label(title="{{_ 'add-template-container'}}")
+ | ➕ {{_ 'add-template-container'}}
else
- a.js-open-board.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}")
- span.details
- span.board-list-item-name(title="{{_ 'board-drag-drop-reorder-or-click-open'}}")
- +viewer
- = title
- unless currentSetting.hideBoardMemberList
- if allowsBoardMemberList
- .minicard-members
- each member in boardMembers _id
- a.name
- +userAvatar(userId=member noRemove=true)
- unless currentSetting.hideCardCounterList
- if allowsCardCounterList
- .minicard-lists.flex.flex-wrap
- each list in boardLists _id
- .item
- | {{ list }}
- i.fa.js-star-board(
- class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
- title="{{_ 'star-board-title'}}")
- p.board-list-item-desc
- +viewer
- = description
- if hasSpentTimeCards
- i.fa.js-has-spenttime-cards(
- class="fa-circle{{#if hasOvertimeCards}} has-overtime-card-active{{else}} no-overtime-card-active{{/if}}"
- title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
- if isTouchScreenOrShowDesktopDragHandles
- i.fa.board-handle(
- class="fa-arrows"
- title="{{_ 'drag-board'}}")
- else
- if isSandstorm
- i.fa.js-clone-board(
- class="fa-clone"
- title="{{_ 'duplicate-board'}}")
- i.fa.js-archive-board(
- class="fa-archive"
- title="{{_ 'archive-board'}}")
- else if isAdministrable
- i.fa.js-clone-board(
- class="fa-clone"
- title="{{_ 'duplicate-board'}}")
- i.fa.js-archive-board(
- class="fa-archive"
- title="{{_ 'archive-board'}}")
- else if currentUser.isAdmin
- i.fa.js-clone-board(
- class="fa-clone"
- title="{{_ 'duplicate-board'}}")
- i.fa.js-archive-board(
- class="fa-archive"
- title="{{_ 'archive-board'}}")
+ a.board-list-item.label(title="{{_ 'add-board'}}")
+ | ➕ {{_ 'add-board'}}
+ each boards
+ li.js-board(class="{{_id}} {{#if isStarred}}starred{{/if}} {{colorClass}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}", draggable="true")
+ if isInvited
+ .board-list-item
+ if BoardMultiSelection.isActive
+ .materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
+ class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
+ span.details
+ span.board-list-item-name= title
+ span.js-star-board(
+ class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
+ title="{{_ 'star-board-title'}}")
+ | {{#if isStarred}}⭐{{else}}☆{{/if}}
+ p.board-list-item-desc {{_ 'just-invited'}}
+ button.js-accept-invite.primary {{_ 'accept'}}
+ button.js-decline-invite {{_ 'decline'}}
+ else
+ if $eq type "template-container"
+ .template-container.board-list-item
+ if BoardMultiSelection.isActive
+ .materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
+ class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
+ span.board-handle(title="{{_ 'drag-board'}}") ↕️
+ a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
+ span.details
+ span.board-list-item-name(title="{{_ 'template-container'}}")
+ +viewer
+ = title
+ p.board-list-item-desc
+ +viewer
+ = description
+ if hasSpentTimeCards
+ span.js-has-spenttime-cards(
+ class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
+ title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
+ | ⏱️
+ span.js-star-board(
+ class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
+ title="{{_ 'star-board-title'}}")
+ | {{#if isStarred}}⭐{{else}}☆{{/if}}
+ else
+ .board-list-item
+ if BoardMultiSelection.isActive
+ .materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
+ class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
+ span.board-handle(title="{{_ 'drag-board'}}") ↕️
+ a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
+ span.details
+ span.board-list-item-name(title="{{_ 'board-drag-drop-reorder-or-click-open'}}")
+ +viewer
+ = title
+ unless currentSetting.hideBoardMemberList
+ if allowsBoardMemberList
+ .minicard-members
+ each member in boardMembers _id
+ a.name
+ +userAvatar(userId=member noRemove=true)
+ unless currentSetting.hideCardCounterList
+ if allowsCardCounterList
+ .minicard-lists.flex.flex-wrap
+ each list in boardLists _id
+ .item
+ | {{ list }}
+ p.board-list-item-desc
+ +viewer
+ = description
+ if hasSpentTimeCards
+ span.js-has-spenttime-cards(
+ class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
+ title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
+ | ⏱️
+ a.js-star-board(
+ class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
+ title="{{_ 'star-board-title'}}")
+ | {{#if isStarred}}⭐{{else}}☆{{/if}}
template(name="boardListHeaderBar")
h1 {{_ title }}
@@ -154,3 +166,25 @@ template(name="boardListHeaderBar")
// a.board-header-btn(href="{{pathFor 'board' id=templatesBoardId slug=templatesBoardSlug}}")
// i.fa.fa-clone
// span {{_ 'templates'}}
+
+// Recursive template for workspaces tree
+template(name="workspaceTree")
+ if nodes
+ ul.workspace-tree.js-workspace-tree
+ each nodes
+ li.workspace-node(class="{{#if $eq id selectedWorkspaceId}}active{{/if}}" data-workspace-id="{{id}}" draggable="true")
+ .workspace-node-content
+ span.workspace-drag-handle ↕️
+ a.js-select-workspace(data-id="{{id}}")
+ span.workspace-icon
+ if icon
+ +viewer
+ = icon
+ else
+ | 📁
+ span.workspace-name= name
+ a.js-edit-workspace(data-id="{{id}}" title="{{_ 'allboards.edit-workspace'}}") ✏️
+ span.workspace-count {{workspaceCount id}}
+ a.js-add-subworkspace(data-id="{{id}}" title="{{_ 'allboards.add-subworkspace'}}") +
+ if children
+ +workspaceTree(nodes=children selectedWorkspaceId=selectedWorkspaceId)
diff --git a/client/components/boards/boardsList.js b/client/components/boards/boardsList.js
index 1d655fd11..bb1d258d0 100644
--- a/client/components/boards/boardsList.js
+++ b/client/components/boards/boardsList.js
@@ -14,6 +14,9 @@ Template.boardList.helpers({
return Utils.isMiniScreen() && Session.get('currentBoard'); */
return true;
},
+ BoardMultiSelection() {
+ return BoardMultiSelection;
+ },
})
Template.boardListHeaderBar.events({
@@ -45,6 +48,9 @@ BlazeComponent.extendComponent({
onCreated() {
Meteor.subscribe('setting');
Meteor.subscribe('tableVisibilityModeSettings');
+ this.selectedMenu = new ReactiveVar('starred');
+ this.selectedWorkspaceIdVar = new ReactiveVar(null);
+ this.workspacesTreeVar = new ReactiveVar([]);
let currUser = ReactiveCache.getCurrentUser();
let userLanguage;
if (currUser && currUser.profile) {
@@ -53,9 +59,72 @@ BlazeComponent.extendComponent({
if (userLanguage) {
TAPi18n.setLanguage(userLanguage);
}
+ // Load workspaces tree reactively
+ this.autorun(() => {
+ const u = ReactiveCache.getCurrentUser();
+ const tree = (u && u.profile && u.profile.boardWorkspacesTree) || [];
+ this.workspacesTreeVar.set(tree);
+ });
+ },
+
+ reorderWorkspaces(draggedSpaceId, targetSpaceId) {
+ const tree = this.workspacesTreeVar.get();
+
+ // Helper to remove a space from tree
+ const removeSpace = (nodes, id) => {
+ for (let i = 0; i < nodes.length; i++) {
+ if (nodes[i].id === id) {
+ const removed = nodes.splice(i, 1)[0];
+ return { tree: nodes, removed };
+ }
+ if (nodes[i].children) {
+ const result = removeSpace(nodes[i].children, id);
+ if (result.removed) {
+ return { tree: nodes, removed: result.removed };
+ }
+ }
+ }
+ return { tree: nodes, removed: null };
+ };
+
+ // Helper to insert a space after target
+ const insertAfter = (nodes, targetId, spaceToInsert) => {
+ for (let i = 0; i < nodes.length; i++) {
+ if (nodes[i].id === targetId) {
+ nodes.splice(i + 1, 0, spaceToInsert);
+ return true;
+ }
+ if (nodes[i].children) {
+ if (insertAfter(nodes[i].children, targetId, spaceToInsert)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+
+ // Clone the tree
+ const newTree = EJSON.clone(tree);
+
+ // Remove the dragged space
+ const { tree: treeAfterRemoval, removed } = removeSpace(newTree, draggedSpaceId);
+
+ if (removed) {
+ // Insert after target
+ insertAfter(treeAfterRemoval, targetSpaceId, removed);
+
+ // Save the new tree
+ Meteor.call('setWorkspacesTree', treeAfterRemoval, (err) => {
+ if (err) console.error(err);
+ });
+ }
},
onRendered() {
+ // jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
+ // The old sortable code has been removed to prevent conflicts
+
+ /* OLD SORTABLE CODE - DISABLED
const itemsSelector = '.js-board:not(.placeholder)';
const $boards = this.$('.js-boards');
@@ -73,27 +142,20 @@ BlazeComponent.extendComponent({
EscapeActions.executeUpTo('popup-close');
},
stop(evt, ui) {
- // To attribute the new index number, we need to get the DOM element
- // of the previous and the following card -- if any.
const prevBoardDom = ui.item.prev('.js-board').get(0);
- const nextBoardBom = ui.item.next('.js-board').get(0);
- const sortIndex = Utils.calculateIndex(prevBoardDom, nextBoardBom, 1);
+ const nextBoardDom = ui.item.next('.js-board').get(0);
+ const sortIndex = Utils.calculateIndex(prevBoardDom, nextBoardDom, 1);
const boardDomElement = ui.item.get(0);
const board = Blaze.getData(boardDomElement);
- // Normally the jquery-ui sortable library moves the dragged DOM element
- // to its new position, which disrupts Blaze reactive updates mechanism
- // (especially when we move the last card of a list, or when multiple
- // users move some cards at the same time). To prevent these UX glitches
- // we ask sortable to gracefully cancel the move, and to put back the
- // DOM in its initial state. The card move is then handled reactively by
- // Blaze with the below query.
$boards.sortable('cancel');
- board.move(sortIndex.base);
+ const currentUser = ReactiveCache.getCurrentUser();
+ if (currentUser && typeof currentUser.setBoardSortIndex === 'function') {
+ currentUser.setBoardSortIndex(board._id, sortIndex.base);
+ }
},
});
- // Disable drag-dropping if the current user is not a board member or is comment only
this.autorun(() => {
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$boards.sortable({
@@ -101,6 +163,7 @@ BlazeComponent.extendComponent({
});
}
});
+ */
},
userHasTeams() {
if (ReactiveCache.getCurrentUser()?.teams?.length > 0)
@@ -132,6 +195,41 @@ BlazeComponent.extendComponent({
const ret = this.userHasOrgs() || this.userHasTeams();
return ret;
},
+ currentMenuPath() {
+ const sel = this.selectedMenu.get();
+ const currentUser = ReactiveCache.getCurrentUser();
+
+ // Helper to find space by id in tree
+ const findSpaceById = (nodes, targetId, path = []) => {
+ for (const node of nodes) {
+ if (node.id === targetId) {
+ return [...path, node];
+ }
+ if (node.children && node.children.length > 0) {
+ const result = findSpaceById(node.children, targetId, [...path, node]);
+ if (result) return result;
+ }
+ }
+ return null;
+ };
+
+ if (sel === 'starred') {
+ return { icon: '⭐', text: TAPi18n.__('allboards.starred') };
+ } else if (sel === 'templates') {
+ return { icon: '📋', text: TAPi18n.__('allboards.templates') };
+ } else if (sel === 'remaining') {
+ return { icon: '📂', text: TAPi18n.__('allboards.remaining') };
+ } else {
+ // sel is a workspaceId, build path
+ const tree = this.workspacesTreeVar.get();
+ const spacePath = findSpaceById(tree, sel);
+ if (spacePath && spacePath.length > 0) {
+ const pathText = spacePath.map(s => s.name).join(' / ');
+ return { icon: '🗂️', text: `${TAPi18n.__('allboards.workspaces')} / ${pathText}` };
+ }
+ return { icon: '🗂️', text: TAPi18n.__('allboards.workspaces') };
+ }
+ },
boards() {
let query = {
// { type: 'board' },
@@ -184,10 +282,33 @@ BlazeComponent.extendComponent({
};
}
- const ret = ReactiveCache.getBoards(query, {
- sort: { sort: 1 /* boards default sorting */ },
- });
- return ret;
+ const boards = ReactiveCache.getBoards(query, {});
+ const currentUser = ReactiveCache.getCurrentUser();
+ let list = boards;
+ // Apply left menu filtering
+ const sel = this.selectedMenu.get();
+ const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
+ if (sel === 'starred') {
+ list = list.filter(b => currentUser && currentUser.hasStarred(b._id));
+ } else if (sel === 'templates') {
+ list = list.filter(b => b.type === 'template-container');
+ } else if (sel === 'remaining') {
+ // Show boards not in any workspace AND not templates
+ // Keep starred boards visible in Remaining too
+ list = list.filter(b =>
+ !assignments[b._id] &&
+ b.type !== 'template-container'
+ );
+ } else {
+ // assume sel is a workspaceId
+ // Keep starred boards visible in their workspace too
+ list = list.filter(b => assignments[b._id] === sel);
+ }
+
+ if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
+ return currentUser.sortBoardsForUser(list);
+ }
+ return list.slice().sort((a, b) => (a.title || '').localeCompare(b.title || ''));
},
boardLists(boardId) {
/* Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
@@ -235,11 +356,65 @@ BlazeComponent.extendComponent({
events() {
return [
{
- 'click .js-add-board': Popup.open('createBoard'),
- 'click .js-star-board'(evt) {
- const boardId = this.currentData()._id;
- ReactiveCache.getCurrentUser().toggleBoardStar(boardId);
+ 'click .js-select-menu'(evt) {
+ const type = evt.currentTarget.getAttribute('data-type');
+ this.selectedWorkspaceIdVar.set(null);
+ this.selectedMenu.set(type);
+ },
+ 'click .js-select-workspace'(evt) {
+ const id = evt.currentTarget.getAttribute('data-id');
+ this.selectedWorkspaceIdVar.set(id);
+ this.selectedMenu.set(id);
+ },
+ 'click .js-add-workspace'(evt) {
evt.preventDefault();
+ const name = prompt(TAPi18n.__('allboards.add-workspace-prompt') || 'New Space name');
+ if (name && name.trim()) {
+ Meteor.call('createWorkspace', { parentId: null, name: name.trim() }, (err, res) => {
+ if (err) console.error(err);
+ });
+ }
+ },
+ 'click .js-add-board'(evt) {
+ // Store the currently selected workspace/menu for board creation
+ const selectedWorkspaceId = this.selectedWorkspaceIdVar.get();
+ const selectedMenu = this.selectedMenu.get();
+
+ if (selectedWorkspaceId) {
+ Session.set('createBoardInWorkspace', selectedWorkspaceId);
+ } else {
+ Session.set('createBoardInWorkspace', null);
+ }
+
+ // Open different popup based on context
+ if (selectedMenu === 'templates') {
+ Popup.open('createTemplateContainer')(evt);
+ } else {
+ Popup.open('createBoard')(evt);
+ }
+ },
+ 'click .js-star-board'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const boardId = this.currentData()._id;
+ if (boardId) {
+ Meteor.call('toggleBoardStar', boardId);
+ }
+ },
+ // HTML5 DnD from boards to spaces
+ 'dragstart .js-board'(evt) {
+ const boardId = this.currentData()._id;
+
+ // Support multi-drag
+ if (BoardMultiSelection.isActive() && BoardMultiSelection.isSelected(boardId)) {
+ const selectedIds = BoardMultiSelection.getSelectedBoardIds();
+ try {
+ evt.originalEvent.dataTransfer.setData('text/plain', JSON.stringify(selectedIds));
+ evt.originalEvent.dataTransfer.setData('application/x-board-multi', 'true');
+ } catch (e) {}
+ } else {
+ try { evt.originalEvent.dataTransfer.setData('text/plain', boardId); } catch (e) {}
+ }
},
'click .js-clone-board'(evt) {
if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
@@ -290,6 +465,58 @@ BlazeComponent.extendComponent({
}
});
},
+ 'click .js-multiselection-activate'(evt) {
+ evt.preventDefault();
+ if (BoardMultiSelection.isActive()) {
+ BoardMultiSelection.disable();
+ } else {
+ BoardMultiSelection.activate();
+ }
+ },
+ 'click .js-multiselection-reset'(evt) {
+ evt.preventDefault();
+ BoardMultiSelection.disable();
+ },
+ 'click .js-toggle-board-multi-selection'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const boardId = this.currentData()._id;
+ BoardMultiSelection.toogle(boardId);
+ },
+ 'click .js-archive-selected-boards'(evt) {
+ evt.preventDefault();
+ const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
+ if (selectedBoards.length > 0 && confirm(TAPi18n.__('archive-board-confirm'))) {
+ selectedBoards.forEach(boardId => {
+ Meteor.call('archiveBoard', boardId);
+ });
+ BoardMultiSelection.reset();
+ }
+ },
+ 'click .js-duplicate-selected-boards'(evt) {
+ evt.preventDefault();
+ const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
+ if (selectedBoards.length > 0 && confirm(TAPi18n.__('duplicate-board-confirm'))) {
+ selectedBoards.forEach(boardId => {
+ const board = ReactiveCache.getBoard(boardId);
+ if (board) {
+ Meteor.call(
+ 'copyBoard',
+ boardId,
+ {
+ sort: ReactiveCache.getBoards({ archived: false }).length,
+ type: 'board',
+ title: board.title,
+ },
+ (err, res) => {
+ if (err) console.error(err);
+ }
+ );
+ }
+ });
+ BoardMultiSelection.reset();
+ }
+ },
'click #resetBtn'(event) {
let allBoards = document.getElementsByClassName("js-board");
let currBoard;
@@ -356,7 +583,260 @@ BlazeComponent.extendComponent({
}
}
},
+ 'click .js-edit-workspace'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const workspaceId = evt.currentTarget.getAttribute('data-id');
+
+ // Find the space in the tree
+ const findSpace = (nodes, id) => {
+ for (const node of nodes) {
+ if (node.id === id) return node;
+ if (node.children) {
+ const found = findSpace(node.children, id);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+
+ const tree = this.workspacesTreeVar.get();
+ const space = findSpace(tree, workspaceId);
+
+ if (space) {
+ const newName = prompt(TAPi18n.__('allboards.edit-workspace-name') || 'Space name:', space.name);
+ const newIcon = prompt(TAPi18n.__('allboards.edit-workspace-icon') || 'Space icon (markdown):', space.icon || '📁');
+
+ if (newName !== null && newName.trim()) {
+ // Update space in tree
+ const updateSpaceInTree = (nodes, id, updates) => {
+ return nodes.map(node => {
+ if (node.id === id) {
+ return { ...node, ...updates };
+ }
+ if (node.children) {
+ return { ...node, children: updateSpaceInTree(node.children, id, updates) };
+ }
+ return node;
+ });
+ };
+
+ const updatedTree = updateSpaceInTree(tree, workspaceId, {
+ name: newName.trim(),
+ icon: newIcon || '📁'
+ });
+
+ Meteor.call('setWorkspacesTree', updatedTree, (err) => {
+ if (err) console.error(err);
+ });
+ }
+ }
+ },
+ 'click .js-add-subworkspace'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const parentId = evt.currentTarget.getAttribute('data-id');
+ const name = prompt(TAPi18n.__('allboards.add-subworkspace-prompt') || 'Subspace name:');
+
+ if (name && name.trim()) {
+ Meteor.call('createWorkspace', { parentId, name: name.trim() }, (err) => {
+ if (err) console.error(err);
+ });
+ }
+ },
+ 'dragstart .workspace-node'(evt) {
+ const workspaceId = evt.currentTarget.getAttribute('data-workspace-id');
+ evt.originalEvent.dataTransfer.effectAllowed = 'move';
+ evt.originalEvent.dataTransfer.setData('application/x-workspace-id', workspaceId);
+
+ // Create a better drag image
+ const dragImage = evt.currentTarget.cloneNode(true);
+ dragImage.style.position = 'absolute';
+ dragImage.style.top = '-9999px';
+ dragImage.style.opacity = '0.8';
+ document.body.appendChild(dragImage);
+ evt.originalEvent.dataTransfer.setDragImage(dragImage, 0, 0);
+ setTimeout(() => document.body.removeChild(dragImage), 0);
+
+ evt.currentTarget.classList.add('dragging');
+ },
+ 'dragend .workspace-node'(evt) {
+ evt.currentTarget.classList.remove('dragging');
+ document.querySelectorAll('.workspace-node').forEach(el => {
+ el.classList.remove('drag-over');
+ });
+ },
+ 'dragover .workspace-node'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+
+ const draggingEl = document.querySelector('.workspace-node.dragging');
+ const targetEl = evt.currentTarget;
+
+ // Allow dropping boards on any space
+ // Or allow dropping spaces on other spaces (but not on itself or descendants)
+ if (!draggingEl || (targetEl !== draggingEl && !draggingEl.contains(targetEl))) {
+ evt.originalEvent.dataTransfer.dropEffect = 'move';
+ targetEl.classList.add('drag-over');
+ }
+ },
+ 'dragleave .workspace-node'(evt) {
+ evt.currentTarget.classList.remove('drag-over');
+ },
+ 'drop .workspace-node'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+
+ const targetEl = evt.currentTarget;
+ targetEl.classList.remove('drag-over');
+
+ // Check what's being dropped - board or workspace
+ const draggedWorkspaceId = evt.originalEvent.dataTransfer.getData('application/x-workspace-id');
+ const isMultiBoard = evt.originalEvent.dataTransfer.getData('application/x-board-multi');
+ const boardData = evt.originalEvent.dataTransfer.getData('text/plain');
+
+ if (draggedWorkspaceId && !boardData) {
+ // This is a workspace reorder operation
+ const targetWorkspaceId = targetEl.getAttribute('data-workspace-id');
+
+ if (draggedWorkspaceId !== targetWorkspaceId) {
+ this.reorderWorkspaces(draggedWorkspaceId, targetWorkspaceId);
+ }
+ } else if (boardData) {
+ // This is a board assignment operation
+ // Get the workspace ID directly from the dropped workspace-node's data-workspace-id attribute
+ const workspaceId = targetEl.getAttribute('data-workspace-id');
+
+ if (workspaceId) {
+ if (isMultiBoard) {
+ // Multi-board drag
+ try {
+ const boardIds = JSON.parse(boardData);
+ boardIds.forEach(boardId => {
+ Meteor.call('assignBoardToWorkspace', boardId, workspaceId);
+ });
+ } catch (e) {
+ // Error parsing multi-board data
+ }
+ } else {
+ // Single board drag
+ Meteor.call('assignBoardToWorkspace', boardData, workspaceId);
+ }
+ }
+ }
+ },
+ 'dragover .js-select-menu'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+
+ const menuType = evt.currentTarget.getAttribute('data-type');
+ // Only allow drop on "remaining" menu to unassign boards from spaces
+ if (menuType === 'remaining') {
+ evt.originalEvent.dataTransfer.dropEffect = 'move';
+ evt.currentTarget.classList.add('drag-over');
+ }
+ },
+ 'dragleave .js-select-menu'(evt) {
+ evt.currentTarget.classList.remove('drag-over');
+ },
+ 'drop .js-select-menu'(evt) {
+ evt.preventDefault();
+ evt.stopPropagation();
+
+ const menuType = evt.currentTarget.getAttribute('data-type');
+ evt.currentTarget.classList.remove('drag-over');
+
+ // Only handle drops on "remaining" menu
+ if (menuType !== 'remaining') return;
+
+ const isMultiBoard = evt.originalEvent.dataTransfer.getData('application/x-board-multi');
+ const boardData = evt.originalEvent.dataTransfer.getData('text/plain');
+
+ if (boardData) {
+ if (isMultiBoard) {
+ // Multi-board drag - unassign all from workspaces
+ try {
+ const boardIds = JSON.parse(boardData);
+ boardIds.forEach(boardId => {
+ Meteor.call('unassignBoardFromWorkspace', boardId);
+ });
+ } catch (e) {
+ // Error parsing multi-board data
+ }
+ } else {
+ // Single board drag - unassign from workspace
+ Meteor.call('unassignBoardFromWorkspace', boardData);
+ }
+ }
+ },
},
];
},
+ // Helpers for templates
+ workspacesTree() {
+ return this.workspacesTreeVar.get();
+ },
+ selectedWorkspaceId() {
+ return this.selectedWorkspaceIdVar.get();
+ },
+ isSelectedMenu(type) {
+ return this.selectedMenu.get() === type;
+ },
+ isSpaceSelected(id) {
+ return this.selectedWorkspaceIdVar.get() === id;
+ },
+ menuItemCount(type) {
+ const currentUser = ReactiveCache.getCurrentUser();
+ const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
+
+ // Get all boards for counting
+ let query = {
+ $and: [
+ { archived: false },
+ { type: { $in: ['board', 'template-container'] } },
+ { $or: [{ 'members.userId': Meteor.userId() }] },
+ { title: { $not: { $regex: /^\^.*\^$/ } } }
+ ]
+ };
+ const allBoards = ReactiveCache.getBoards(query, {});
+
+ if (type === 'starred') {
+ return allBoards.filter(b => currentUser && currentUser.hasStarred(b._id)).length;
+ } else if (type === 'templates') {
+ return allBoards.filter(b => b.type === 'template-container').length;
+ } else if (type === 'remaining') {
+ // Count boards not in any workspace AND not templates
+ // Include starred boards (they appear in both Starred and Remaining)
+ return allBoards.filter(b =>
+ !assignments[b._id] &&
+ b.type !== 'template-container'
+ ).length;
+ }
+ return 0;
+ },
+ workspaceCount(workspaceId) {
+ const currentUser = ReactiveCache.getCurrentUser();
+ const assignments = (currentUser && currentUser.profile && currentUser.profile.boardWorkspaceAssignments) || {};
+
+ // Get all boards for counting
+ let query = {
+ $and: [
+ { archived: false },
+ { type: { $in: ['board', 'template-container'] } },
+ { $or: [{ 'members.userId': Meteor.userId() }] },
+ { title: { $not: { $regex: /^\^.*\^$/ } } }
+ ]
+ };
+ const allBoards = ReactiveCache.getBoards(query, {});
+
+ // Count boards directly assigned to this space (not including children)
+ return allBoards.filter(b => assignments[b._id] === workspaceId).length;
+ },
+ canModifyBoards() {
+ const currentUser = ReactiveCache.getCurrentUser();
+ return currentUser && !currentUser.isCommentOnly();
+ },
+ hasBoardsSelected() {
+ return BoardMultiSelection.count() > 0;
+ },
}).register('boardList');
diff --git a/client/components/cards/attachments.jade b/client/components/cards/attachments.jade
index efa66f513..34a9b2496 100644
--- a/client/components/cards/attachments.jade
+++ b/client/components/cards/attachments.jade
@@ -86,17 +86,17 @@ template(name="attachmentGallery")
= name
span.file-size ({{fileSize size}})
.attachment-actions
- a.js-download(href="{{link}}?download=true", download="{{name}}")
- | ⬇️(title="{{_ 'download'}}")
+ a.js-download(href="{{link}}?download=true", download="{{name}}", title="{{_ 'download'}}")
+ | ⬇️
if currentUser.isBoardMember
unless currentUser.isCommentOnly
unless currentUser.isWorker
- a.js-rename
- | ✏️(title="{{_ 'rename'}}")
- a.js-confirm-delete
- | 🗑️(title="{{_ 'delete'}}")
- a.js-open-attachment-menu
- | ☰(data-attachment-link="{{link}}" title="{{_ 'attachmentActionsPopup-title'}}")
+ a.js-rename(title="{{_ 'rename'}}")
+ | ✏️
+ a.js-confirm-delete(title="{{_ 'delete'}}")
+ | 🗑️
+ a.js-open-attachment-menu(data-attachment-link="{{link}}", title="{{_ 'attachmentActionsPopup-title'}}")
+ | ☰
// Migration spinner overlay
if isAttachmentMigrating _id
diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js
index 18788f22d..a883877e1 100644
--- a/client/components/cards/attachments.js
+++ b/client/components/cards/attachments.js
@@ -343,7 +343,7 @@ export function handleFileUpload(card, files) {
}
// Check if user can modify the card
- if (!card.canModifyCard()) {
+ if (!Utils.canModifyCard()) {
if (process.env.DEBUG === 'true') {
console.warn('User does not have permission to modify this card');
}
diff --git a/client/components/cards/cardCustomFields.js b/client/components/cards/cardCustomFields.js
index 5d02091ca..82c025503 100644
--- a/client/components/cards/cardCustomFields.js
+++ b/client/components/cards/cardCustomFields.js
@@ -1,8 +1,10 @@
import { TAPi18n } from '/imports/i18n';
import { DatePicker } from '/client/lib/datepicker';
+import { ReactiveCache } from '/imports/reactiveCache';
import {
formatDateTime,
formatDate,
+ formatDateByUserPreference,
formatTime,
getISOWeek,
isValidDate,
@@ -168,11 +170,18 @@ CardCustomField.register('cardCustomField');
}
showWeekOfYear() {
- return ReactiveCache.getCurrentUser().isShowWeekOfYear();
+ const user = ReactiveCache.getCurrentUser();
+ if (!user) {
+ // For non-logged-in users, week of year is not shown
+ return false;
+ }
+ return user.isShowWeekOfYear();
}
showDate() {
- return calendar(this.date.get());
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
showISODate() {
diff --git a/client/components/cards/cardDate.css b/client/components/cards/cardDate.css
index 952cc15b7..4a873e485 100644
--- a/client/components/cards/cardDate.css
+++ b/client/components/cards/cardDate.css
@@ -8,76 +8,112 @@
.card-date.is-active {
background-color: #b3b3b3;
}
-.card-date.current,
-.card-date.almost-due,
-.card-date.due,
-.card-date.long-overdue {
+/* Date status colors - red = overdue, amber = due soon, no shade = not due */
+.card-date.overdue {
+ background-color: #ff4444; /* Red for overdue */
color: #fff;
}
+.card-date.overdue:hover,
+.card-date.overdue.is-active {
+ background-color: #cc3333;
+}
+
+.card-date.due-soon {
+ background-color: #ffaa00; /* Amber for due soon */
+ color: #000;
+}
+.card-date.due-soon:hover,
+.card-date.due-soon.is-active {
+ background-color: #e69900;
+}
+
+.card-date.not-due {
+ /* No special background - uses default date type colors */
+}
+
.card-date.current {
- background-color: #5ba639;
+ background-color: #5ba639; /* Green for current/active */
+ color: #fff;
}
.card-date.current:hover,
.card-date.current.is-active {
background-color: #46802c;
}
-.card-date.almost-due {
- background-color: #edc909;
+
+.card-date.completed {
+ background-color: #90ee90; /* Light green for completed */
+ color: #000;
}
-.card-date.almost-due:hover,
-.card-date.almost-due.is-active {
- background-color: #bc9f07;
+.card-date.completed:hover,
+.card-date.completed.is-active {
+ background-color: #7dd87d;
}
-.card-date.due {
- background-color: #fa3f00;
+
+.card-date.completed-early {
+ background-color: #4caf50; /* Green for completed early */
+ color: #fff;
}
-.card-date.due:hover,
-.card-date.due.is-active {
- background-color: #c73200;
+.card-date.completed-early:hover,
+.card-date.completed-early.is-active {
+ background-color: #45a049;
}
-.card-date.long-overdue {
- background-color: #fd5d47;
+
+.card-date.completed-late {
+ background-color: #ff9800; /* Orange for completed late */
+ color: #fff;
}
-.card-date.long-overdue:hover,
-.card-date.long-overdue.is-active {
- background-color: #fd3e24;
+.card-date.completed-late:hover,
+.card-date.completed-late.is-active {
+ background-color: #f57c00;
+}
+
+.card-date.completed-on-time {
+ background-color: #2196f3; /* Blue for completed on time */
+ color: #fff;
+}
+.card-date.completed-on-time:hover,
+.card-date.completed-on-time.is-active {
+ background-color: #1976d2;
}
/* Date type specific colors */
.card-date.received-date {
- background-color: #0079bf; /* Blue for received */
+ background-color: #dbdbdb; /* Light grey for received */
}
.card-date.received-date:hover,
.card-date.received-date.is-active {
- background-color: #005a8b;
+ background-color: #b3b3b3;
}
.card-date.start-date {
- background-color: #3cb500; /* Green for start */
+ background-color: #90ee90; /* Light green for start */
+ color: #000; /* Black text for start */
}
.card-date.start-date:hover,
.card-date.start-date.is-active {
- background-color: #2d8f00;
+ background-color: #7dd87d;
}
.card-date.due-date {
- background-color: #ff9f19; /* Orange for due */
+ background-color: #ffd700; /* Yellow for due */
+ color: #000; /* Black text for due */
}
.card-date.due-date:hover,
.card-date.due-date.is-active {
- background-color: #e68a00;
+ background-color: #e6c200;
}
.card-date.end-date {
- background-color: #a632db; /* Purple for end */
+ background-color: #ffb3b3; /* Light red for end */
+ color: #000; /* Black text for end */
}
.card-date.end-date:hover,
.card-date.end-date.is-active {
- background-color: #8a2bb8;
+ background-color: #ff9999;
}
.card-date.end-date time::before {
content: "🏁"; /* Finish flag - represents end/completion */
@@ -94,7 +130,7 @@
/* Generic date badge and custom field date */
.card-date:not(.received-date):not(.start-date):not(.due-date):not(.end-date) time::before {
- content: "📅"; /* Calendar - represents generic date */
+ /*content: "📅"; // Calendar - represents generic date */
}
.card-date time::before {
font-size: inherit;
diff --git a/client/components/cards/cardDate.jade b/client/components/cards/cardDate.jade
index c19f45528..c8c6f45a3 100644
--- a/client/components/cards/cardDate.jade
+++ b/client/components/cards/cardDate.jade
@@ -24,14 +24,14 @@ template(name="dateCustomField")
template(name="minicardReceivedDate")
if canModifyCard
- a.js-edit-date.card-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.js-edit-date.card-date.received-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
- a.card-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.card-date.received-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
@@ -40,14 +40,14 @@ template(name="minicardReceivedDate")
template(name="minicardStartDate")
if canModifyCard
- a.js-edit-date.card-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.js-edit-date.card-date.start-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
- a.card-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.card-date.start-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
@@ -56,14 +56,14 @@ template(name="minicardStartDate")
template(name="minicardDueDate")
if canModifyCard
- a.js-edit-date.card-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.js-edit-date.card-date.due-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
- a.card-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.card-date.due-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
@@ -72,14 +72,14 @@ template(name="minicardDueDate")
template(name="minicardEndDate")
if canModifyCard
- a.js-edit-date.card-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.js-edit-date.card-date.end-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
- a.card-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
+ a.card-date.end-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
@@ -93,3 +93,60 @@ template(name="minicardCustomFieldDate")
if showWeekOfYear
b
| {{showWeek}}
+
+template(name="editCardReceivedDatePopup")
+ form.edit-card-received-date
+ .datepicker
+ // Date input field (existing)
+ // Insert calendar selector right after date input
+ .calendar-selector
+ label(for="calendar-received") 🗓️
+ input#calendar-received.js-calendar-date(type="date")
+ // Time input field (if present)
+ .clear-date
+ a.js-clear-date {{_ 'clear'}}
+ .datepicker-actions
+ button.primary.wide.left(type="submit") {{_ 'save'}}
+ button.js-delete-date.negate.wide.right {{_ 'delete'}}
+
+template(name="editCardStartDatePopup")
+ form.edit-card-start-date
+ .datepicker
+ // Date input field (existing)
+ .calendar-selector
+ label(for="calendar-start") 🗓️
+ input#calendar-start.js-calendar-date(type="date")
+ // Time input field (if present)
+ .clear-date
+ a.js-clear-date {{_ 'clear'}}
+ .datepicker-actions
+ button.primary.wide.left(type="submit") {{_ 'save'}}
+ button.js-delete-date.negate.wide.right {{_ 'delete'}}
+
+template(name="editCardDueDatePopup")
+ form.edit-card-due-date
+ .datepicker
+ // Date input field (existing)
+ .calendar-selector
+ label(for="calendar-due") 🗓️
+ input#calendar-due.js-calendar-date(type="date")
+ // Time input field (if present)
+ .clear-date
+ a.js-clear-date {{_ 'clear'}}
+ .datepicker-actions
+ button.primary.wide.left(type="submit") {{_ 'save'}}
+ button.js-delete-date.negate.wide.right {{_ 'delete'}}
+
+template(name="editCardEndDatePopup")
+ form.edit-card-end-date
+ .datepicker
+ // Date input field (existing)
+ .calendar-selector
+ label(for="calendar-end") 🗓️
+ input#calendar-end.js-calendar-date(type="date")
+ // Time input field (if present)
+ .clear-date
+ a.js-clear-date {{_ 'clear'}}
+ .datepicker-actions
+ button.primary.wide.left(type="submit") {{_ 'save'}}
+ button.js-delete-date.negate.wide.right {{_ 'delete'}}
diff --git a/client/components/cards/cardDate.js b/client/components/cards/cardDate.js
index 451af183c..a470bbba4 100644
--- a/client/components/cards/cardDate.js
+++ b/client/components/cards/cardDate.js
@@ -3,6 +3,7 @@ import { DatePicker } from '/client/lib/datepicker';
import {
formatDateTime,
formatDate,
+ formatDateByUserPreference,
formatTime,
getISOWeek,
isValidDate,
@@ -18,7 +19,8 @@ import {
now,
createDate,
fromNow,
- calendar
+ calendar,
+ diff
} from '/imports/lib/dateUtils';
// editCardReceivedDatePopup
@@ -47,12 +49,18 @@ import {
onRendered() {
super.onRendered();
- if (moment.isDate(this.card.getReceived())) {
- this.$('.js-datepicker').datepicker(
- 'setStartDate',
- this.card.getReceived(),
- );
- }
+ // DatePicker base class handles initialization with native HTML inputs
+ const self = this;
+ this.$('.js-calendar-date').on('change', function(evt) {
+ const currentUser = ReactiveCache.getCurrentUser && ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ const value = evt.target.value;
+ if (value) {
+ // Format date according to user preference
+ const formatted = formatDateByUserPreference(new Date(value), dateFormat, true);
+ self._storeDate(new Date(value));
+ }
+ });
}
_storeDate(date) {
@@ -73,9 +81,7 @@ import {
onRendered() {
super.onRendered();
- if (moment.isDate(this.card.getStart())) {
- this.$('.js-datepicker').datepicker('setStartDate', this.card.getStart());
- }
+ // DatePicker base class handles initialization with native HTML inputs
}
_storeDate(date) {
@@ -96,9 +102,7 @@ import {
onRendered() {
super.onRendered();
- if (moment.isDate(this.card.getStart())) {
- this.$('.js-datepicker').datepicker('setStartDate', this.card.getStart());
- }
+ // DatePicker base class handles initialization with native HTML inputs
}
_storeDate(date) {
@@ -130,11 +134,18 @@ const CardDate = BlazeComponent.extendComponent({
},
showWeekOfYear() {
- return ReactiveCache.getCurrentUser().isShowWeekOfYear();
+ const user = ReactiveCache.getCurrentUser();
+ if (!user) {
+ // For non-logged-in users, week of year is not shown
+ return false;
+ }
+ return user.isShowWeekOfYear();
},
showDate() {
- return calendar(this.date.get());
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
},
showISODate() {
@@ -157,21 +168,26 @@ class CardReceivedDate extends CardDate {
const endAt = this.data().getEnd();
const startAt = this.data().getStart();
const theDate = this.date.get();
- // if dueAt, endAt and startAt exist & are > receivedAt, receivedAt doesn't need to be flagged
+ const now = this.now.get();
+
+ // Received date logic: if received date is after start, due, or end dates, it's overdue
if (
- (startAt && theDate.isAfter(startAt)) ||
- (endAt && theDate.isAfter(endAt)) ||
- (dueAt && theDate.isAfter(dueAt))
- )
- classes += 'long-overdue';
- else classes += 'current';
+ (startAt && isAfter(theDate, startAt)) ||
+ (endAt && isAfter(theDate, endAt)) ||
+ (dueAt && isAfter(theDate, dueAt))
+ ) {
+ classes += 'overdue';
+ } else {
+ classes += 'not-due';
+ }
return classes;
}
showTitle() {
- return `${TAPi18n.__('card-received-on')} ${this.date
- .get()
- .format('LLLL')}`;
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ const formattedDate = formatDateByUserPreference(this.date.get(), dateFormat, true);
+ return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
}
events() {
@@ -192,21 +208,30 @@ class CardStartDate extends CardDate {
}
classes() {
- let classes = 'start-date' + ' ';
+ let classes = 'start-date ';
const dueAt = this.data().getDue();
const endAt = this.data().getEnd();
const theDate = this.date.get();
const now = this.now.get();
- // if dueAt or endAt exist & are > startAt, startAt doesn't need to be flagged
- if ((endAt && theDate.isAfter(endAt)) || (dueAt && theDate.isAfter(dueAt)))
- classes += 'long-overdue';
- else if (theDate.isAfter(now)) classes += '';
- else classes += 'current';
+
+ // Start date logic: if start date is after due or end dates, it's overdue
+ if ((endAt && isAfter(theDate, endAt)) || (dueAt && isAfter(theDate, dueAt))) {
+ classes += 'overdue';
+ } else if (isAfter(theDate, now)) {
+ // Start date is in the future - not due yet
+ classes += 'not-due';
+ } else {
+ // Start date is today or in the past - current/active
+ classes += 'current';
+ }
return classes;
}
showTitle() {
- return `${TAPi18n.__('card-start-on')} ${this.date.get().format('LLLL')}`;
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ const formattedDate = formatDateByUserPreference(this.date.get(), dateFormat, true);
+ return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
}
events() {
@@ -227,22 +252,43 @@ class CardDueDate extends CardDate {
}
classes() {
- let classes = 'due-date' + ' ';
+ let classes = 'due-date ';
const endAt = this.data().getEnd();
const theDate = this.date.get();
const now = this.now.get();
- // if the due date is after the end date, green - done early
- if (endAt && theDate.isAfter(endAt)) classes += 'current';
- // if there is an end date, don't need to flag the due date
- else if (endAt) classes += '';
- else if (now.diff(theDate, 'days') >= 2) classes += 'long-overdue';
- else if (now.diff(theDate, 'minute') >= 0) classes += 'due';
- else if (now.diff(theDate, 'days') >= -1) classes += 'almost-due';
+
+ // If there's an end date and it's before the due date, task is completed early
+ if (endAt && isBefore(endAt, theDate)) {
+ classes += 'completed-early';
+ }
+ // If there's an end date, don't show due date status since task is completed
+ else if (endAt) {
+ classes += 'completed';
+ }
+ // Due date logic based on current time
+ else {
+ const daysDiff = diff(theDate, now, 'days');
+
+ if (daysDiff < 0) {
+ // Due date is in the past - overdue
+ classes += 'overdue';
+ } else if (daysDiff <= 1) {
+ // Due today or tomorrow - due soon
+ classes += 'due-soon';
+ } else {
+ // Due date is more than 1 day away - not due yet
+ classes += 'not-due';
+ }
+ }
+
return classes;
}
showTitle() {
- return `${TAPi18n.__('card-due-on')} ${this.date.get().format('LLLL')}`;
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ const formattedDate = formatDateByUserPreference(this.date.get(), dateFormat, true);
+ return `${TAPi18n.__('card-due-on')} ${formattedDate}`;
}
events() {
@@ -263,17 +309,28 @@ class CardEndDate extends CardDate {
}
classes() {
- let classes = 'end-date' + ' ';
+ let classes = 'end-date ';
const dueAt = this.data().getDue();
const theDate = this.date.get();
- if (!dueAt) classes += '';
- else if (theDate.isBefore(dueAt)) classes += 'current';
- else if (theDate.isAfter(dueAt)) classes += 'due';
+
+ if (!dueAt) {
+ // No due date set - just show as completed
+ classes += 'completed';
+ } else if (isBefore(theDate, dueAt)) {
+ // End date is before due date - completed early
+ classes += 'completed-early';
+ } else if (isAfter(theDate, dueAt)) {
+ // End date is after due date - completed late
+ classes += 'completed-late';
+ } else {
+ // End date equals due date - completed on time
+ classes += 'completed-on-time';
+ }
return classes;
}
showTitle() {
- return `${TAPi18n.__('card-end-on')} ${this.date.get().format('LLLL')}`;
+ return `${TAPi18n.__('card-end-on')} ${format(this.date.get(), 'LLLL')}`;
}
events() {
@@ -302,7 +359,12 @@ class CardCustomFieldDate extends CardDate {
}
showWeekOfYear() {
- return ReactiveCache.getCurrentUser().isShowWeekOfYear();
+ const user = ReactiveCache.getCurrentUser();
+ if (!user) {
+ // For non-logged-in users, week of year is not shown
+ return false;
+ }
+ return user.isShowWeekOfYear();
}
showDate() {
@@ -315,7 +377,10 @@ class CardCustomFieldDate extends CardDate {
}
showTitle() {
- return `${this.date.get().format('LLLL')}`;
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ const formattedDate = formatDateByUserPreference(this.date.get(), dateFormat, true);
+ return `${formattedDate}`;
}
classes() {
@@ -334,7 +399,9 @@ CardCustomFieldDate.register('cardCustomFieldDate');
}
showDate() {
- return format(this.date.get(), 'L');
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
}.register('minicardReceivedDate'));
@@ -344,7 +411,9 @@ CardCustomFieldDate.register('cardCustomFieldDate');
}
showDate() {
- return format(this.date.get(), 'YYYY-MM-DD HH:mm');
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
}.register('minicardStartDate'));
@@ -354,7 +423,9 @@ CardCustomFieldDate.register('cardCustomFieldDate');
}
showDate() {
- return format(this.date.get(), 'YYYY-MM-DD HH:mm');
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
}.register('minicardDueDate'));
@@ -364,7 +435,9 @@ CardCustomFieldDate.register('cardCustomFieldDate');
}
showDate() {
- return format(this.date.get(), 'YYYY-MM-DD HH:mm');
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
}.register('minicardEndDate'));
@@ -374,7 +447,9 @@ CardCustomFieldDate.register('cardCustomFieldDate');
}
showDate() {
- return format(this.date.get(), 'L');
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
}.register('minicardCustomFieldDate'));
@@ -391,7 +466,9 @@ class VoteEndDate extends CardDate {
return classes;
}
showDate() {
- return format(this.date.get(), 'L') + ' ' + format(this.date.get(), 'HH:mm');
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
showTitle() {
return `${TAPi18n.__('card-end-on')} ${this.date.get().toLocaleString()}`;
@@ -418,10 +495,12 @@ class PokerEndDate extends CardDate {
return classes;
}
showDate() {
- return this.date.get().format('l LT');
+ const currentUser = ReactiveCache.getCurrentUser();
+ const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
+ return formatDateByUserPreference(this.date.get(), dateFormat, true);
}
showTitle() {
- return `${TAPi18n.__('card-end-on')} ${this.date.get().format('LLLL')}`;
+ return `${TAPi18n.__('card-end-on')} ${format(this.date.get(), 'LLLL')}`;
}
events() {
diff --git a/client/components/cards/cardDetails.css b/client/components/cards/cardDetails.css
index 8fbf0f717..fb68b2957 100644
--- a/client/components/cards/cardDetails.css
+++ b/client/components/cards/cardDetails.css
@@ -1,3 +1,31 @@
+/* Date Format Selector */
+.card-details-item-date-format {
+ margin-bottom: 10px;
+}
+
+.card-details-item-date-format .card-details-item-title {
+ font-size: 14px;
+ font-weight: bold;
+ margin-bottom: 5px;
+ color: #333;
+}
+
+.card-details-item-date-format .js-date-format-selector {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ background-color: #fff;
+ font-size: 14px;
+ cursor: pointer;
+}
+
+.card-details-item-date-format .js-date-format-selector:focus {
+ outline: none;
+ border-color: #007cba;
+ box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2);
+}
+
.assignee {
border-radius: 3px;
display: block;
diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade
index b3d44a52b..cf810b38a 100644
--- a/client/components/cards/cardDetails.jade
+++ b/client/components/cards/cardDetails.jade
@@ -113,6 +113,16 @@ template(name="cardDetails")
if currentBoard.hasAnyAllowsDate
hr
+ .card-details-item.card-details-item-date-format
+ h3.card-details-item-title
+ | 📅
+ | {{_ 'date-format'}}
+ .card-details-item-content
+ select.js-date-format-selector
+ option(value="YYYY-MM-DD" selected="{{#if isDateFormat 'YYYY-MM-DD'}}selected{{/if}}") {{_ 'date-format-yyyy-mm-dd'}}
+ option(value="DD-MM-YYYY" selected="{{#if isDateFormat 'DD-MM-YYYY'}}selected{{/if}}") {{_ 'date-format-dd-mm-yyyy'}}
+ option(value="MM-DD-YYYY" selected="{{#if isDateFormat 'MM-DD-YYYY'}}selected{{/if}}") {{_ 'date-format-mm-dd-yyyy'}}
+
if currentBoard.allowsReceivedDate
.card-details-item.card-details-item-received
h3.card-details-item-title
@@ -181,7 +191,7 @@ template(name="cardDetails")
if currentBoard.allowsMembers
.card-details-item.card-details-item-members
h3.card-details-item-title
- | 👤s
+ | 👥
| {{_ 'members'}}
each userId in getMembers
+userAvatar(userId=userId cardId=_id)
@@ -232,7 +242,7 @@ template(name="cardDetails")
if currentBoard.allowsAssignedBy
.card-details-item.card-details-item-name
h3.card-details-item-title
- | 👤-plus
+ | ✍️
| {{_ 'assigned-by'}}
if canModifyCard
unless currentUser.isWorker
diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js
index da95765ba..43ee28473 100644
--- a/client/components/cards/cardDetails.js
+++ b/client/components/cards/cardDetails.js
@@ -306,6 +306,10 @@ BlazeComponent.extendComponent({
const $tooltip = this.$('.card-details-header .copied-tooltip');
Utils.showCopied(promise, $tooltip);
},
+ 'change .js-date-format-selector'(event) {
+ const dateFormat = event.target.value;
+ Meteor.call('changeDateFormat', dateFormat);
+ },
'click .js-open-card-details-menu': Popup.open('cardDetailsActions'),
'submit .js-card-description'(event) {
event.preventDefault();
@@ -426,56 +430,57 @@ BlazeComponent.extendComponent({
) {
newState = forIt;
}
- this.data().setVote(Meteor.userId(), newState);
+ // Use secure server method; direct client updates to vote are blocked
+ Meteor.call('cards.vote', this.data()._id, newState);
},
'click .js-poker'(e) {
let newState = null;
if ($(e.target).hasClass('js-poker-vote-one')) {
newState = 'one';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-two')) {
newState = 'two';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-three')) {
newState = 'three';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-five')) {
newState = 'five';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-eight')) {
newState = 'eight';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-thirteen')) {
newState = 'thirteen';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-twenty')) {
newState = 'twenty';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-forty')) {
newState = 'forty';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-one-hundred')) {
newState = 'oneHundred';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
if ($(e.target).hasClass('js-poker-vote-unsure')) {
newState = 'unsure';
- this.data().setPoker(Meteor.userId(), newState);
+ Meteor.call('cards.pokerVote', this.data()._id, newState);
}
},
'click .js-poker-finish'(e) {
if ($(e.target).hasClass('js-poker-finish')) {
e.preventDefault();
- const now = formatDateTime(new Date());
- this.data().setPokerEnd(now);
+ const now = new Date();
+ Meteor.call('cards.setPokerEnd', this.data()._id, now);
}
},
@@ -483,9 +488,9 @@ BlazeComponent.extendComponent({
if ($(e.target).hasClass('js-poker-replay')) {
e.preventDefault();
this.currentCard = this.currentData();
- this.currentCard.replayPoker();
- this.data().unsetPokerEnd();
- this.data().unsetPokerEstimation();
+ Meteor.call('cards.replayPoker', this.currentCard._id);
+ Meteor.call('cards.unsetPokerEnd', this.currentCard._id);
+ Meteor.call('cards.unsetPokerEstimation', this.currentCard._id);
}
},
'click .js-poker-estimation'(event) {
@@ -496,63 +501,66 @@ BlazeComponent.extendComponent({
this.find('#pokerEstimation').value = '';
if (ruleTitle) {
- this.data().setPokerEstimation(parseInt(ruleTitle, 10));
+ Meteor.call('cards.setPokerEstimation', this.data()._id, parseInt(ruleTitle, 10));
} else {
- this.data().setPokerEstimation('');
+ Meteor.call('cards.unsetPokerEstimation', this.data()._id);
}
}
},
// Drag and drop file upload handlers
'dragover .js-card-details'(event) {
- event.preventDefault();
- event.stopPropagation();
+ // Only prevent default for file drags to avoid interfering with other drag operations
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
},
'dragenter .js-card-details'(event) {
- event.preventDefault();
- event.stopPropagation();
- const card = this.data();
- const board = card.board();
- // Only allow drag-and-drop if user can modify card and board allows attachments
- if (card.canModifyCard() && board && board.allowsAttachments) {
- // Check if the drag contains files
- const dataTransfer = event.originalEvent.dataTransfer;
- if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ const card = this.data();
+ const board = card.board();
+ // Only allow drag-and-drop if user can modify card and board allows attachments
+ if (Utils.canModifyCard() && board && board.allowsAttachments) {
$(event.currentTarget).addClass('is-dragging-over');
}
}
},
'dragleave .js-card-details'(event) {
- event.preventDefault();
- event.stopPropagation();
- $(event.currentTarget).removeClass('is-dragging-over');
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ $(event.currentTarget).removeClass('is-dragging-over');
+ }
},
'drop .js-card-details'(event) {
- event.preventDefault();
- event.stopPropagation();
- $(event.currentTarget).removeClass('is-dragging-over');
-
- const card = this.data();
- const board = card.board();
-
- // Check permissions
- if (!card.canModifyCard() || !board || !board.allowsAttachments) {
- return;
- }
-
- // Check if this is a file drop (not a checklist item reorder)
const dataTransfer = event.originalEvent.dataTransfer;
- if (!dataTransfer || !dataTransfer.files || dataTransfer.files.length === 0) {
- return;
- }
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ $(event.currentTarget).removeClass('is-dragging-over');
- // Check if the drop contains files (not just text/HTML)
- if (!dataTransfer.types.includes('Files')) {
- return;
- }
+ const card = this.data();
+ const board = card.board();
- const files = dataTransfer.files;
- if (files && files.length > 0) {
- handleFileUpload(card, files);
+ // Check permissions
+ if (!Utils.canModifyCard() || !board || !board.allowsAttachments) {
+ return;
+ }
+
+ // Check if this is a file drop (not a checklist item reorder)
+ if (!dataTransfer.files || dataTransfer.files.length === 0) {
+ return;
+ }
+
+ const files = dataTransfer.files;
+ if (files && files.length > 0) {
+ handleFileUpload(card, files);
+ }
}
},
},
@@ -565,6 +573,11 @@ Template.cardDetails.helpers({
let ret = !!Utils.getPopupCardId();
return ret;
},
+ isDateFormat(format) {
+ const currentUser = ReactiveCache.getCurrentUser();
+ if (!currentUser) return format === 'YYYY-MM-DD';
+ return currentUser.getDateFormat() === format;
+ },
// Upload progress helpers
hasActiveUploads() {
return uploadProgressManager.hasActiveUploads(this._id);
@@ -1093,20 +1106,15 @@ BlazeComponent.extendComponent({
'is-checked',
);
const endString = this.currentCard.getVoteEnd();
-
- this.currentCard.setVoteQuestion(
- voteQuestion,
- publicVote,
- allowNonBoardMembers,
- );
+ Meteor.call('cards.setVoteQuestion', this.currentCard._id, voteQuestion, publicVote, allowNonBoardMembers);
if (endString) {
- this.currentCard.setVoteEnd(endString);
+ Meteor.call('cards.setVoteEnd', this.currentCard._id, endString);
}
Popup.back();
},
'click .js-remove-vote': Popup.afterConfirm('deleteVote', () => {
event.preventDefault();
- this.currentCard.unsetVote();
+ Meteor.call('cards.unsetVote', this.currentCard._id);
Popup.back();
}),
'click a.js-toggle-vote-public'(event) {
@@ -1305,10 +1313,10 @@ BlazeComponent.extendComponent({
];
}
_storeDate(newDate) {
- this.card.setVoteEnd(newDate);
+ Meteor.call('cards.setVoteEnd', this.card._id, newDate);
}
_deleteDate() {
- this.card.unsetVoteEnd();
+ Meteor.call('cards.unsetVoteEnd', this.card._id);
}
}.register('editVoteEndDatePopup'));
@@ -1330,17 +1338,14 @@ BlazeComponent.extendComponent({
);
const endString = this.currentCard.getPokerEnd();
- this.currentCard.setPokerQuestion(
- pokerQuestion,
- allowNonBoardMembers,
- );
+ Meteor.call('cards.setPokerQuestion', this.currentCard._id, pokerQuestion, allowNonBoardMembers);
if (endString) {
- this.currentCard.setPokerEnd(endString);
+ Meteor.call('cards.setPokerEnd', this.currentCard._id, new Date(endString));
}
Popup.back();
},
'click .js-remove-poker': Popup.afterConfirm('deletePoker', (event) => {
- this.currentCard.unsetPoker();
+ Meteor.call('cards.unsetPoker', this.currentCard._id);
Popup.back();
}),
'click a.js-toggle-poker-allow-non-members'(event) {
@@ -1561,10 +1566,10 @@ BlazeComponent.extendComponent({
];
}
_storeDate(newDate) {
- this.card.setPokerEnd(newDate);
+ Meteor.call('cards.setPokerEnd', this.card._id, newDate);
}
_deleteDate() {
- this.card.unsetPokerEnd();
+ Meteor.call('cards.unsetPokerEnd', this.card._id);
}
}.register('editPokerEndDatePopup'));
diff --git a/client/components/cards/checklists.css b/client/components/cards/checklists.css
index 6b8c7e8f9..566df27f0 100644
--- a/client/components/cards/checklists.css
+++ b/client/components/cards/checklists.css
@@ -72,6 +72,10 @@ textarea.js-edit-checklist-item {
padding-top: 3px;
float: left;
}
+.checklist-title span.fa.checklist-handle.fa-arrows::before {
+ content: "↕️" !important;
+ font-family: inherit !important;
+}
#card-details-overlay {
top: 0;
bottom: -600px;
@@ -148,6 +152,10 @@ textarea.js-edit-checklist-item {
padding-top: 2px;
padding-right: 10px;
}
+.checklist-item span.fa.checklistitem-handle.fa-arrows::before {
+ content: "↕️" !important;
+ font-family: inherit !important;
+}
.js-delete-checklist-item,
.js-convert-checklist-item-to-card {
margin: 0 0 0.5em 1.33em;
diff --git a/client/components/cards/checklists.jade b/client/components/cards/checklists.jade
index 3adccaf43..39ed211b1 100644
--- a/client/components/cards/checklists.jade
+++ b/client/components/cards/checklists.jade
@@ -69,6 +69,7 @@ template(name="addChecklistItemForm")
.edit-controls.clearfix
button.primary.confirm.js-submit-add-checklist-item-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form(title="{{_ 'close-add-checklist-item'}}")
+ | ❌
if showNewlineBecomesNewChecklistItem
.material-toggle-switch(title="{{_ 'newlineBecomesNewChecklistItem'}}")
input.toggle-switch(type="checkbox" id="toggleNewlineBecomesNewChecklistItem")
@@ -91,6 +92,7 @@ template(name="editChecklistItemForm")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-checklist-item-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form(title="{{_ 'close-edit-checklist-item'}}")
+ | ❌
span(title=createdAt) {{ moment createdAt }}
if canModifyCard
a.js-delete-checklist-item {{_ "delete"}}...
@@ -125,8 +127,7 @@ template(name='checklistItemDetail')
if canModifyCard
.check-box-container
.check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
- if isTouchScreenOrShowDesktopDragHandles
- span.fa.checklistitem-handle(class="fa-arrows" title="{{_ 'dragChecklistItem'}}")
+ span.fa.checklistitem-handle(class="fa-arrows" title="{{_ 'dragChecklistItem'}}")
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
diff --git a/client/components/cards/labels.js b/client/components/cards/labels.js
index e09598189..2962cae77 100644
--- a/client/components/cards/labels.js
+++ b/client/components/cards/labels.js
@@ -125,8 +125,19 @@ Template.createLabelPopup.events({
.$('#labelName')
.val()
.trim();
- const color = Blaze.getData(templateInstance.find('.fa-check')).color;
- board.addLabel(name, color);
+
+ // Find the selected color by looking for the palette color that contains the checkmark
+ let selectedColor = null;
+ templateInstance.$('.js-palette-color').each(function() {
+ if ($(this).text().includes('✅')) {
+ selectedColor = Blaze.getData(this).color;
+ return false; // break out of loop
+ }
+ });
+
+ if (selectedColor) {
+ board.addLabel(name, selectedColor);
+ }
Popup.back();
},
});
@@ -144,8 +155,19 @@ Template.editLabelPopup.events({
.$('#labelName')
.val()
.trim();
- const color = Blaze.getData(templateInstance.find('.fa-check')).color;
- board.editLabel(this._id, name, color);
+
+ // Find the selected color by looking for the palette color that contains the checkmark
+ let selectedColor = null;
+ templateInstance.$('.js-palette-color').each(function() {
+ if ($(this).text().includes('✅')) {
+ selectedColor = Blaze.getData(this).color;
+ return false; // break out of loop
+ }
+ });
+
+ if (selectedColor) {
+ board.editLabel(this._id, name, selectedColor);
+ }
Popup.back();
},
});
diff --git a/client/components/cards/minicard.css b/client/components/cards/minicard.css
index 637be7763..8e6158826 100644
--- a/client/components/cards/minicard.css
+++ b/client/components/cards/minicard.css
@@ -99,8 +99,8 @@
float: none;
}
.minicard .minicard-labels .minicard-label {
- width: 1.5vw;
- height: 1.5vw;
+ width: clamp(12px, 1.5vw, 16px);
+ height: clamp(12px, 1.5vw, 16px);
border-radius: 0.3vw;
margin-right: 0.4vw;
margin-bottom: 0.4vh;
@@ -130,8 +130,8 @@
margin-right: 0.5vw;
}
.minicard .handle {
- width: 2.5vw;
- height: 2.5vw;
+ width: clamp(20px, 2.5vw, 28px);
+ height: clamp(20px, 2.5vw, 28px);
position: absolute;
right: 0.7vw;
top: 0.7vh;
@@ -169,6 +169,134 @@
margin-right: 0.4vw;
}
+/* Unicode icons for minicard dates - matching cardDate.css */
+.minicard .card-date.end-date time::before {
+ content: "🏁"; /* Finish flag - represents end/completion */
+}
+.minicard .card-date.due-date time::before {
+ content: "⏰"; /* Alarm clock - represents due/deadline */
+}
+.minicard .card-date.start-date time::before {
+ content: "🚀"; /* Rocket - represents start/launch */
+}
+.minicard .card-date.received-date time::before {
+ content: "📥"; /* Inbox tray - represents received/incoming */
+}
+
+.minicard .card-date time::before {
+ font-size: inherit;
+ margin-right: 0.3em;
+ display: inline-block;
+}
+
+/* Date type specific colors for minicards - matching cardDate.css */
+.minicard .card-date.received-date {
+ background-color: #dbdbdb; /* Grey for received - same as base card-date */
+}
+
+.minicard .card-date.received-date:hover,
+.minicard .card-date.received-date.is-active {
+ background-color: #b3b3b3;
+}
+
+.minicard .card-date.start-date {
+ background-color: #90ee90; /* Light green for start */
+ color: #000; /* Black text for start */
+}
+
+.minicard .card-date.start-date:hover,
+.minicard .card-date.start-date.is-active {
+ background-color: #7dd87d;
+}
+
+.minicard .card-date.due-date {
+ background-color: #ffd700; /* Yellow for due */
+ color: #000; /* Black text for due */
+}
+
+.minicard .card-date.due-date:hover,
+.minicard .card-date.due-date.is-active {
+ background-color: #e6c200;
+}
+
+.minicard .card-date.end-date {
+ background-color: #ffb3b3; /* Light red for end */
+ color: #000; /* Black text for end */
+}
+
+.minicard .card-date.end-date:hover,
+.minicard .card-date.end-date.is-active {
+ background-color: #ff9999;
+}
+
+/* Date status colors for minicards - matching cardDate.css */
+.minicard .card-date.overdue {
+ background-color: #ff4444 !important; /* Red for overdue */
+ color: #fff !important;
+}
+.minicard .card-date.overdue:hover,
+.minicard .card-date.overdue.is-active {
+ background-color: #cc3333 !important;
+}
+
+.minicard .card-date.due-soon {
+ background-color: #ffaa00 !important; /* Amber for due soon */
+ color: #000 !important;
+}
+.minicard .card-date.due-soon:hover,
+.minicard .card-date.due-soon.is-active {
+ background-color: #e69900 !important;
+}
+
+.minicard .card-date.not-due {
+ /* No special background - uses default date type colors */
+}
+
+.minicard .card-date.current {
+ background-color: #5ba639 !important; /* Green for current/active */
+ color: #fff !important;
+}
+.minicard .card-date.current:hover,
+.minicard .card-date.current.is-active {
+ background-color: #46802c !important;
+}
+
+.minicard .card-date.completed {
+ background-color: #90ee90 !important; /* Light green for completed */
+ color: #000 !important;
+}
+.minicard .card-date.completed:hover,
+.minicard .card-date.completed.is-active {
+ background-color: #7dd87d !important;
+}
+
+.minicard .card-date.completed-early {
+ background-color: #4caf50 !important; /* Green for completed early */
+ color: #fff !important;
+}
+.minicard .card-date.completed-early:hover,
+.minicard .card-date.completed-early.is-active {
+ background-color: #45a049 !important;
+}
+
+.minicard .card-date.completed-late {
+ background-color: #ff9800 !important; /* Orange for completed late */
+ color: #fff !important;
+}
+.minicard .card-date.completed-late:hover,
+.minicard .card-date.completed-late.is-active {
+ background-color: #f57c00 !important;
+}
+
+.minicard .card-date.completed-on-time {
+ background-color: #2196f3 !important; /* Blue for completed on time */
+ color: #fff !important;
+}
+.minicard .card-date.completed-on-time:hover,
+.minicard .card-date.completed-on-time.is-active {
+ background-color: #1976d2 !important;
+}
+
/* Font Awesome icons in minicard dates */
.minicard .card-date i.fa {
margin-right: 0.3vw;
@@ -234,8 +362,8 @@
.minicard .minicard-creator .member {
float: right;
border-radius: 50%;
- height: 3.5vw;
- width: 3.5vw;
+ height: clamp(24px, 3.5vw, 32px);
+ width: clamp(24px, 3.5vw, 32px);
margin-bottom: 0.5vh;
}
.minicard .minicard-members .assignee,
@@ -243,8 +371,8 @@
.minicard .minicard-creator .assignee {
float: right;
border-radius: 50%;
- height: 3.5vw;
- width: 3.5vw;
+ height: clamp(24px, 3.5vw, 32px);
+ width: clamp(24px, 3.5vw, 32px);
}
.minicard .minicard-members + .badges,
.minicard .minicard-assignees + .badges,
diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade
index 686fd5eac..b36af1ceb 100644
--- a/client/components/cards/minicard.jade
+++ b/client/components/cards/minicard.jade
@@ -4,19 +4,14 @@ template(name="minicard")
class="{{#if isLinkedBoard}}linked-board{{/if}}"
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
if canModifyCard
- if isTouchScreenOrShowDesktopDragHandles
- a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}") | ☰
- .handle
- | ↔️
- else
- a.minicard-details-menu.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}") | ☰
+ a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}") ☰
+ if canMoveCard
+ .handle
+ | ↕️
.dates
if getReceived
- unless getStart
- unless getDue
- unless getEnd
- .date
- +minicardReceivedDate
+ .date
+ +minicardReceivedDate
if getStart
.date
+minicardStartDate
@@ -147,7 +142,7 @@ template(name="minicard")
if canModifyCard
if comments.length
.badge(title="{{_ 'card-comments-title' comments.length }}")
- span.badge-icon.badge-comment.badge-text | 💬
+ span.badge-icon.badge-comment.badge-text 💬
= ' '
= comments.length
//span.badge-comment.badge-text
@@ -155,36 +150,36 @@ template(name="minicard")
if getDescription
unless currentBoard.allowsDescriptionTextOnMinicard
.badge.badge-state-image-only(title=getDescription)
- span.badge-icon | 📝
+ span.badge-icon 📝
if getVoteQuestion
.badge.badge-state-image-only(title=getVoteQuestion)
- span.badge-icon(class="{{#if voteState}}text-green{{/if}}") | 👍
+ span.badge-icon(class="{{#if voteState}}text-green{{/if}}") 👍
span.badge-text {{ voteCountPositive }}
- span.badge-icon(class="{{#if $eq voteState false}}text-red{{/if}}") | 👎
+ span.badge-icon(class="{{#if $eq voteState false}}text-red{{/if}}") 👎
span.badge-text {{ voteCountNegative }}
if getPokerQuestion
.badge.badge-state-image-only(title=getPokerQuestion)
- span.badge-icon(class="{{#if pokerState}}text-green{{/if}}") | ✅
+ span.badge-icon(class="{{#if pokerState}}text-green{{/if}}") ✅
if expiredPoker
span.badge-text {{ getPokerEstimation }}
if attachments.length
if currentBoard.allowsBadgeAttachmentOnMinicard
.badge
- span.badge-icon | 📎
+ span.badge-icon 📎
span.badge-text= attachments.length
if checklists.length
.badge(class="{{#if checklistFinished}}is-finished{{/if}}")
- span.badge-icon | ☑️
+ span.badge-icon ☑️
span.badge-text.check-list-text {{checklistFinishedCount}}/{{checklistItemCount}}
if allSubtasks.count
.badge
- span.badge-icon | 🌐
+ span.badge-icon 🌐
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
if currentBoard.allowsCardSortingByNumber
if currentBoard.allowsCardSortingByNumberOnMinicard
.badge
- span.badge-icon | 🔢
+ span.badge-icon 🔢
span.badge-text.check-list-sort {{ sort }}
if currentBoard.allowsDescriptionTextOnMinicard
if getDescription
diff --git a/client/components/cards/minicard.js b/client/components/cards/minicard.js
index 2cecccd7e..91ebddc8c 100644
--- a/client/components/cards/minicard.js
+++ b/client/components/cards/minicard.js
@@ -111,55 +111,58 @@ BlazeComponent.extendComponent({
'click .js-open-minicard-details-menu': Popup.open('minicardDetailsActions'),
// Drag and drop file upload handlers
'dragover .minicard'(event) {
- event.preventDefault();
- event.stopPropagation();
+ // Only prevent default for file drags to avoid interfering with sortable
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
},
'dragenter .minicard'(event) {
- event.preventDefault();
- event.stopPropagation();
- const card = this.data();
- const board = card.board();
- // Only allow drag-and-drop if user can modify card and board allows attachments
- if (card.canModifyCard() && board && board.allowsAttachments) {
- // Check if the drag contains files
- const dataTransfer = event.originalEvent.dataTransfer;
- if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ const card = this.data();
+ const board = card.board();
+ // Only allow drag-and-drop if user can modify card and board allows attachments
+ if (Utils.canModifyCard() && board && board.allowsAttachments) {
$(event.currentTarget).addClass('is-dragging-over');
}
}
},
'dragleave .minicard'(event) {
- event.preventDefault();
- event.stopPropagation();
- $(event.currentTarget).removeClass('is-dragging-over');
+ const dataTransfer = event.originalEvent.dataTransfer;
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ $(event.currentTarget).removeClass('is-dragging-over');
+ }
},
'drop .minicard'(event) {
- event.preventDefault();
- event.stopPropagation();
- $(event.currentTarget).removeClass('is-dragging-over');
-
- const card = this.data();
- const board = card.board();
-
- // Check permissions
- if (!card.canModifyCard() || !board || !board.allowsAttachments) {
- return;
- }
-
- // Check if this is a file drop (not a card reorder)
const dataTransfer = event.originalEvent.dataTransfer;
- if (!dataTransfer || !dataTransfer.files || dataTransfer.files.length === 0) {
- return;
- }
+ if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
+ event.preventDefault();
+ event.stopPropagation();
+ $(event.currentTarget).removeClass('is-dragging-over');
- // Check if the drop contains files (not just text/HTML)
- if (!dataTransfer.types.includes('Files')) {
- return;
- }
+ const card = this.data();
+ const board = card.board();
- const files = dataTransfer.files;
- if (files && files.length > 0) {
- handleFileUpload(card, files);
+ // Check permissions
+ if (!Utils.canModifyCard() || !board || !board.allowsAttachments) {
+ return;
+ }
+
+ // Check if this is a file drop (not a card reorder)
+ if (!dataTransfer.files || dataTransfer.files.length === 0) {
+ return;
+ }
+
+ const files = dataTransfer.files;
+ if (files && files.length > 0) {
+ handleFileUpload(card, files);
+ }
}
},
}
diff --git a/client/components/import/import.jade b/client/components/import/import.jade
index 7b55dadbd..ed42fe44b 100644
--- a/client/components/import/import.jade
+++ b/client/components/import/import.jade
@@ -56,17 +56,17 @@ template(name="importMapMembersAddPopup")
p
| {{_ 'import-user-select'}}
.js-map-member
- +EasySearch.Input(index=searchIndex)
+ input.js-search-member-input(type="text" placeholder="{{_ 'search-users'}}")
ul.pop-over-list
- +EasySearch.Each(index=searchIndex)
+ each searchResults
li.item.js-member-item
- a.name.js-select-import(title="{{profile.fullname}} ({{username}})" data-id="{{__originalId}}")
- +userAvatar(userId=__originalId)
+ a.name.js-select-import(title="{{profile.fullname}} ({{username}})" data-id="{{_id}}")
+ +userAvatar(userId=_id)
span.full-name
= profile.fullname
| ({{username}})
- +EasySearch.IfSearching(index=searchIndex)
+ if searching.get
+spinner
- +EasySearch.IfNoResults(index=searchIndex)
+ if noResults.get
.manage-member-section
p.quiet {{_ 'no-results'}}
diff --git a/client/components/import/import.js b/client/components/import/import.js
index 4d4ba7fa7..757b55e41 100644
--- a/client/components/import/import.js
+++ b/client/components/import/import.js
@@ -311,6 +311,73 @@ BlazeComponent.extendComponent({
},
}).register('importMapMembersAddPopup');
+// Global reactive variables for import member popup
+const importMemberPopupState = {
+ searching: new ReactiveVar(false),
+ searchResults: new ReactiveVar([]),
+ noResults: new ReactiveVar(false),
+ searchTimeout: null
+};
+
+BlazeComponent.extendComponent({
+ onCreated() {
+ // Use global state
+ this.searching = importMemberPopupState.searching;
+ this.searchResults = importMemberPopupState.searchResults;
+ this.noResults = importMemberPopupState.noResults;
+ this.searchTimeout = importMemberPopupState.searchTimeout;
+ },
+
+ onRendered() {
+ this.find('.js-search-member-input').focus();
+ },
+
+ performSearch(query) {
+ if (!query || query.length < 2) {
+ this.searchResults.set([]);
+ this.noResults.set(false);
+ return;
+ }
+
+ this.searching.set(true);
+ this.noResults.set(false);
+
+ const results = UserSearchIndex.search(query, { limit: 20 }).fetch();
+ this.searchResults.set(results);
+ this.searching.set(false);
+
+ if (results.length === 0) {
+ this.noResults.set(true);
+ }
+ },
+
+ events() {
+ return [
+ {
+ 'keyup .js-search-member-input'(event) {
+ const query = event.target.value.trim();
+
+ if (this.searchTimeout) {
+ clearTimeout(this.searchTimeout);
+ }
+
+ this.searchTimeout = setTimeout(() => {
+ this.performSearch(query);
+ }, 300);
+ },
+ },
+ ];
+ },
+}).register('importMapMembersAddPopupSearch');
+
Template.importMapMembersAddPopup.helpers({
- searchIndex: () => UserSearchIndex,
+ searchResults() {
+ return importMemberPopupState.searchResults.get();
+ },
+ searching() {
+ return importMemberPopupState.searching;
+ },
+ noResults() {
+ return importMemberPopupState.noResults;
+ }
})
diff --git a/client/components/lists/list.css b/client/components/lists/list.css
index 8b76046ad..77e78de29 100644
--- a/client/components/lists/list.css
+++ b/client/components/lists/list.css
@@ -191,6 +191,11 @@ body.list-resizing-active * {
margin-right: 0 !important;
/* Ensure proper display */
display: inline-block !important;
+ /* Ensure it's clickable and shows proper cursor */
+ cursor: move !important;
+ pointer-events: auto !important;
+ /* Add some padding for better clickability */
+ padding: 4px !important;
}
/* Ensure buttons maintain original positioning */
@@ -373,9 +378,6 @@ body.list-resizing-active * {
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
-}
-.list-header .list-rotated {
-
}
.list-header .list-header-watch-icon {
padding-left: 10px;
@@ -639,17 +641,22 @@ body.list-resizing-active * {
.mini-list.mobile-view {
flex: 0 0 60px;
height: auto;
- width: 100%;
- min-width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
+ border-left: 0px !important;
border-bottom: 1px solid #ccc;
+ display: block !important;
}
.list.mobile-view {
- display: contents;
+ display: block !important;
flex-basis: auto;
- width: 100%;
- min-width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
+ border-left: 0px !important;
+ margin: 0 !important;
+ padding: 0 !important;
}
.list.mobile-view:first-child {
margin-left: 0px;
@@ -657,9 +664,11 @@ body.list-resizing-active * {
.list.mobile-view.ui-sortable-helper {
flex: 0 0 60px;
height: 60px;
- width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ border-left: 0px !important;
border-bottom: 1px solid #ccc;
+ display: block !important;
}
.list.mobile-view.ui-sortable-helper .list-header.ui-sortable-handle {
cursor: grabbing;
@@ -667,14 +676,17 @@ body.list-resizing-active * {
.list.mobile-view.placeholder {
flex: 0 0 60px;
height: 60px;
- width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ border-left: 0px !important;
border-bottom: 1px solid #ccc;
+ display: block !important;
}
.list.mobile-view .list-body {
padding: 15px 19px;
- width: 100%;
- min-width: 100%;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
}
.list.mobile-view .list-header {
/*Updated padding values for mobile devices, this should fix text grouping issue*/
@@ -683,8 +695,9 @@ body.list-resizing-active * {
min-height: 30px;
margin-top: 10px;
align-items: center;
- width: 100%;
- min-width: 100%;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
/* Force grid layout for iPhone */
display: grid !important;
grid-template-columns: 30px 1fr auto auto !important;
@@ -760,21 +773,27 @@ body.list-resizing-active * {
align-items: initial;
}
-@media screen and (max-width: 800px) {
+@media screen and (max-width: 800px),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.mini-list {
flex: 0 0 60px;
height: auto;
- width: 100%;
- min-width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
+ border-left: 0px !important;
border-bottom: 1px solid #ccc;
+ display: block !important;
}
.list {
- display: contents;
+ display: block !important;
flex-basis: auto;
- width: 100%;
- min-width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
+ border-left: 0px !important;
+ margin: 0 !important;
+ padding: 0 !important;
}
.list:first-child {
margin-left: 0px;
@@ -782,9 +801,11 @@ body.list-resizing-active * {
.list.ui-sortable-helper {
flex: 0 0 60px;
height: 60px;
- width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ border-left: 0px !important;
border-bottom: 1px solid #ccc;
+ display: block !important;
}
.list.ui-sortable-helper .list-header.ui-sortable-handle {
cursor: grabbing;
@@ -792,14 +813,17 @@ body.list-resizing-active * {
.list.placeholder {
flex: 0 0 60px;
height: 60px;
- width: 100%;
- border-left: 0px;
+ width: 100vw;
+ max-width: 100vw;
+ border-left: 0px !important;
border-bottom: 1px solid #ccc;
+ display: block !important;
}
.list-body {
padding: 15px 19px;
- width: 100%;
- min-width: 100%;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
}
.list-header {
/*Updated padding values for mobile devices, this should fix text grouping issue*/
@@ -808,8 +832,9 @@ body.list-resizing-active * {
min-height: 30px;
margin-top: 10px;
align-items: center;
- width: 100%;
- min-width: 100%;
+ width: 100vw;
+ max-width: 100vw;
+ min-width: 100vw;
}
.list-header .list-header-left-icon {
padding: 7px;
diff --git a/client/components/lists/list.js b/client/components/lists/list.js
index 6c3695ebf..7501886ae 100644
--- a/client/components/lists/list.js
+++ b/client/components/lists/list.js
@@ -150,17 +150,13 @@ BlazeComponent.extendComponent({
});
this.autorun(() => {
- if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
- $cards.sortable({
- handle: '.handle',
- });
- } else {
- $cards.sortable({
- handle: '.minicard',
- });
- }
-
if ($cards.data('uiSortable') || $cards.data('sortable')) {
+ if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
+ $cards.sortable('option', 'handle', '.handle');
+ } else {
+ $cards.sortable('option', 'handle', '.minicard');
+ }
+
$cards.sortable(
'option',
'disabled',
@@ -201,20 +197,60 @@ BlazeComponent.extendComponent({
listWidth() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
- if (!user || !list) return 270; // Return default width if user or list is not available
- return user.getListWidthFromStorage(list.boardId, list._id);
+ if (!list) return 270; // Return default width if list is not available
+
+ if (user) {
+ // For logged-in users, get from user profile
+ return user.getListWidthFromStorage(list.boardId, list._id);
+ } else {
+ // For non-logged-in users, get from localStorage
+ try {
+ const stored = localStorage.getItem('wekan-list-widths');
+ if (stored) {
+ const widths = JSON.parse(stored);
+ if (widths[list.boardId] && widths[list.boardId][list._id]) {
+ return widths[list.boardId][list._id];
+ }
+ }
+ } catch (e) {
+ console.warn('Error reading list width from localStorage:', e);
+ }
+ return 270; // Return default width if not found
+ }
},
listConstraint() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
- if (!user || !list) return 550; // Return default constraint if user or list is not available
- return user.getListConstraintFromStorage(list.boardId, list._id);
+ if (!list) return 550; // Return default constraint if list is not available
+
+ if (user) {
+ // For logged-in users, get from user profile
+ return user.getListConstraintFromStorage(list.boardId, list._id);
+ } else {
+ // For non-logged-in users, get from localStorage
+ try {
+ const stored = localStorage.getItem('wekan-list-constraints');
+ if (stored) {
+ const constraints = JSON.parse(stored);
+ if (constraints[list.boardId] && constraints[list.boardId][list._id]) {
+ return constraints[list.boardId][list._id];
+ }
+ }
+ } catch (e) {
+ console.warn('Error reading list constraint from localStorage:', e);
+ }
+ return 550; // Return default constraint if not found
+ }
},
autoWidth() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
+ if (!user) {
+ // For non-logged-in users, auto-width is disabled
+ return false;
+ }
return user.isAutoWidth(list.boardId);
},
@@ -333,14 +369,49 @@ BlazeComponent.extendComponent({
// Use the new storage method that handles both logged-in and non-logged-in users
if (process.env.DEBUG === 'true') {
}
- Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, listConstraint, (error, result) => {
- if (error) {
- console.error('Error saving list width:', error);
- } else {
+
+ const currentUser = ReactiveCache.getCurrentUser();
+ if (currentUser) {
+ // For logged-in users, use server method
+ Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, listConstraint, (error, result) => {
+ if (error) {
+ console.error('Error saving list width:', error);
+ } else {
+ if (process.env.DEBUG === 'true') {
+ }
+ }
+ });
+ } else {
+ // For non-logged-in users, save to localStorage directly
+ try {
+ // Save list width
+ const storedWidths = localStorage.getItem('wekan-list-widths');
+ let widths = storedWidths ? JSON.parse(storedWidths) : {};
+
+ if (!widths[boardId]) {
+ widths[boardId] = {};
+ }
+ widths[boardId][listId] = finalWidth;
+
+ localStorage.setItem('wekan-list-widths', JSON.stringify(widths));
+
+ // Save list constraint
+ const storedConstraints = localStorage.getItem('wekan-list-constraints');
+ let constraints = storedConstraints ? JSON.parse(storedConstraints) : {};
+
+ if (!constraints[boardId]) {
+ constraints[boardId] = {};
+ }
+ constraints[boardId][listId] = listConstraint;
+
+ localStorage.setItem('wekan-list-constraints', JSON.stringify(constraints));
+
if (process.env.DEBUG === 'true') {
}
+ } catch (e) {
+ console.warn('Error saving list width/constraint to localStorage:', e);
}
- });
+ }
e.preventDefault();
};
diff --git a/client/components/lists/listHeader.jade b/client/components/lists/listHeader.jade
index db5f86c2f..160be7b11 100644
--- a/client/components/lists/listHeader.jade
+++ b/client/components/lists/listHeader.jade
@@ -51,11 +51,12 @@ template(name="listHeader")
div.list-header-menu
unless currentUser.isCommentOnly
if canSeeAddCard
- a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") | ➕
- a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") | ☰
+ a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") ➕
+ a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") ☰
else
- a.list-header-menu-icon.js-select-list | ▶️
- a.list-header-handle.handle.js-list-handle | ↔️
+ a.list-header-menu-icon.js-select-list ▶️
+ unless currentUser.isWorker
+ a.list-header-handle.handle.js-list-handle ↕️
else if currentUser.isBoardMember
if isWatching
i.list-header-watch-icon | 👁️
@@ -65,14 +66,15 @@ template(name="listHeader")
//if isBoardAdmin
// a.fa.js-list-star.list-header-plus-top(class="fa-star{{#unless starred}}-o{{/unless}}")
if canSeeAddCard
- a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") | ➕
+ a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}") ➕
a.js-collapse(title="{{_ 'collapse'}}")
| ⬅️
| ➡️
- a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") | ☰
- if currentUser.isBoardAdmin
- if isTouchScreenOrShowDesktopDragHandles
- a.list-header-handle.handle.js-list-handle | ↔️
+ a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}") ☰
+ if currentUser.isBoardMember
+ unless currentUser.isCommentOnly
+ unless currentUser.isWorker
+ a.list-header-handle.handle.js-list-handle ↕️
template(name="editListTitleForm")
.list-composer
diff --git a/client/components/main/dueCards.jade b/client/components/main/dueCards.jade
index 09480be23..f482c9233 100644
--- a/client/components/main/dueCards.jade
+++ b/client/components/main/dueCards.jade
@@ -32,7 +32,16 @@ template(name="dueCards")
span.global-search-error-messages
= msg
else
- +resultsPaged(this)
+ .due-cards-results-header
+ h1
+ = resultsText
+ each card in dueCardsList
+ +resultCard(card)
+ else
+ .global-search-results-list-wrapper
+ .no-results
+ h3 {{_ 'dueCards-noResults-title'}}
+ p {{_ 'dueCards-noResults-description'}}
template(name="dueCardsViewChangePopup")
if currentUser
diff --git a/client/components/main/dueCards.js b/client/components/main/dueCards.js
index da113c07a..f17bc9a74 100644
--- a/client/components/main/dueCards.js
+++ b/client/components/main/dueCards.js
@@ -1,13 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
-import { CardSearchPagedComponent } from '../../lib/cardSearch';
-import {
- OPERATOR_HAS,
- OPERATOR_SORT,
- OPERATOR_USER,
- ORDER_ASCENDING,
- PREDICATE_DUE_AT,
-} from '../../../config/search-const';
-import { QueryParams } from '../../../config/query-classes';
+import { BlazeComponent } from 'meteor/peerlibrary:blaze-components';
+import { TAPi18n } from '/imports/i18n';
// const subManager = new SubsManager();
@@ -15,7 +8,7 @@ BlazeComponent.extendComponent({
dueCardsView() {
// eslint-disable-next-line no-console
// console.log('sort:', Utils.dueCardsView());
- return Utils.dueCardsView();
+ return Utils && Utils.dueCardsView ? Utils.dueCardsView() : 'me';
},
events() {
@@ -31,6 +24,47 @@ Template.dueCards.helpers({
userId() {
return Meteor.userId();
},
+ dueCardsList() {
+ const component = BlazeComponent.getComponentForElement(this.firstNode);
+ if (component && component.dueCardsList) {
+ return component.dueCardsList();
+ }
+ return [];
+ },
+ hasResults() {
+ const component = BlazeComponent.getComponentForElement(this.firstNode);
+ if (component && component.hasResults) {
+ return component.hasResults.get();
+ }
+ return false;
+ },
+ searching() {
+ const component = BlazeComponent.getComponentForElement(this.firstNode);
+ if (component && component.isLoading) {
+ return component.isLoading.get();
+ }
+ return true; // Show loading by default
+ },
+ hasQueryErrors() {
+ return false; // No longer using search, so always false
+ },
+ errorMessages() {
+ return []; // No longer using search, so always empty
+ },
+ cardsCount() {
+ const component = BlazeComponent.getComponentForElement(this.firstNode);
+ if (component && component.cardsCount) {
+ return component.cardsCount();
+ }
+ return 0;
+ },
+ resultsText() {
+ const component = BlazeComponent.getComponentForElement(this.firstNode);
+ if (component && component.resultsText) {
+ return component.resultsText();
+ }
+ return '';
+ },
});
BlazeComponent.extendComponent({
@@ -38,12 +72,16 @@ BlazeComponent.extendComponent({
return [
{
'click .js-due-cards-view-me'() {
- Utils.setDueCardsView('me');
+ if (Utils && Utils.setDueCardsView) {
+ Utils.setDueCardsView('me');
+ }
Popup.back();
},
'click .js-due-cards-view-all'() {
- Utils.setDueCardsView('all');
+ if (Utils && Utils.setDueCardsView) {
+ Utils.setDueCardsView('all');
+ }
Popup.back();
},
},
@@ -51,61 +89,162 @@ BlazeComponent.extendComponent({
},
}).register('dueCardsViewChangePopup');
-class DueCardsComponent extends CardSearchPagedComponent {
+class DueCardsComponent extends BlazeComponent {
onCreated() {
super.onCreated();
-
- const queryParams = new QueryParams();
- queryParams.addPredicate(OPERATOR_HAS, {
- field: PREDICATE_DUE_AT,
- exists: true,
- });
- // queryParams[OPERATOR_LIMIT] = 5;
- queryParams.addPredicate(OPERATOR_SORT, {
- name: PREDICATE_DUE_AT,
- order: ORDER_ASCENDING,
+
+ this._cachedCards = null;
+ this._cachedTimestamp = null;
+ this.subscriptionHandle = null;
+ this.isLoading = new ReactiveVar(true);
+ this.hasResults = new ReactiveVar(false);
+ this.searching = new ReactiveVar(false);
+
+ // Subscribe to the optimized due cards publication
+ this.autorun(() => {
+ const allUsers = this.dueCardsView() === 'all';
+ if (this.subscriptionHandle) {
+ this.subscriptionHandle.stop();
+ }
+ this.subscriptionHandle = Meteor.subscribe('dueCards', allUsers);
+
+ // Update loading state based on subscription
+ this.autorun(() => {
+ if (this.subscriptionHandle && this.subscriptionHandle.ready()) {
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards: subscription ready, loading data...');
+ }
+ this.isLoading.set(false);
+ const cards = this.dueCardsList();
+ this.hasResults.set(cards && cards.length > 0);
+ } else {
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards: subscription not ready, showing loading...');
+ }
+ this.isLoading.set(true);
+ this.hasResults.set(false);
+ }
+ });
});
+ }
- if (Utils.dueCardsView() !== 'all') {
- queryParams.addPredicate(OPERATOR_USER, ReactiveCache.getCurrentUser().username);
+ onDestroyed() {
+ super.onDestroyed();
+ if (this.subscriptionHandle) {
+ this.subscriptionHandle.stop();
}
-
- this.runGlobalSearch(queryParams);
}
dueCardsView() {
// eslint-disable-next-line no-console
//console.log('sort:', Utils.dueCardsView());
- return Utils.dueCardsView();
+ return Utils && Utils.dueCardsView ? Utils.dueCardsView() : 'me';
}
sortByBoard() {
return this.dueCardsView() === 'board';
}
+ hasResults() {
+ return this.hasResults.get();
+ }
+
+ cardsCount() {
+ const cards = this.dueCardsList();
+ return cards ? cards.length : 0;
+ }
+
+ resultsText() {
+ const count = this.cardsCount();
+ if (count === 1) {
+ return TAPi18n.__('one-card-found');
+ } else {
+ // Get the translated text and manually replace %s with the count
+ const baseText = TAPi18n.__('n-cards-found');
+ const result = baseText.replace('%s', count);
+
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards: base text:', baseText, 'count:', count, 'result:', result);
+ }
+ return result;
+ }
+ }
+
dueCardsList() {
- const results = this.getResults();
- console.log('results:', results);
- const cards = [];
- if (results) {
- results.forEach(card => {
- cards.push(card);
+ // Check if subscription is ready
+ if (!this.subscriptionHandle || !this.subscriptionHandle.ready()) {
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards client: subscription not ready');
+ }
+ return [];
+ }
+
+ // Use cached results if available to avoid expensive re-sorting
+ if (this._cachedCards && this._cachedTimestamp && (Date.now() - this._cachedTimestamp < 5000)) {
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards client: using cached results,', this._cachedCards.length, 'cards');
+ }
+ return this._cachedCards;
+ }
+
+ // Get cards directly from the subscription (already sorted by the publication)
+ const cards = ReactiveCache.getCards({
+ type: 'cardType-card',
+ archived: false,
+ dueAt: { $exists: true, $nin: [null, ''] }
+ });
+
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards client: found', cards.length, 'cards with due dates');
+ console.log('dueCards client: cards details:', cards.map(c => ({
+ id: c._id,
+ title: c.title,
+ dueAt: c.dueAt,
+ boardId: c.boardId,
+ members: c.members,
+ assignees: c.assignees,
+ userId: c.userId
+ })));
+ }
+
+ // Filter cards based on user view preference
+ const allUsers = this.dueCardsView() === 'all';
+ const currentUser = ReactiveCache.getCurrentUser();
+ let filteredCards = cards;
+
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards client: current user:', currentUser ? currentUser._id : 'none');
+ console.log('dueCards client: showing all users:', allUsers);
+ }
+
+ if (!allUsers && currentUser) {
+ filteredCards = cards.filter(card => {
+ const isMember = card.members && card.members.includes(currentUser._id);
+ const isAssignee = card.assignees && card.assignees.includes(currentUser._id);
+ const isAuthor = card.userId === currentUser._id;
+ const matches = isMember || isAssignee || isAuthor;
+
+ if (process.env.DEBUG === 'true' && matches) {
+ console.log('dueCards client: card matches user:', card.title, { isMember, isAssignee, isAuthor });
+ }
+
+ return matches;
});
}
- cards.sort((a, b) => {
- const x = a.dueAt === null ? new Date('2100-12-31') : a.dueAt;
- const y = b.dueAt === null ? new Date('2100-12-31') : b.dueAt;
+ if (process.env.DEBUG === 'true') {
+ console.log('dueCards client: filtered to', filteredCards.length, 'cards');
+ }
- if (x > y) return 1;
- else if (x < y) return -1;
+ // Cache the results for 5 seconds to avoid re-filtering on every render
+ this._cachedCards = filteredCards;
+ this._cachedTimestamp = Date.now();
- return 0;
- });
+ // Update reactive variables
+ this.hasResults.set(filteredCards && filteredCards.length > 0);
+ this.isLoading.set(false);
- // eslint-disable-next-line no-console
- console.log('cards:', cards);
- return cards;
+ return filteredCards;
}
}
diff --git a/client/components/main/header.css b/client/components/main/header.css
index 6bb1043f3..609941320 100644
--- a/client/components/main/header.css
+++ b/client/components/main/header.css
@@ -100,8 +100,9 @@
z-index: 1000;
padding: 10px 0px;
align-items: center;
- flex-wrap: wrap; /* Allow wrapping on mobile */
- min-height: 28px; /* Allow height to grow */
+ flex-wrap: nowrap; /* Prevent wrapping to keep single row */
+ min-height: 28px;
+ overflow: hidden; /* Prevent content from overflowing */
}
#header-quick-access .home-icon {
display: flex;
@@ -167,13 +168,39 @@
white-space: nowrap;
padding: 10px;
margin: -10px;
+ flex: 1; /* Take up available space */
+ min-width: 0; /* Allow shrinking below content size */
+ display: flex; /* Use flexbox for better control */
+ align-items: center;
+ scrollbar-width: thin; /* Firefox */
+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent; /* Firefox */
+}
+
+/* Webkit scrollbar styling for better UX */
+#header-quick-access ul.header-quick-access-list::-webkit-scrollbar {
+ height: 4px;
+}
+
+#header-quick-access ul.header-quick-access-list::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+#header-quick-access ul.header-quick-access-list::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+}
+
+#header-quick-access ul.header-quick-access-list::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.5);
}
#header-quick-access ul.header-quick-access-list li {
- display: inline;
+ display: inline-block; /* Keep inline-block for proper spacing */
width: auto;
color: #d9d9d9;
padding: 12px 0px;
margin: -10px 0px;
+ flex-shrink: 0; /* Prevent items from shrinking */
+ white-space: nowrap; /* Prevent text wrapping within items */
}
#header-quick-access ul.header-quick-access-list li a {
padding: 12px 10px;
@@ -220,6 +247,7 @@
margin: 0;
margin-top: 1px;
}
+
#header-quick-access #header-user-bar .header-user-bar-name,
#header-quick-access #header-help {
margin: 4px 8px 0 0;
@@ -314,7 +342,8 @@
}
/* Make zoom input wider on all mobile screens */
- @media screen and (max-width: 800px) {
+ @media screen and (max-width: 800px),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
#header-quick-access .zoom-controls .zoom-input {
min-width: 50px !important; /* Wider on mobile */
width: 50px !important; /* Fixed width to show all numbers */
@@ -424,7 +453,8 @@
margin: 6px 5px 0;
width: 12px;
}
-@media screen and (max-width: 800px) {
+@media screen and (max-width: 800px),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
#header #header-main-bar {
height: 40px;
}
@@ -446,6 +476,8 @@
transition: background-color 0.4s;
width: 100%;
z-index: 30;
+ flex-wrap: nowrap !important; /* Force single row on mobile */
+ overflow: hidden; /* Prevent content overflow */
}
/* Mobile home icon styling */
@@ -489,11 +521,12 @@
screen and (max-width: 800px) and (orientation: portrait),
screen and (max-width: 800px) and (orientation: landscape) {
#header-quick-access {
- height: auto !important; /* Allow height to grow */
+ height: 48px !important; /* Fixed height for mobile */
min-height: 48px !important; /* Minimum height for mobile */
- flex-wrap: wrap !important; /* Force wrapping */
- align-items: flex-start !important; /* Align to top when wrapping */
+ flex-wrap: nowrap !important; /* Force single row */
+ align-items: center !important; /* Center align items */
padding: 8px 0px !important; /* Adjust padding for mobile */
+ overflow: hidden !important; /* Prevent content overflow */
}
#header-quick-access {
font-size: 2em !important; /* 2x bigger base font size */
diff --git a/client/components/main/header.jade b/client/components/main/header.jade
index b7e870dc2..1ac11f189 100644
--- a/client/components/main/header.jade
+++ b/client/components/main/header.jade
@@ -83,10 +83,6 @@ template(name="header")
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}") 📱
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}") 🖥️
- // Bookmarks button - desktop opens popup, mobile routes to page
- a.board-header-btn.js-open-bookmarks(title="{{_ 'bookmarks'}}")
- | 🔖
-
// Notifications
+notifications
diff --git a/client/components/main/layouts.css b/client/components/main/layouts.css
index 8385d9cd9..fb8f4bf5c 100644
--- a/client/components/main/layouts.css
+++ b/client/components/main/layouts.css
@@ -470,8 +470,10 @@ a:not(.disabled).is-active i.fa {
MOBILE & TABLET RESPONSIVE IMPROVEMENTS
======================================== */
-/* Mobile devices (up to 800px) */
-@media screen and (max-width: 800px) {
+/* Mobile devices (up to 800px) and all iPhone models */
+@media screen and (max-width: 800px),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) and (orientation: landscape),
+ screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) and (orientation: portrait) {
#content {
margin: 1px 0px 0px 0px;
height: calc(100% - 0px);
diff --git a/client/components/main/layouts.js b/client/components/main/layouts.js
index eff1eba4a..d2d535207 100644
--- a/client/components/main/layouts.js
+++ b/client/components/main/layouts.js
@@ -92,6 +92,18 @@ Template.userFormsLayout.onRendered(() => {
if (loginInput && loginInput.name && (loginInput.name.toLowerCase().includes('user') || loginInput.name.toLowerCase().includes('email'))) {
loginInput.setAttribute('autocomplete', 'username email');
}
+
+ // Add autocomplete attributes to password fields for WCAG compliance
+ const passwordInputs = document.querySelectorAll('input[type="password"]');
+ passwordInputs.forEach(input => {
+ if (input.name && input.name.includes('password')) {
+ if (input.name.includes('password_again') || input.name.includes('new_password')) {
+ input.setAttribute('autocomplete', 'new-password');
+ } else {
+ input.setAttribute('autocomplete', 'current-password');
+ }
+ }
+ });
});
});
diff --git a/client/components/main/popup.css b/client/components/main/popup.css
index 889c1eb4c..dafbd2576 100644
--- a/client/components/main/popup.css
+++ b/client/components/main/popup.css
@@ -93,6 +93,29 @@
max-height: inherit;
}
+/* Admin edit popups: use full height */
+.pop-over[data-popup="editUser"],
+.pop-over[data-popup="editOrg"],
+.pop-over[data-popup="editTeam"] {
+ height: calc(100vh - 20px) !important;
+ max-height: calc(100vh - 20px) !important;
+}
+
+.pop-over[data-popup="editUser"] .content-wrapper,
+.pop-over[data-popup="editOrg"] .content-wrapper,
+.pop-over[data-popup="editTeam"] .content-wrapper {
+ max-height: calc(100vh - 80px) !important; /* Subtract header height */
+ height: calc(100vh - 80px) !important;
+ overflow-y: auto !important;
+}
+
+.pop-over[data-popup="editUser"] .content-container,
+.pop-over[data-popup="editOrg"] .content-container,
+.pop-over[data-popup="editTeam"] .content-container {
+ max-height: calc(100vh - 80px) !important; /* Subtract header height */
+ height: calc(100vh - 80px) !important;
+}
+
/* Ensure language popup list can scroll properly */
.pop-over .pop-over-list {
max-height: none;
@@ -270,6 +293,8 @@
overflow-y: auto !important;
}
+
+
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date button,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date button,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date button,
@@ -364,9 +389,6 @@
margin: 0;
visibility: hidden;
}
-.pop-over .quiet {
-/* padding: 6px 6px 4px;*/
-}
.pop-over.search-over {
background: #f0f0f0;
min-height: 14vh;
diff --git a/client/components/migrationProgress.css b/client/components/migrationProgress.css
index d44f4eda8..f3b9a45d4 100644
--- a/client/components/migrationProgress.css
+++ b/client/components/migrationProgress.css
@@ -1,38 +1,33 @@
/* Migration Progress Styles */
-.migration-overlay {
+.migration-progress-overlay {
position: fixed;
top: 0;
left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.8);
- z-index: 10000;
- display: none;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ z-index: 9999;
+ display: flex;
align-items: center;
justify-content: center;
- overflow-y: auto;
+ backdrop-filter: blur(2px);
}
-.migration-overlay.active {
- display: flex;
-}
-
-.migration-modal {
+.migration-progress-modal {
background: white;
- border-radius: 12px;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
- max-width: 800px;
- width: 95%;
- max-height: 90vh;
+ border-radius: 8px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
+ max-width: 500px;
+ width: 90%;
+ max-height: 80vh;
overflow: hidden;
- animation: slideInScale 0.4s ease-out;
- margin: 20px;
+ animation: migrationModalSlideIn 0.3s ease-out;
}
-@keyframes slideInScale {
+@keyframes migrationModalSlideIn {
from {
opacity: 0;
- transform: translateY(-30px) scale(0.95);
+ transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
@@ -40,333 +35,235 @@
}
}
-.migration-header {
- padding: 24px 32px 20px;
- border-bottom: 2px solid #e0e0e0;
- text-align: center;
+.migration-progress-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
+ padding: 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
-.migration-header h3 {
- margin: 0 0 8px 0;
- font-size: 24px;
+.migration-progress-title {
+ margin: 0;
+ font-size: 18px;
font-weight: 600;
}
-.migration-header h3 i {
- margin-right: 12px;
- color: #FFD700;
-}
-
-.migration-header p {
- margin: 0;
+.migration-progress-close {
+ cursor: pointer;
font-size: 16px;
- opacity: 0.9;
+ opacity: 0.8;
+ transition: opacity 0.2s ease;
}
-.migration-content {
- padding: 24px 32px;
- max-height: 60vh;
- overflow-y: auto;
+.migration-progress-close:hover {
+ opacity: 1;
}
-.migration-overview {
- margin-bottom: 32px;
- padding: 20px;
- background: #f8f9fa;
- border-radius: 8px;
- border-left: 4px solid #667eea;
+.migration-progress-content {
+ padding: 30px;
}
-.overall-progress {
- margin-bottom: 20px;
+.migration-progress-overall {
+ margin-bottom: 25px;
}
-.progress-bar {
- width: 100%;
- height: 12px;
- background-color: #e0e0e0;
- border-radius: 6px;
- overflow: hidden;
+.migration-progress-overall-label {
+ font-weight: 600;
+ color: #333;
margin-bottom: 8px;
- position: relative;
+ font-size: 14px;
}
-.progress-fill {
+.migration-progress-overall-bar {
+ background: #e9ecef;
+ border-radius: 10px;
+ height: 12px;
+ overflow: hidden;
+ margin-bottom: 5px;
+}
+
+.migration-progress-overall-fill {
+ background: linear-gradient(90deg, #28a745, #20c997);
height: 100%;
- background: linear-gradient(90deg, #667eea, #764ba2);
- border-radius: 6px;
+ border-radius: 10px;
transition: width 0.3s ease;
position: relative;
}
-.progress-fill::after {
+.migration-progress-overall-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
- background: linear-gradient(
- 90deg,
- transparent,
- rgba(255, 255, 255, 0.4),
- transparent
- );
- animation: shimmer 2s infinite;
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
+ animation: migrationProgressShimmer 2s infinite;
}
-@keyframes shimmer {
- 0% {
- transform: translateX(-100%);
- }
- 100% {
- transform: translateX(100%);
- }
+@keyframes migrationProgressShimmer {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
}
-.progress-text {
- text-align: center;
- font-weight: 700;
- color: #667eea;
- font-size: 18px;
-}
-
-.progress-label {
- text-align: center;
- color: #666;
- font-size: 14px;
- margin-top: 4px;
-}
-
-.current-step {
- text-align: center;
- color: #333;
- font-size: 16px;
- font-weight: 500;
- margin-bottom: 16px;
-}
-
-.current-step i {
- margin-right: 8px;
- color: #667eea;
-}
-
-.estimated-time {
- text-align: center;
- color: #666;
- font-size: 14px;
- background-color: #fff3cd;
- padding: 8px 12px;
- border-radius: 4px;
- border: 1px solid #ffeaa7;
-}
-
-.estimated-time i {
- margin-right: 6px;
- color: #f39c12;
-}
-
-.migration-steps {
- margin-bottom: 24px;
-}
-
-.migration-steps h4 {
- margin: 0 0 16px 0;
- color: #333;
- font-size: 18px;
- font-weight: 600;
-}
-
-.steps-list {
- max-height: 300px;
- overflow-y: auto;
- border: 1px solid #e0e0e0;
- border-radius: 8px;
-}
-
-.migration-step {
- padding: 16px 20px;
- border-bottom: 1px solid #f0f0f0;
- transition: all 0.3s ease;
-}
-
-.migration-step:last-child {
- border-bottom: none;
-}
-
-.migration-step.completed {
- background-color: #d4edda;
- border-left: 4px solid #28a745;
-}
-
-.migration-step.current {
- background-color: #cce7ff;
- border-left: 4px solid #667eea;
- animation: pulse 2s infinite;
-}
-
-@keyframes pulse {
- 0% {
- box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4);
- }
- 70% {
- box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
- }
- 100% {
- box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
- }
-}
-
-.step-header {
- display: flex;
- align-items: center;
- margin-bottom: 8px;
-}
-
-.step-icon {
- margin-right: 12px;
- font-size: 18px;
- width: 24px;
- text-align: center;
-}
-
-.step-icon i.fa-check-circle {
- color: #28a745;
-}
-
-.step-icon i.fa-cog.fa-spin {
- color: #667eea;
-}
-
-.step-icon i.fa-circle-o {
- color: #ccc;
-}
-
-.step-info {
- flex: 1;
-}
-
-.step-name {
- font-weight: 600;
- color: #333;
- font-size: 14px;
- margin-bottom: 2px;
-}
-
-.step-description {
- color: #666;
- font-size: 12px;
- line-height: 1.3;
-}
-
-.step-progress {
+.migration-progress-overall-percentage {
text-align: right;
- min-width: 40px;
-}
-
-.step-progress .progress-text {
font-size: 12px;
+ color: #666;
font-weight: 600;
}
-.step-progress-bar {
- width: 100%;
- height: 4px;
- background-color: #e0e0e0;
- border-radius: 2px;
- overflow: hidden;
- margin-top: 8px;
+.migration-progress-current-step {
+ margin-bottom: 25px;
}
-.step-progress-bar .progress-fill {
+.migration-progress-step-label {
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 8px;
+ font-size: 14px;
+}
+
+.migration-progress-step-bar {
+ background: #e9ecef;
+ border-radius: 8px;
+ height: 8px;
+ overflow: hidden;
+ margin-bottom: 5px;
+}
+
+.migration-progress-step-fill {
+ background: linear-gradient(90deg, #007bff, #0056b3);
height: 100%;
- background: linear-gradient(90deg, #667eea, #764ba2);
- border-radius: 2px;
+ border-radius: 8px;
transition: width 0.3s ease;
}
-.migration-status {
- text-align: center;
- color: #333;
- font-size: 16px;
- background-color: #e3f2fd;
- padding: 12px 16px;
+.migration-progress-step-percentage {
+ text-align: right;
+ font-size: 12px;
+ color: #666;
+ font-weight: 600;
+}
+
+.migration-progress-status {
+ margin-bottom: 20px;
+ padding: 15px;
+ background: #f8f9fa;
border-radius: 6px;
- border: 1px solid #bbdefb;
- margin-bottom: 16px;
+ border-left: 4px solid #007bff;
}
-.migration-status i {
- margin-right: 8px;
- color: #2196f3;
+.migration-progress-status-label {
+ font-weight: 600;
+ color: #333;
+ margin-bottom: 5px;
+ font-size: 13px;
}
-.migration-footer {
- padding: 16px 32px 24px;
- border-top: 1px solid #e0e0e0;
- background-color: #f8f9fa;
+.migration-progress-status-text {
+ color: #555;
+ font-size: 14px;
+ line-height: 1.4;
}
-.migration-info {
+.migration-progress-details {
+ margin-bottom: 20px;
+ padding: 12px;
+ background: #e3f2fd;
+ border-radius: 6px;
+ border-left: 4px solid #2196f3;
+}
+
+.migration-progress-details-label {
+ font-weight: 600;
+ color: #1976d2;
+ margin-bottom: 5px;
+ font-size: 13px;
+}
+
+.migration-progress-details-text {
+ color: #1565c0;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+.migration-progress-footer {
+ padding: 20px 30px;
+ background: #f8f9fa;
+ border-top: 1px solid #e9ecef;
+}
+
+.migration-progress-note {
text-align: center;
color: #666;
font-size: 13px;
- line-height: 1.4;
- margin-bottom: 8px;
-}
-
-.migration-info i {
- margin-right: 6px;
- color: #667eea;
-}
-
-.migration-warning {
- text-align: center;
- color: #856404;
- font-size: 12px;
- line-height: 1.3;
- background-color: #fff3cd;
- padding: 8px 12px;
- border-radius: 4px;
- border: 1px solid #ffeaa7;
-}
-
-.migration-warning i {
- margin-right: 6px;
- color: #f39c12;
+ font-style: italic;
}
/* Responsive design */
-@media (max-width: 768px) {
- .migration-modal {
- width: 98%;
- margin: 10px;
+@media (max-width: 600px) {
+ .migration-progress-modal {
+ width: 95%;
+ margin: 20px;
}
- .migration-header,
- .migration-content,
- .migration-footer {
- padding-left: 16px;
- padding-right: 16px;
+ .migration-progress-content {
+ padding: 20px;
}
- .migration-header h3 {
- font-size: 20px;
+ .migration-progress-header {
+ padding: 15px;
}
- .step-header {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .step-progress {
- text-align: left;
- margin-top: 8px;
- }
-
- .steps-list {
- max-height: 200px;
+ .migration-progress-title {
+ font-size: 16px;
}
}
+
+/* Dark mode support */
+@media (prefers-color-scheme: dark) {
+ .migration-progress-modal {
+ background: #2d3748;
+ color: #e2e8f0;
+ }
+
+ .migration-progress-overall-label,
+ .migration-progress-step-label,
+ .migration-progress-status-label {
+ color: #e2e8f0;
+ }
+
+ .migration-progress-status {
+ background: #4a5568;
+ border-left-color: #63b3ed;
+ }
+
+ .migration-progress-status-text {
+ color: #cbd5e0;
+ }
+
+ .migration-progress-details {
+ background: #2b6cb0;
+ border-left-color: #4299e1;
+ }
+
+ .migration-progress-details-label {
+ color: #bee3f8;
+ }
+
+ .migration-progress-details-text {
+ color: #90cdf4;
+ }
+
+ .migration-progress-footer {
+ background: #4a5568;
+ border-top-color: #718096;
+ }
+
+ .migration-progress-note {
+ color: #a0aec0;
+ }
+}
\ No newline at end of file
diff --git a/client/components/migrationProgress.jade b/client/components/migrationProgress.jade
index 274ea4621..250e20920 100644
--- a/client/components/migrationProgress.jade
+++ b/client/components/migrationProgress.jade
@@ -1,63 +1,43 @@
template(name="migrationProgress")
- .migration-overlay(class="{{#if isMigrating}}active{{/if}}")
- .migration-modal
- .migration-header
- h3
- | 🗄️
- | {{_ 'database-migration'}}
- p {{_ 'database-migration-description'}}
-
- .migration-content
- .migration-overview
- .overall-progress
- .progress-bar
- .progress-fill(style="width: {{migrationProgress}}%")
- .progress-text {{migrationProgress}}%
- .progress-label {{_ 'overall-progress'}}
-
- .current-step
- | ⚙️
- | {{migrationCurrentStep}}
-
- .estimated-time(style="{{#unless migrationEstimatedTime}}display: none;{{/unless}}")
- | ⏰
- | {{_ 'estimated-time-remaining'}}: {{migrationEstimatedTime}}
+ if isMigrating
+ .migration-progress-overlay
+ .migration-progress-modal
+ .migration-progress-header
+ h3.migration-progress-title
+ | 🔄 Board Migration in Progress
+ .migration-progress-close.js-close-migration-progress
+ | ❌
- .migration-steps
- h4 {{_ 'migration-steps'}}
- .steps-list
- each migrationSteps
- .migration-step(class="{{#if completed}}completed{{/if}}" class="{{#if isCurrentStep}}current{{/if}}")
- .step-header
- .step-icon
- if completed
- | ✅
- else if isCurrentStep
- | ⚙️
- else
- | ⭕
- .step-info
- .step-name {{name}}
- .step-description {{description}}
- .step-progress
- if completed
- .progress-text 100%
- else if isCurrentStep
- .progress-text {{progress}}%
- else
- .progress-text 0%
- if isCurrentStep
- .step-progress-bar
- .progress-fill(style="width: {{progress}}%")
+ .migration-progress-content
+ .migration-progress-overall
+ .migration-progress-overall-label
+ | Overall Progress: {{currentStep}} of {{totalSteps}} steps
+ .migration-progress-overall-bar
+ .migration-progress-overall-fill(style="{{progressBarStyle}}")
+ .migration-progress-overall-percentage
+ | {{overallProgress}}%
+
+ .migration-progress-current-step
+ .migration-progress-step-label
+ | Current Step: {{stepNameFormatted}}
+ .migration-progress-step-bar
+ .migration-progress-step-fill(style="{{stepProgressBarStyle}}")
+ .migration-progress-step-percentage
+ | {{stepProgress}}%
+
+ .migration-progress-status
+ .migration-progress-status-label
+ | Status:
+ .migration-progress-status-text
+ | {{stepStatus}}
+
+ if stepDetailsFormatted
+ .migration-progress-details
+ .migration-progress-details-label
+ | Details:
+ .migration-progress-details-text
+ | {{stepDetailsFormatted}}
- .migration-status
- | ℹ️
- | {{migrationStatus}}
-
- .migration-footer
- .migration-info
- | 💡
- | {{_ 'migration-info-text'}}
- .migration-warning
- | ⚠️
- | {{_ 'migration-warning-text'}}
+ .migration-progress-footer
+ .migration-progress-note
+ | Please wait while we migrate your board to the latest structure...
\ No newline at end of file
diff --git a/client/components/migrationProgress.js b/client/components/migrationProgress.js
index 83a05ea36..7c4064d39 100644
--- a/client/components/migrationProgress.js
+++ b/client/components/migrationProgress.js
@@ -1,54 +1,212 @@
-import { Template } from 'meteor/templating';
-import {
- migrationManager,
- isMigrating,
- migrationProgress,
- migrationStatus,
- migrationCurrentStep,
- migrationEstimatedTime,
- migrationSteps
-} from '/client/lib/migrationManager';
+/**
+ * Migration Progress Component
+ * Displays detailed progress for comprehensive board migration
+ */
+import { ReactiveVar } from 'meteor/reactive-var';
+import { ReactiveCache } from '/imports/reactiveCache';
+
+// Reactive variables for migration progress
+export const migrationProgress = new ReactiveVar(0);
+export const migrationStatus = new ReactiveVar('');
+export const migrationStepName = new ReactiveVar('');
+export const migrationStepProgress = new ReactiveVar(0);
+export const migrationStepStatus = new ReactiveVar('');
+export const migrationStepDetails = new ReactiveVar(null);
+export const migrationCurrentStep = new ReactiveVar(0);
+export const migrationTotalSteps = new ReactiveVar(0);
+export const isMigrating = new ReactiveVar(false);
+
+class MigrationProgressManager {
+ constructor() {
+ this.progressHistory = [];
+ }
+
+ /**
+ * Update migration progress
+ */
+ updateProgress(progressData) {
+ const {
+ overallProgress,
+ currentStep,
+ totalSteps,
+ stepName,
+ stepProgress,
+ stepStatus,
+ stepDetails,
+ boardId
+ } = progressData;
+
+ // Update reactive variables
+ migrationProgress.set(overallProgress);
+ migrationCurrentStep.set(currentStep);
+ migrationTotalSteps.set(totalSteps);
+ migrationStepName.set(stepName);
+ migrationStepProgress.set(stepProgress);
+ migrationStepStatus.set(stepStatus);
+ migrationStepDetails.set(stepDetails);
+
+ // Store in history
+ this.progressHistory.push({
+ timestamp: new Date(),
+ ...progressData
+ });
+
+ // Update overall status
+ migrationStatus.set(`${stepName}: ${stepStatus}`);
+ }
+
+ /**
+ * Start migration
+ */
+ startMigration() {
+ isMigrating.set(true);
+ migrationProgress.set(0);
+ migrationStatus.set('Starting migration...');
+ migrationStepName.set('');
+ migrationStepProgress.set(0);
+ migrationStepStatus.set('');
+ migrationStepDetails.set(null);
+ migrationCurrentStep.set(0);
+ migrationTotalSteps.set(0);
+ this.progressHistory = [];
+ }
+
+ /**
+ * Complete migration
+ */
+ completeMigration() {
+ isMigrating.set(false);
+ migrationProgress.set(100);
+ migrationStatus.set('Migration completed successfully!');
+
+ // Clear step details after a delay
+ setTimeout(() => {
+ migrationStepName.set('');
+ migrationStepProgress.set(0);
+ migrationStepStatus.set('');
+ migrationStepDetails.set(null);
+ migrationCurrentStep.set(0);
+ migrationTotalSteps.set(0);
+ }, 3000);
+ }
+
+ /**
+ * Fail migration
+ */
+ failMigration(error) {
+ isMigrating.set(false);
+ migrationStatus.set(`Migration failed: ${error.message || error}`);
+ migrationStepStatus.set('Error occurred');
+ }
+
+ /**
+ * Get progress history
+ */
+ getProgressHistory() {
+ return this.progressHistory;
+ }
+
+ /**
+ * Clear progress
+ */
+ clearProgress() {
+ isMigrating.set(false);
+ migrationProgress.set(0);
+ migrationStatus.set('');
+ migrationStepName.set('');
+ migrationStepProgress.set(0);
+ migrationStepStatus.set('');
+ migrationStepDetails.set(null);
+ migrationCurrentStep.set(0);
+ migrationTotalSteps.set(0);
+ this.progressHistory = [];
+ }
+}
+
+// Export singleton instance
+export const migrationProgressManager = new MigrationProgressManager();
+
+// Template helpers
Template.migrationProgress.helpers({
isMigrating() {
return isMigrating.get();
},
-
- migrationProgress() {
+
+ overallProgress() {
return migrationProgress.get();
},
-
- migrationStatus() {
+
+ overallStatus() {
return migrationStatus.get();
},
-
- migrationCurrentStep() {
+
+ currentStep() {
return migrationCurrentStep.get();
},
-
- migrationEstimatedTime() {
- return migrationEstimatedTime.get();
+
+ totalSteps() {
+ return migrationTotalSteps.get();
},
-
- migrationSteps() {
- const steps = migrationSteps.get();
- const currentStep = migrationCurrentStep.get();
+
+ stepName() {
+ return migrationStepName.get();
+ },
+
+ stepProgress() {
+ return migrationStepProgress.get();
+ },
+
+ stepStatus() {
+ return migrationStepStatus.get();
+ },
+
+ stepDetails() {
+ return migrationStepDetails.get();
+ },
+
+ progressBarStyle() {
+ const progress = migrationProgress.get();
+ return `width: ${progress}%`;
+ },
+
+ stepProgressBarStyle() {
+ const progress = migrationStepProgress.get();
+ return `width: ${progress}%`;
+ },
+
+ stepNameFormatted() {
+ const stepName = migrationStepName.get();
+ if (!stepName) return '';
- return steps.map(step => ({
- ...step,
- isCurrentStep: step.name === currentStep
- }));
+ // Convert snake_case to Title Case
+ return stepName
+ .split('_')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+ },
+
+ stepDetailsFormatted() {
+ const details = migrationStepDetails.get();
+ if (!details) return '';
+
+ const formatted = [];
+ for (const [key, value] of Object.entries(details)) {
+ const formattedKey = key
+ .split(/(?=[A-Z])/)
+ .join(' ')
+ .toLowerCase()
+ .replace(/^\w/, c => c.toUpperCase());
+ formatted.push(`${formattedKey}: ${value}`);
+ }
+
+ return formatted.join(', ');
}
});
-Template.migrationProgress.onCreated(function() {
- // Subscribe to migration state changes
- this.autorun(() => {
- isMigrating.get();
- migrationProgress.get();
- migrationStatus.get();
- migrationCurrentStep.get();
- migrationEstimatedTime.get();
- migrationSteps.get();
- });
-});
+// Template events
+Template.migrationProgress.events({
+ 'click .js-close-migration-progress'() {
+ migrationProgressManager.clearProgress();
+ }
+});
\ No newline at end of file
diff --git a/client/components/settings/peopleBody.css b/client/components/settings/peopleBody.css
index 5bbd70e96..bb529b2d2 100644
--- a/client/components/settings/peopleBody.css
+++ b/client/components/settings/peopleBody.css
@@ -71,6 +71,13 @@ table tr:nth-child(even) {
left: 0 !important;
display: block !important;
}
+
+/* Make checkbox column fit content */
+table th:first-child,
+table td:first-child {
+ width: auto;
+ min-width: auto;
+}
#divAddOrRemoveTeam {
background: #008000;
display: none;
@@ -141,7 +148,7 @@ table tr:nth-child(even) {
}
.account-active-status {
- width: 20px;
+ width: auto;
text-align: center;
}
@@ -170,7 +177,7 @@ table tr:nth-child(even) {
}
.account-status {
- width: 20px;
+ width: auto;
text-align: center;
}
diff --git a/client/components/settings/peopleBody.jade b/client/components/settings/peopleBody.jade
index bd5641067..0aa9a4dba 100644
--- a/client/components/settings/peopleBody.jade
+++ b/client/components/settings/peopleBody.jade
@@ -46,6 +46,7 @@ template(name="people")
option(value="locked") {{_ 'admin-people-filter-locked'}}
option(value="active") {{_ 'admin-people-filter-active'}}
option(value="inactive") {{_ 'admin-people-filter-inactive'}}
+ option(value="admin") Admin
button#unlockAllUsers.unlock-all-btn
| 🔓
| {{_ 'accounts-lockout-unlock-all'}}
@@ -57,7 +58,7 @@ template(name="people")
| {{_ 'add'}} / {{_ 'delete'}} {{_ 'teams'}}
else if lockedUsersSetting.get
span
- | 🔒.text-red
+ span.text-red 🔒
unless isMiniScreen
| {{_ 'accounts-lockout-locked-users'}}
@@ -78,7 +79,7 @@ template(name="people")
| {{_ 'people'}}
li
a.js-locked-users-menu(data-id="locked-users-setting")
- | 🔒.text-red
+ span.text-red 🔒
| {{_ 'accounts-lockout-locked-users'}}
.main-body
if loading.get
@@ -100,7 +101,6 @@ template(name="orgGeneral")
th {{_ 'displayName'}}
th {{_ 'description'}}
th {{_ 'shortName'}}
- th {{_ 'autoAddUsersWithDomainName'}}
th {{_ 'website'}}
th {{_ 'createdAt'}}
th {{_ 'active'}}
@@ -140,16 +140,9 @@ template(name="peopleGeneral")
th {{_ 'admin-people-active-status'}}
th {{_ 'username'}}
th {{_ 'fullname'}}
- th {{_ 'initials'}}
th {{_ 'admin'}}
th {{_ 'email'}}
- th {{_ 'verified'}}
th {{_ 'createdAt'}}
- th {{_ 'active'}}
- th {{_ 'authentication-method'}}
- th {{_ 'import-usernames'}}
- th {{_ 'organizations'}}
- th {{_ 'teams'}}
th
+newUserRow
tbody
@@ -190,10 +183,6 @@ template(name="orgRow")
td {{ orgData.orgShortName }}
else
td {{ orgData.orgShortName }}
- if orgData.orgIsActive
- td {{ orgData.orgAutoAddUsersWithDomainName }}
- else
- td {{ orgData.orgAutoAddUsersWithDomainName }}
if orgData.orgIsActive
td {{ orgData.orgWebsite }}
else
@@ -258,14 +247,14 @@ template(name="peopleRow")
input.selectUserChkBox(type="checkbox", id="{{userData._id}}")
td.account-status
if isUserLocked
- | 🔒.text-red.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="true", title="{{_ 'accounts-lockout-click-to-unlock'}}")
+ span.text-red.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="true", title="{{_ 'accounts-lockout-click-to-unlock'}}") 🔒
else
- | 🔓.text-green.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="false", title="{{_ 'accounts-lockout-user-unlocked'}}")
+ span.text-green.js-toggle-lock-status(data-user-id=userData._id, data-is-locked="false", title="{{_ 'accounts-lockout-user-unlocked'}}") 🔓
td.account-active-status
if userData.loginDisabled
- | 🚫.text-red.js-toggle-active-status(data-user-id=userData._id, data-is-active="false", title="{{_ 'admin-people-user-inactive'}}")
+ span.text-red.js-toggle-active-status(data-user-id=userData._id, data-is-active="false", title="{{_ 'admin-people-user-inactive'}}") 🚫
else
- | ✅.text-green.js-toggle-active-status(data-user-id=userData._id, data-is-active="true", title="{{_ 'admin-people-user-active'}}")
+ span.text-green.js-toggle-active-status(data-user-id=userData._id, data-is-active="true", title="{{_ 'admin-people-user-active'}}") ✅
if userData.loginDisabled
td.username {{ userData.username }}
else if isUserLocked
@@ -276,10 +265,6 @@ template(name="peopleRow")
td {{ userData.profile.fullname }}
else
td {{ userData.profile.fullname }}
- if userData.loginDisabled
- td {{ userData.profile.initials }}
- else
- td {{ userData.profile.initials }}
if userData.loginDisabled
td
if userData.isAdmin
@@ -296,43 +281,10 @@ template(name="peopleRow")
td {{ userData.emails.[0].address }}
else
td {{ userData.emails.[0].address }}
- if userData.loginDisabled
- td
- if userData.emails.[0].verified
- | {{_ 'yes'}}
- else
- | {{_ 'no'}}
- else
- td
- if userData.emails.[0].verified
- | {{_ 'yes'}}
- else
- | {{_ 'no'}}
if userData.loginDisabled
td {{ moment userData.createdAt 'LLL' }}
else
td {{ moment userData.createdAt 'LLL' }}
- td
- if userData.loginDisabled
- | {{_ 'no'}}
- else
- | {{_ 'yes'}}
- if userData.loginDisabled
- td {{_ userData.authenticationMethod }}
- else
- td {{_ userData.authenticationMethod }}
- if userData.loginDisabled
- td {{ userData.importUsernamesString }}
- else
- td {{ userData.importUsernamesString }}
- if userData.loginDisabled
- td {{ userData.orgsUserBelongs }}
- else
- td {{ userData.orgsUserBelongs }}
- if userData.loginDisabled
- td {{ userData.teamsUserBelongs }}
- else
- td {{ userData.teamsUserBelongs }}
td
a.edit-user
| ✏️
@@ -448,8 +400,8 @@ template(name="editUserPopup")
option(value="{{value}}") {{_ value}}
label
| {{_ 'organizations'}}
- | ➕#addUserOrg
- | ➖#removeUserOrg
+ span#addUserOrg ➕
+ span#removeUserOrg ➖
select.js-orgs#jsOrgs
option(value="-1") {{_ 'organizations'}} :
each value in orgsDatas
@@ -458,8 +410,8 @@ template(name="editUserPopup")
input#jsUserOrgIdsInPut.js-userOrgIds.hide(type="hidden" value=user.orgIdsUserBelongs)
label
| {{_ 'teams'}}
- | ➕#addUserTeam
- | ➖#removeUserTeam
+ span#addUserTeam ➕
+ span#removeUserTeam ➖
select.js-teams#jsTeams
option(value="-1") {{_ 'teams'}} :
each value in teamsDatas
@@ -591,8 +543,8 @@ template(name="newUserPopup")
option(value="{{value}}") {{_ value}}
label
| {{_ 'organizations'}}
- | ➕#addUserOrgNewUser
- | ➖#removeUserOrgNewUser
+ span#addUserOrgNewUser ➕
+ span#removeUserOrgNewUser ➖
select.js-orgsNewUser#jsOrgsNewUser
option(value="-1") {{_ 'organizations'}} :
each value in orgsDatas
@@ -601,8 +553,8 @@ template(name="newUserPopup")
input#jsUserOrgIdsInPutNewUser.js-userOrgIdsNewUser.hide(type="text" value=user.orgIdsUserBelongs)
label
| {{_ 'teams'}}
- | ➕#addUserTeamNewUser
- | ➖#removeUserTeamNewUser
+ span#addUserTeamNewUser ➕
+ span#removeUserTeamNewUser ➖
select.js-teamsNewUser#jsTeamsNewUser
option(value="-1") {{_ 'teams'}} :
each value in teamsDatas
diff --git a/client/components/settings/peopleBody.js b/client/components/settings/peopleBody.js
index 622155d61..9abaf7ff3 100644
--- a/client/components/settings/peopleBody.js
+++ b/client/components/settings/peopleBody.js
@@ -172,6 +172,10 @@ BlazeComponent.extendComponent({
// Show only inactive users (loginDisabled is true)
query['loginDisabled'] = true;
break;
+ case 'admin':
+ // Show only admin users (isAdmin is true)
+ query['isAdmin'] = true;
+ break;
case 'all':
default:
// Show all users, no additional filter
diff --git a/client/components/sidebar/sidebar.css b/client/components/sidebar/sidebar.css
index 831719f36..7867aec6d 100644
--- a/client/components/sidebar/sidebar.css
+++ b/client/components/sidebar/sidebar.css
@@ -48,6 +48,59 @@
display: flex;
flex-direction: column;
}
+
+/* Use checklist-style green checkboxes for all sidebar checkboxes */
+.sidebar .materialCheckBox.is-checked,
+.boardCardSettingsPopup .materialCheckBox.is-checked,
+.boardSubtaskSettingsPopup .materialCheckBox.is-checked {
+ top: -4px !important;
+ left: -3px !important;
+ width: 7px !important;
+ height: 15px !important;
+ margin-right: 6px !important;
+ border-top: 2px solid transparent !important;
+ border-left: 2px solid transparent !important;
+ border-bottom: 2px solid #3cb500 !important;
+ border-right: 2px solid #3cb500 !important;
+ transform: rotate(40deg) !important;
+ -webkit-backface-visibility: hidden !important;
+ backface-visibility: hidden !important;
+ transform-origin: 100% 100% !important;
+}
+
+/* Card Settings 3-column grid layout */
+.card-settings-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 2fr;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.card-settings-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr 2fr;
+ gap: 10px;
+ align-items: center;
+ padding: 5px 0;
+ border-bottom: 1px solid #eee;
+}
+
+.card-settings-column {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.card-settings-column:last-child {
+ justify-content: flex-start;
+}
+
+.card-settings-column h4 {
+ margin: 0;
+ font-size: 12px;
+ font-weight: bold;
+ text-align: center;
+}
.sidebar .sidebar-content ul.sidebar-list li > a {
display: flex;
height: 30px;
diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade
index 138d4b8bf..e5e90e1a5 100644
--- a/client/components/sidebar/sidebar.jade
+++ b/client/components/sidebar/sidebar.jade
@@ -28,38 +28,32 @@ template(name="sidebar")
+Template.dynamic(template=getViewTemplate)
template(name='homeSidebar')
- hr
+membersWidget
hr
+labelsWidget
hr
ul#cards.label-text-hidden
a.flex.js-toggle-minicard-label-text(title="{{_ 'hide-minicard-label-text'}}")
+ span {{#if hiddenMinicardLabelText}}✅{{else}}⬜{{/if}}
span {{_ 'hide-minicard-label-text'}}
- b
- .materialCheckBox(class="{{#if hiddenMinicardLabelText}}is-checked{{/if}}")
- ul#cards.vertical-scrollbars-toggle
- a.flex.js-vertical-scrollbars-toggle(title="{{_ 'enable-vertical-scrollbars'}}")
- span {{_ 'enable-vertical-scrollbars'}}
- b
- .materialCheckBox(class="{{#if isVerticalScrollbars}}is-checked{{/if}}")
+ if currentUser
+ ul#cards.vertical-scrollbars-toggle
+ a.flex.js-vertical-scrollbars-toggle(title="{{_ 'enable-vertical-scrollbars'}}")
+ span {{#if isVerticalScrollbars}}✅{{else}}⬜{{/if}}
+ span {{_ 'enable-vertical-scrollbars'}}
ul#cards.show-week-of-year-toggle
a.flex.js-show-week-of-year-toggle(title="{{_ 'show-week-of-year'}}")
+ span {{#if isShowWeekOfYear}}✅{{else}}⬜{{/if}}
span {{_ 'show-week-of-year'}}
- b
- .materialCheckBox(class="{{#if isShowWeekOfYear}}is-checked{{/if}}")
hr
unless currentUser.isNoComments
h3.activity-title
| 💬
| {{_ 'activities'}}
- .material-toggle-switch(title="{{_ 'show-activities'}}")
- if showActivities
- input.toggle-switch(type="checkbox" id="toggleShowActivitiesBoard" checked="checked")
- else
- input.toggle-switch(type="checkbox" id="toggleShowActivitiesBoard")
- label.toggle-label(for="toggleShowActivitiesBoard")
+ a.flex.js-toggle-show-activities(title="{{_ 'show-activities'}}")
+ span {{#if showActivities}}✅{{else}}⬜{{/if}}
+ span {{_ 'show-activities'}}
+activities(mode="board")
template(name="membersWidget")
@@ -185,165 +179,282 @@ template(name="boardInfoOnMyBoardsPopup")
unless currentSetting.hideCardCounterList
div.check-div
a.flex.js-field-has-cardcounterlist(class="{{#if allowsCardCounterList}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsCardCounterList}}is-checked{{/if}}")
+ span {{#if allowsCardCounterList}}✅{{else}}⬜{{/if}}
span
| 🚪
| {{_ 'show-card-counter-per-list'}}
unless currentSetting.hideBoardMemberList
div.check-div
a.flex.js-field-has-boardmemberlist(class="{{#if allowsBoardMemberList}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsBoardMemberList}}is-checked{{/if}}")
+ span {{#if allowsBoardMemberList}}✅{{else}}⬜{{/if}}
span
| ⏳
| {{_ 'show-board_members-avatar'}}
template(name="boardCardSettingsPopup")
form.board-card-settings
- h3 {{_ 'show-on-card'}}, {{_ 'show-on-minicard'}}
- div.check-div
- a.flex.js-field-has-receiveddate(class="{{#if allowsReceivedDate}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsReceivedDate}}is-checked{{/if}}")
+ .card-settings-grid
+ .card-settings-column
+ h4 {{_ 'show-on-card'}}
+ .card-settings-column
+ h4 {{_ 'show-on-minicard'}}
+ .card-settings-column
+ h4 {{_ 'description'}}
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-receiveddate(title="{{_ 'card-received'}}" class="{{#if allowsReceivedDate}}is-checked{{/if}}")
+ span {{#if allowsReceivedDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-receiveddate(title="{{_ 'card-received'}}" class="{{#if allowsReceivedDate}}is-checked{{/if}}")
+ span {{#if allowsReceivedDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 🚪
| {{_ 'card-received'}}
- div.check-div
- a.flex.js-field-has-startdate(class="{{#if allowsStartDate}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsStartDate}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-startdate(title="{{_ 'card-start'}}" class="{{#if allowsStartDate}}is-checked{{/if}}")
+ span {{#if allowsStartDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-startdate(title="{{_ 'card-start'}}" class="{{#if allowsStartDate}}is-checked{{/if}}")
+ span {{#if allowsStartDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| ⏳
| {{_ 'card-start'}}
- div.check-div
- a.flex.js-field-has-duedate(class="{{#if allowsDueDate}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsDueDate}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-duedate(title="{{_ 'card-due'}}" class="{{#if allowsDueDate}}is-checked{{/if}}")
+ span {{#if allowsDueDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-duedate(title="{{_ 'card-due'}}" class="{{#if allowsDueDate}}is-checked{{/if}}")
+ span {{#if allowsDueDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 🚪
| {{_ 'card-due'}}
- div.check-div
- a.flex.js-field-has-enddate(class="{{#if allowsEndDate}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsEndDate}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-enddate(title="{{_ 'card-end'}}" class="{{#if allowsEndDate}}is-checked{{/if}}")
+ span {{#if allowsEndDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-enddate(title="{{_ 'card-end'}}" class="{{#if allowsEndDate}}is-checked{{/if}}")
+ span {{#if allowsEndDate}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| ⏰
| {{_ 'card-end'}}
- div.check-div
- a.flex.js-field-has-members(class="{{#if allowsMembers}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsMembers}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-members(title="{{_ 'members'}}" class="{{#if allowsMembers}}is-checked{{/if}}")
+ span {{#if allowsMembers}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-members(title="{{_ 'members'}}" class="{{#if allowsMembers}}is-checked{{/if}}")
+ span {{#if allowsMembers}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 👥
| {{_ 'members'}}
- div.check-div
- a.flex.js-field-has-creator(class="{{#if allowsCreator}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsCreator}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-creator(title="{{_ 'creator'}}" class="{{#if allowsCreator}}is-checked{{/if}}")
+ span {{#if allowsCreator}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ span
+ .card-settings-column
span
| 👤
| {{_ 'creator'}}
- div.check-div
- a.flex.js-field-has-creator-on-minicard(class="{{#if allowsCreatorOnMinicard}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsCreatorOnMinicard}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ span
+ .card-settings-column
+ a.flex.js-field-has-creator-on-minicard(title="{{_ 'creator-on-minicard'}}" class="{{#if allowsCreatorOnMinicard}}is-checked{{/if}}")
+ span {{#if allowsCreatorOnMinicard}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 👤
| {{_ 'creator-on-minicard'}}
- div.check-div
- a.flex.js-field-has-assignee(class="{{#if allowsAssignee}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsAssignee}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-assignee(title="{{_ 'assignee'}}" class="{{#if allowsAssignee}}is-checked{{/if}}")
+ span {{#if allowsAssignee}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-assignee(title="{{_ 'assignee'}}" class="{{#if allowsAssignee}}is-checked{{/if}}")
+ span {{#if allowsAssignee}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 👤
| {{_ 'assignee'}}
- div.check-div
- a.flex.js-field-has-assigned-by(class="{{#if allowsAssignedBy}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsAssignedBy}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-assigned-by(title="{{_ 'assigned-by'}}" class="{{#if allowsAssignedBy}}is-checked{{/if}}")
+ span {{#if allowsAssignedBy}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-assigned-by(title="{{_ 'assigned-by'}}" class="{{#if allowsAssignedBy}}is-checked{{/if}}")
+ span {{#if allowsAssignedBy}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 🛒
| {{_ 'assigned-by'}}
- div.check-div
- a.flex.js-field-has-requested-by(class="{{#if allowsRequestedBy}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsRequestedBy}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-requested-by(title="{{_ 'requested-by'}}" class="{{#if allowsRequestedBy}}is-checked{{/if}}")
+ span {{#if allowsRequestedBy}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-requested-by(title="{{_ 'requested-by'}}" class="{{#if allowsRequestedBy}}is-checked{{/if}}")
+ span {{#if allowsRequestedBy}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 👤➕
| {{_ 'requested-by'}}
- div.check-div
- a.flex.js-field-has-card-sorting-by-number(class="{{#if allowsCardSortingByNumber}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsCardSortingByNumber}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-card-sorting-by-number(title="{{_ 'card-sorting-by-number'}}" class="{{#if allowsCardSortingByNumber}}is-checked{{/if}}")
+ span {{#if allowsCardSortingByNumber}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ span
+ .card-settings-column
span
| 🔢
| {{_ 'card-sorting-by-number'}}
- div.check-div
- a.flex.js-field-has-card-sorting-by-number-on-minicard(class="{{#if allowsCardSortingByNumberOnMinicard}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsCardSortingByNumberOnMinicard}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ span
+ .card-settings-column
+ a.flex.js-field-has-card-sorting-by-number-on-minicard(title="{{_ 'card-sorting-by-number-on-minicard'}}" class="{{#if allowsCardSortingByNumberOnMinicard}}is-checked{{/if}}")
+ span {{#if allowsCardSortingByNumberOnMinicard}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 🔢
| {{_ 'card-sorting-by-number-on-minicard'}}
- div.check-div
- a.flex.js-field-has-card-show-lists(class="{{#if allowsShowLists}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsShowLists}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-card-show-lists(title="{{_ 'card-show-lists'}}" class="{{#if allowsShowLists}}is-checked{{/if}}")
+ span {{#if allowsShowLists}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ span
+ .card-settings-column
span
| 📋
| {{_ 'card-show-lists'}}
- div.check-div
- a.flex.js-field-has-labels(class="{{#if allowsLabels}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsLabels}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-labels(title="{{_ 'labels'}}" class="{{#if allowsLabels}}is-checked{{/if}}")
+ span {{#if allowsLabels}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-labels(title="{{_ 'labels'}}" class="{{#if allowsLabels}}is-checked{{/if}}")
+ span {{#if allowsLabels}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 🏷️
| {{_ 'labels'}}
- div.check-div
- a.flex.js-field-has-card-show-lists-on-minicard(class="{{#if allowsShowListsOnMinicard}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsShowListsOnMinicard}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ span
+ .card-settings-column
+ a.flex.js-field-has-card-show-lists-on-minicard(title="{{_ 'card-show-lists-on-minicard'}}" class="{{#if allowsShowListsOnMinicard}}is-checked{{/if}}")
+ span {{#if allowsShowListsOnMinicard}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 📋
| {{_ 'card-show-lists-on-minicard'}}
- div.check-div
- a.flex.js-field-has-card-number(class="{{#if allowsCardNumber}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsCardNumber}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-card-number(title="{{_ 'card'}} {{_ 'number'}}" class="{{#if allowsCardNumber}}is-checked{{/if}}")
+ span {{#if allowsCardNumber}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-card-number(title="{{_ 'card'}} {{_ 'number'}}" class="{{#if allowsCardNumber}}is-checked{{/if}}")
+ span {{#if allowsCardNumber}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| #️⃣
| {{_ 'card'}}
| {{_ 'number'}}
- div.check-div
- a.flex.js-field-has-description-title(class="{{#if allowsDescriptionTitle}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsDescriptionTitle}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-description-title(title="{{_ 'description'}} {{_ 'title'}}" class="{{#if allowsDescriptionTitle}}is-checked{{/if}}")
+ span {{#if allowsDescriptionTitle}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-description-title(title="{{_ 'description'}} {{_ 'title'}}" class="{{#if allowsDescriptionTitle}}is-checked{{/if}}")
+ span {{#if allowsDescriptionTitle}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 📝
| {{_ 'description'}}
| {{_ 'title'}}
- div.check-div
- a.flex.js-field-has-description-text(class="{{#if allowsDescriptionText}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsDescriptionText}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-description-text(title="{{_ 'description'}} {{_ 'custom-field-text'}}" class="{{#if allowsDescriptionText}}is-checked{{/if}}")
+ span {{#if allowsDescriptionText}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-description-text(title="{{_ 'description'}} {{_ 'custom-field-text'}}" class="{{#if allowsDescriptionText}}is-checked{{/if}}")
+ span {{#if allowsDescriptionText}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 📝
| {{_ 'description'}}
| {{_ 'custom-field-text'}}
- div.check-div
- a.flex.js-field-has-description-text-on-minicard(class="{{#if allowsDescriptionTextOnMinicard}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsDescriptionTextOnMinicard}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ span
+ .card-settings-column
+ a.flex.js-field-has-description-text-on-minicard(title="{{_ 'description-on-minicard'}}" class="{{#if allowsDescriptionTextOnMinicard}}is-checked{{/if}}")
+ span {{#if allowsDescriptionTextOnMinicard}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 📝
| {{_ 'description-on-minicard'}}
- div.check-div
- a.flex.js-field-has-checklists(class="{{#if allowsChecklists}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsChecklists}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-checklists(title="{{_ 'checklists'}}" class="{{#if allowsChecklists}}is-checked{{/if}}")
+ span {{#if allowsChecklists}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-checklists(title="{{_ 'checklists'}}" class="{{#if allowsChecklists}}is-checked{{/if}}")
+ span {{#if allowsChecklists}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| ✅
| {{_ 'checklists'}}
- div.check-div
- a.flex.js-field-has-subtasks(class="{{#if allowsSubtasks}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsSubtasks}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-subtasks(title="{{_ 'subtasks'}}" class="{{#if allowsSubtasks}}is-checked{{/if}}")
+ span {{#if allowsSubtasks}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-subtasks(title="{{_ 'subtasks'}}" class="{{#if allowsSubtasks}}is-checked{{/if}}")
+ span {{#if allowsSubtasks}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 🌐
| {{_ 'subtasks'}}
- div.check-div
- a.flex.js-field-has-attachments(class="{{#if allowsAttachments}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsAttachments}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ a.flex.js-field-has-attachments(title="{{_ 'attachments'}}" class="{{#if allowsAttachments}}is-checked{{/if}}")
+ span {{#if allowsAttachments}}✅{{else}}⬜{{/if}}
+ .card-settings-column
+ a.flex.js-field-has-attachments(title="{{_ 'attachments'}}" class="{{#if allowsAttachments}}is-checked{{/if}}")
+ span {{#if allowsAttachments}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 📎
| {{_ 'attachments'}}
- div.check-div
- a.flex.js-field-has-badge-attachment-on-minicard(class="{{#if allowsBadgeAttachmentOnMinicard}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsBadgeAttachmentOnMinicard}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ span
+ .card-settings-column
+ a.flex.js-field-has-badge-attachment-on-minicard(title="{{_ 'badge-attachment-on-minicard'}}" class="{{#if allowsBadgeAttachmentOnMinicard}}is-checked{{/if}}")
+ span {{#if allowsBadgeAttachmentOnMinicard}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 📎
| {{_ 'badge-attachment-on-minicard'}}
- div.check-div
- a.flex.js-field-has-cover-attachment-on-minicard(class="{{#if allowsCoverAttachmentOnMinicard}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsCoverAttachmentOnMinicard}}is-checked{{/if}}")
+ .card-settings-row
+ .card-settings-column
+ span
+ .card-settings-column
+ a.flex.js-field-has-cover-attachment-on-minicard(title="{{_ 'cover-attachment-on-minicard'}}" class="{{#if allowsCoverAttachmentOnMinicard}}is-checked{{/if}}")
+ span {{#if allowsCoverAttachmentOnMinicard}}✅{{else}}⬜{{/if}}
+ .card-settings-column
span
| 📖
| 🖼️
@@ -364,27 +475,27 @@ template(name="boardCardSettingsPopup")
template(name="boardSubtaskSettingsPopup")
form.board-subtask-settings
h3 {{_ 'show-parent-in-minicard'}}
- a#prefix-with-full-path.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'prefix-with-full-path'}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if $eq presentParentTask 'prefix-with-full-path'}}is-checked{{/if}}")
+ a#prefix-with-full-path.flex.js-field-show-parent-in-minicard(title="{{_ 'prefix-with-full-path'}}" class="{{#if $eq presentParentTask 'prefix-with-full-path'}}is-checked{{/if}}")
+ span {{#if $eq presentParentTask 'prefix-with-full-path'}}✅{{else}}⬜{{/if}}
span {{_ 'prefix-with-full-path'}}
- a#prefix-with-parent.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'prefix-with-parent'}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if $eq presentParentTask 'prefix-with-parent'}}is-checked{{/if}}")
+ a#prefix-with-parent.flex.js-field-show-parent-in-minicard(title="{{_ 'prefix-with-parent'}}" class="{{#if $eq presentParentTask 'prefix-with-parent'}}is-checked{{/if}}")
+ span {{#if $eq presentParentTask 'prefix-with-parent'}}✅{{else}}⬜{{/if}}
span {{_ 'prefix-with-parent'}}
- a#subtext-with-full-path.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'subtext-with-full-path'}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if $eq presentParentTask 'subtext-with-full-path'}}is-checked{{/if}}")
+ a#subtext-with-full-path.flex.js-field-show-parent-in-minicard(title="{{_ 'subtext-with-full-path'}}" class="{{#if $eq presentParentTask 'subtext-with-full-path'}}is-checked{{/if}}")
+ span {{#if $eq presentParentTask 'subtext-with-full-path'}}✅{{else}}⬜{{/if}}
span {{_ 'subtext-with-full-path'}}
- a#subtext-with-parent.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'subtext-with-parent'}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if $eq presentParentTask 'subtext-with-parent'}}is-checked{{/if}}")
+ a#subtext-with-parent.flex.js-field-show-parent-in-minicard(title="{{_ 'subtext-with-parent'}}" class="{{#if $eq presentParentTask 'subtext-with-parent'}}is-checked{{/if}}")
+ span {{#if $eq presentParentTask 'subtext-with-parent'}}✅{{else}}⬜{{/if}}
span {{_ 'subtext-with-parent'}}
- a#no-parent.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'no-parent'}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if $eq presentParentTask 'no-parent'}}is-checked{{/if}}")
+ a#no-parent.flex.js-field-show-parent-in-minicard(title="{{_ 'no-parent'}}" class="{{#if $eq presentParentTask 'no-parent'}}is-checked{{/if}}")
+ span {{#if $eq presentParentTask 'no-parent'}}✅{{else}}⬜{{/if}}
span {{_ 'no-parent'}}
div
hr
div.check-div
- a.flex.js-field-has-subtasks(class="{{#if allowsSubtasks}}is-checked{{/if}}")
- .materialCheckBox(class="{{#if allowsSubtasks}}is-checked{{/if}}")
+ a.flex.js-field-has-subtasks(title="{{_ 'show-subtasks-field'}}" class="{{#if allowsSubtasks}}is-checked{{/if}}")
+ span {{#if allowsSubtasks}}✅{{else}}⬜{{/if}}
span {{_ 'show-subtasks-field'}}
label
@@ -426,13 +537,18 @@ template(name="archiveBoardPopup")
| 📦
| {{_ 'archive'}}
+template(name="deleteDuplicateListsPopup")
+ p {{_ 'delete-duplicate-lists-confirm'}}
+ button.js-confirm.negate.full(type="submit")
+ | 🗑️
+ | {{_ 'delete'}}
+
template(name="outgoingWebhooksPopup")
each integrations
form.integration-form
a.flex
+ span {{#unless enabled}}✅{{else}}⬜{{/unless}}
span {{_ 'disable-webhook'}}
- b
- .materialCheckBox(class="{{#unless enabled}}is-checked{{/unless}}")
input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title" value=title)
input.js-outgoing-webhooks-url(type="text" name="url" value=url)
input.js-outgoing-webhooks-token(placeholder="{{_ 'webhook-token' }}" type="text" value=token name="token")
@@ -471,6 +587,10 @@ template(name="boardMenuPopup")
| 📦
| {{_ 'archived-items'}}
if currentUser.isBoardAdmin
+ li
+ a.js-open-migrations
+ | 🔧
+ | {{_ 'migrations'}}
li
a.js-change-board-color
| 🎨
@@ -511,6 +631,10 @@ template(name="boardMenuPopup")
if currentUser.isBoardAdmin
hr
ul.pop-over-list
+ // li
+ // a.js-delete-duplicate-lists
+ // | 🗑️
+ // | {{_ 'delete-duplicate-lists'}}
li
a.js-archive-board
| ➡️📦
@@ -631,7 +755,7 @@ template(name="removeBoardTeamPopup")
template(name="addMemberPopup")
.js-search-member
- +EasySearch.Input(index=searchIndex)
+ input.js-search-member-input(type="text" placeholder="{{_ 'email-address'}}")
if loading.get
+spinner
@@ -639,25 +763,38 @@ template(name="addMemberPopup")
.warning {{_ error.get}}
else
ul.pop-over-list
- +EasySearch.Each(index=searchIndex)
+ each searchResults
li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}")
a.name.js-select-member(title="{{profile.fullname}} ({{username}})")
- +userAvatar(userId=__originalId)
+ +userAvatar(userId=_id)
span.full-name
= profile.fullname
| ({{username}})
if isBoardMember
.quiet ({{_ 'joined'}})
- +EasySearch.IfSearching(index=searchIndex)
+ if searching.get
+spinner
- +EasySearch.IfNoResults(index=searchIndex)
+ if noResults.get
.manage-member-section
p.quiet {{_ 'no-results'}}
button.js-email-invite.primary.full {{_ 'email-invite'}}
+template(name="addMemberPopupTest")
+ .js-search-member
+ input.js-search-member-input(type="text" placeholder="{{_ 'email-address'}}")
+ ul.pop-over-list
+ each searchResults
+ li.item.js-member-item
+ a.name.js-select-member(title="{{profile.fullname}} ({{username}})")
+ +userAvatar(userId=_id)
+ span.full-name
+ = profile.fullname
+ | ({{username}})
+ button.js-email-invite.primary.full {{_ 'email-invite'}}
+
template(name="changePermissionsPopup")
ul.pop-over-list
li
diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js
index a6de901d3..5831e601a 100644
--- a/client/components/sidebar/sidebar.js
+++ b/client/components/sidebar/sidebar.js
@@ -13,6 +13,7 @@ const viewTitles = {
multiselection: 'multi-selection',
customFields: 'custom-fields',
archives: 'archives',
+ migrations: 'migrations',
};
BlazeComponent.extendComponent({
@@ -195,7 +196,7 @@ BlazeComponent.extendComponent({
events() {
return [
{
- 'click #toggleShowActivitiesBoard'() {
+ 'click .js-toggle-show-activities'() {
Utils.getCurrentBoard().toggleShowActivities();
},
},
@@ -203,6 +204,8 @@ BlazeComponent.extendComponent({
},
}).register('homeSidebar');
+
+
Template.boardInfoOnMyBoardsPopup.helpers({
hideCardCounterList() {
return Utils.isMiniScreen() && Session.get('currentBoard');
@@ -269,10 +272,53 @@ Template.boardMenuPopup.events({
Sidebar.setView('archives');
Popup.back();
},
+ 'click .js-open-migrations'() {
+ Sidebar.setView('migrations');
+ Popup.back();
+ },
'click .js-change-board-color': Popup.open('boardChangeColor'),
'click .js-change-background-image': Popup.open('boardChangeBackgroundImage'),
'click .js-board-info-on-my-boards': Popup.open('boardInfoOnMyBoards'),
'click .js-change-language': Popup.open('changeLanguage'),
+ 'click .js-delete-duplicate-lists': Popup.afterConfirm('deleteDuplicateLists', function() {
+ const currentBoard = Utils.getCurrentBoard();
+ if (!currentBoard) return;
+
+ // Get all lists in the current board
+ const allLists = ReactiveCache.getLists({ boardId: currentBoard._id, archived: false });
+
+ // Group lists by title to find duplicates
+ const listsByTitle = {};
+ allLists.forEach(list => {
+ if (!listsByTitle[list.title]) {
+ listsByTitle[list.title] = [];
+ }
+ listsByTitle[list.title].push(list);
+ });
+
+ // Find and delete duplicate lists that have no cards
+ let deletedCount = 0;
+ Object.keys(listsByTitle).forEach(title => {
+ const listsWithSameTitle = listsByTitle[title];
+ if (listsWithSameTitle.length > 1) {
+ // Keep the first list, delete the rest if they have no cards
+ for (let i = 1; i < listsWithSameTitle.length; i++) {
+ const list = listsWithSameTitle[i];
+ const cardsInList = ReactiveCache.getCards({ listId: list._id, archived: false });
+
+ if (cardsInList.length === 0) {
+ Lists.remove(list._id);
+ deletedCount++;
+ }
+ }
+ }
+ });
+
+ // Show notification
+ if (deletedCount > 0) {
+ // You could add a toast notification here if available
+ }
+ }),
'click .js-archive-board ': Popup.afterConfirm('archiveBoard', function() {
const currentBoard = Utils.getCurrentBoard();
currentBoard.archive();
@@ -820,7 +866,11 @@ BlazeComponent.extendComponent({
},
allowsSubtasks() {
- return this.currentBoard.allowsSubtasks;
+ // Get the current board reactively using board ID from Session
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ const result = currentBoard ? currentBoard.allowsSubtasks : false;
+ return result;
},
allowsReceivedDate() {
@@ -872,7 +922,11 @@ BlazeComponent.extendComponent({
},
presentParentTask() {
- let result = this.currentBoard.presentParentTask;
+ // Get the current board reactively using board ID from Session
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+
+ let result = currentBoard ? currentBoard.presentParentTask : null;
if (result === null || result === undefined) {
result = 'no-parent';
}
@@ -884,19 +938,11 @@ BlazeComponent.extendComponent({
{
'click .js-field-has-subtasks'(evt) {
evt.preventDefault();
- this.currentBoard.allowsSubtasks = !this.currentBoard.allowsSubtasks;
- this.currentBoard.setAllowsSubtasks(this.currentBoard.allowsSubtasks);
- $(`.js-field-has-subtasks ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsSubtasks,
- );
- $('.js-field-has-subtasks').toggleClass(
- CKCLS,
- this.currentBoard.allowsSubtasks,
- );
+ const newValue = !this.currentBoard.allowsSubtasks;
+ Boards.update(this.currentBoard._id, { $set: { allowsSubtasks: newValue } });
$('.js-field-deposit-board').prop(
'disabled',
- !this.currentBoard.allowsSubtasks,
+ !newValue,
);
},
'change .js-field-deposit-board'(evt) {
@@ -912,28 +958,13 @@ BlazeComponent.extendComponent({
evt.preventDefault();
},
'click .js-field-show-parent-in-minicard'(evt) {
- const value =
- evt.target.id ||
- $(evt.target).parent()[0].id ||
- $(evt.target)
- .parent()[0]
- .parent()[0].id;
- const options = [
- 'prefix-with-full-path',
- 'prefix-with-parent',
- 'subtext-with-full-path',
- 'subtext-with-parent',
- 'no-parent',
- ];
- options.forEach(function(element) {
- if (element !== value) {
- $(`#${element} ${MCB}`).toggleClass(CKCLS, false);
- $(`#${element}`).toggleClass(CKCLS, false);
- }
- });
- $(`#${value} ${MCB}`).toggleClass(CKCLS, true);
- $(`#${value}`).toggleClass(CKCLS, true);
- this.currentBoard.setPresentParentTask(value);
+ // Get the ID from the anchor element, not the span
+ const anchorElement = $(evt.target).closest('.js-field-show-parent-in-minicard')[0];
+ const value = anchorElement ? anchorElement.id : null;
+
+ if (value) {
+ Boards.update(this.currentBoard._id, { $set: { presentParentTask: value } });
+ }
evt.preventDefault();
},
},
@@ -947,115 +978,168 @@ BlazeComponent.extendComponent({
},
allowsReceivedDate() {
- return this.currentBoard.allowsReceivedDate;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsReceivedDate : false;
},
allowsStartDate() {
- return this.currentBoard.allowsStartDate;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsStartDate : false;
},
allowsDueDate() {
- return this.currentBoard.allowsDueDate;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsDueDate : false;
},
allowsEndDate() {
- return this.currentBoard.allowsEndDate;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsEndDate : false;
},
allowsSubtasks() {
- return this.currentBoard.allowsSubtasks;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsSubtasks : false;
},
allowsCreator() {
- return this.currentBoard.allowsCreator ?? false;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? (currentBoard.allowsCreator ?? false) : false;
},
allowsCreatorOnMinicard() {
- return this.currentBoard.allowsCreatorOnMinicard ?? false;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? (currentBoard.allowsCreatorOnMinicard ?? false) : false;
},
allowsMembers() {
- return this.currentBoard.allowsMembers;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsMembers : false;
},
allowsAssignee() {
- return this.currentBoard.allowsAssignee;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsAssignee : false;
},
allowsAssignedBy() {
- return this.currentBoard.allowsAssignedBy;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsAssignedBy : false;
},
allowsRequestedBy() {
- return this.currentBoard.allowsRequestedBy;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsRequestedBy : false;
},
allowsCardSortingByNumber() {
- return this.currentBoard.allowsCardSortingByNumber;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsCardSortingByNumber : false;
},
allowsShowLists() {
- return this.currentBoard.allowsShowLists;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsShowLists : false;
},
-
allowsLabels() {
- return this.currentBoard.allowsLabels;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsLabels : false;
},
allowsShowListsOnMinicard() {
- return this.currentBoard.allowsShowListsOnMinicard;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsShowListsOnMinicard : false;
},
allowsChecklists() {
- return this.currentBoard.allowsChecklists;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsChecklists : false;
},
allowsAttachments() {
- return this.currentBoard.allowsAttachments;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsAttachments : false;
},
allowsComments() {
- return this.currentBoard.allowsComments;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsComments : false;
},
allowsCardNumber() {
- return this.currentBoard.allowsCardNumber;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsCardNumber : false;
},
allowsDescriptionTitle() {
- return this.currentBoard.allowsDescriptionTitle;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsDescriptionTitle : false;
},
allowsDescriptionText() {
- return this.currentBoard.allowsDescriptionText;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsDescriptionText : false;
},
isBoardSelected() {
- return this.currentBoard.dateSettingsDefaultBoardID;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.dateSettingsDefaultBoardID : false;
},
isNullBoardSelected() {
- return (
- this.currentBoard.dateSettingsDefaultBoardId === null ||
- this.currentBoard.dateSettingsDefaultBoardId === undefined
- );
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? (
+ currentBoard.dateSettingsDefaultBoardId === null ||
+ currentBoard.dateSettingsDefaultBoardId === undefined
+ ) : true;
},
allowsDescriptionTextOnMinicard() {
- return this.currentBoard.allowsDescriptionTextOnMinicard;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsDescriptionTextOnMinicard : false;
},
allowsCoverAttachmentOnMinicard() {
- return this.currentBoard.allowsCoverAttachmentOnMinicard;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsCoverAttachmentOnMinicard : false;
},
allowsBadgeAttachmentOnMinicard() {
- return this.currentBoard.allowsBadgeAttachmentOnMinicard;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsBadgeAttachmentOnMinicard : false;
},
allowsCardSortingByNumberOnMinicard() {
- return this.currentBoard.allowsCardSortingByNumberOnMinicard;
+ const boardId = Session.get('currentBoard');
+ const currentBoard = ReactiveCache.getBoard(boardId);
+ return currentBoard ? currentBoard.allowsCardSortingByNumberOnMinicard : false;
},
boards() {
@@ -1098,203 +1182,73 @@ BlazeComponent.extendComponent({
{
'click .js-field-has-receiveddate'(evt) {
evt.preventDefault();
- this.currentBoard.allowsReceivedDate = !this.currentBoard
- .allowsReceivedDate;
- this.currentBoard.setAllowsReceivedDate(
- this.currentBoard.allowsReceivedDate,
- );
- $(`.js-field-has-receiveddate ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsReceivedDate,
- );
- $('.js-field-has-receiveddate').toggleClass(
- CKCLS,
- this.currentBoard.allowsReceivedDate,
- );
+ const newValue = !this.currentBoard.allowsReceivedDate;
+ Boards.update(this.currentBoard._id, { $set: { allowsReceivedDate: newValue } });
},
'click .js-field-has-startdate'(evt) {
evt.preventDefault();
- this.currentBoard.allowsStartDate = !this.currentBoard
- .allowsStartDate;
- this.currentBoard.setAllowsStartDate(
- this.currentBoard.allowsStartDate,
- );
- $(`.js-field-has-startdate ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsStartDate,
- );
- $('.js-field-has-startdate').toggleClass(
- CKCLS,
- this.currentBoard.allowsStartDate,
- );
+ const newValue = !this.currentBoard.allowsStartDate;
+ Boards.update(this.currentBoard._id, { $set: { allowsStartDate: newValue } });
},
'click .js-field-has-enddate'(evt) {
evt.preventDefault();
- this.currentBoard.allowsEndDate = !this.currentBoard.allowsEndDate;
- this.currentBoard.setAllowsEndDate(this.currentBoard.allowsEndDate);
- $(`.js-field-has-enddate ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsEndDate,
- );
- $('.js-field-has-enddate').toggleClass(
- CKCLS,
- this.currentBoard.allowsEndDate,
- );
+ const newValue = !this.currentBoard.allowsEndDate;
+ Boards.update(this.currentBoard._id, { $set: { allowsEndDate: newValue } });
},
'click .js-field-has-duedate'(evt) {
evt.preventDefault();
- this.currentBoard.allowsDueDate = !this.currentBoard.allowsDueDate;
- this.currentBoard.setAllowsDueDate(this.currentBoard.allowsDueDate);
- $(`.js-field-has-duedate ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsDueDate,
- );
- $('.js-field-has-duedate').toggleClass(
- CKCLS,
- this.currentBoard.allowsDueDate,
- );
+ const newValue = !this.currentBoard.allowsDueDate;
+ Boards.update(this.currentBoard._id, { $set: { allowsDueDate: newValue } });
},
'click .js-field-has-subtasks'(evt) {
evt.preventDefault();
- this.currentBoard.allowsSubtasks = !this.currentBoard.allowsSubtasks;
- this.currentBoard.setAllowsSubtasks(this.currentBoard.allowsSubtasks);
- $(`.js-field-has-subtasks ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsSubtasks,
- );
- $('.js-field-has-subtasks').toggleClass(
- CKCLS,
- this.currentBoard.allowsSubtasks,
- );
+ const newValue = !this.currentBoard.allowsSubtasks;
+ Boards.update(this.currentBoard._id, { $set: { allowsSubtasks: newValue } });
},
'click .js-field-has-creator'(evt) {
evt.preventDefault();
- this.currentBoard.allowsCreator = !this.currentBoard.allowsCreator;
- this.currentBoard.setAllowsCreator(this.currentBoard.allowsCreator);
- $(`.js-field-has-creator ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsCreator,
- );
- $('.js-field-has-creator').toggleClass(
- CKCLS,
- this.currentBoard.allowsCreator,
- );
+ const newValue = !this.currentBoard.allowsCreator;
+ Boards.update(this.currentBoard._id, { $set: { allowsCreator: newValue } });
},
'click .js-field-has-creator-on-minicard'(evt) {
evt.preventDefault();
- this.currentBoard.allowsCreatorOnMinicard = !this.currentBoard.allowsCreatorOnMinicard;
- this.currentBoard.setAllowsCreatorOnMinicard(this.currentBoard.allowsCreatorOnMinicard);
- $(`.js-field-has-creator-on-minicard ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsCreatorOnMinicard,
- );
- $('.js-field-has-creator-on-minicard').toggleClass(
- CKCLS,
- this.currentBoard.allowsCreatorOnMinicard,
- );
+ const newValue = !this.currentBoard.allowsCreatorOnMinicard;
+ Boards.update(this.currentBoard._id, { $set: { allowsCreatorOnMinicard: newValue } });
},
'click .js-field-has-members'(evt) {
evt.preventDefault();
- this.currentBoard.allowsMembers = !this.currentBoard.allowsMembers;
- this.currentBoard.setAllowsMembers(this.currentBoard.allowsMembers);
- $(`.js-field-has-members ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsMembers,
- );
- $('.js-field-has-members').toggleClass(
- CKCLS,
- this.currentBoard.allowsMembers,
- );
+ const newValue = !this.currentBoard.allowsMembers;
+ Boards.update(this.currentBoard._id, { $set: { allowsMembers: newValue } });
},
'click .js-field-has-assignee'(evt) {
evt.preventDefault();
- this.currentBoard.allowsAssignee = !this.currentBoard.allowsAssignee;
- this.currentBoard.setAllowsAssignee(this.currentBoard.allowsAssignee);
- $(`.js-field-has-assignee ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsAssignee,
- );
- $('.js-field-has-assignee').toggleClass(
- CKCLS,
- this.currentBoard.allowsAssignee,
- );
+ const newValue = !this.currentBoard.allowsAssignee;
+ Boards.update(this.currentBoard._id, { $set: { allowsAssignee: newValue } });
},
'click .js-field-has-assigned-by'(evt) {
evt.preventDefault();
- this.currentBoard.allowsAssignedBy = !this.currentBoard
- .allowsAssignedBy;
- this.currentBoard.setAllowsAssignedBy(
- this.currentBoard.allowsAssignedBy,
- );
- $(`.js-field-has-assigned-by ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsAssignedBy,
- );
- $('.js-field-has-assigned-by').toggleClass(
- CKCLS,
- this.currentBoard.allowsAssignedBy,
- );
+ const newValue = !this.currentBoard.allowsAssignedBy;
+ Boards.update(this.currentBoard._id, { $set: { allowsAssignedBy: newValue } });
},
'click .js-field-has-requested-by'(evt) {
evt.preventDefault();
- this.currentBoard.allowsRequestedBy = !this.currentBoard
- .allowsRequestedBy;
- this.currentBoard.setAllowsRequestedBy(
- this.currentBoard.allowsRequestedBy,
- );
- $(`.js-field-has-requested-by ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsRequestedBy,
- );
- $('.js-field-has-requested-by').toggleClass(
- CKCLS,
- this.currentBoard.allowsRequestedBy,
- );
+ const newValue = !this.currentBoard.allowsRequestedBy;
+ Boards.update(this.currentBoard._id, { $set: { allowsRequestedBy: newValue } });
},
'click .js-field-has-card-sorting-by-number'(evt) {
evt.preventDefault();
- this.currentBoard.allowsCardSortingByNumber = !this.currentBoard
- .allowsCardSortingByNumber;
- this.currentBoard.setAllowsCardSortingByNumber(
- this.currentBoard.allowsCardSortingByNumber,
- );
- $(`.js-field-has-card-sorting-by-number ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsCardSortingByNumber,
- );
- $('.js-field-has-card-sorting-by-number').toggleClass(
- CKCLS,
- this.currentBoard.allowsCardSortingByNumber,
- );
+ const newValue = !this.currentBoard.allowsCardSortingByNumber;
+ Boards.update(this.currentBoard._id, { $set: { allowsCardSortingByNumber: newValue } });
},
'click .js-field-has-card-show-lists'(evt) {
evt.preventDefault();
- this.currentBoard.allowsShowLists = !this.currentBoard
- .allowsShowLists;
- this.currentBoard.setAllowsShowLists(
- this.currentBoard.allowsShowLists,
- );
- $(`.js-field-has-card-show-lists ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsShowLists,
- );
- $('.js-field-has-card-show-lists').toggleClass(
- CKCLS,
- this.currentBoard.allowsShowLists,
- );
+ const newValue = !this.currentBoard.allowsShowLists;
+ Boards.update(this.currentBoard._id, { $set: { allowsShowLists: newValue } });
},
'click .js-field-has-labels'(evt) {
evt.preventDefault();
- this.currentBoard.allowsLabels = !this.currentBoard.allowsLabels;
- this.currentBoard.setAllowsLabels(this.currentBoard.allowsLabels);
- $(`.js-field-has-labels ${MCB}`).toggleClass(
- CKCLS,
- this.currentBoard.allowsLabels,
- );
- $('.js-field-has-labels').toggleClass(
- CKCLS,
- this.currentBoard.allowsLabels,
- );
+ const newValue = !this.currentBoard.allowsLabels;
+ Boards.update(this.currentBoard._id, { $set: { allowsLabels: newValue } });
},
'click .js-field-has-card-show-lists-on-minicard'(evt) {
evt.preventDefault();
@@ -1490,19 +1444,27 @@ BlazeComponent.extendComponent({
},
}).register('boardCardSettingsPopup');
+// Use Session variables instead of global ReactiveVars
+Session.setDefault('addMemberPopup.searchResults', []);
+Session.setDefault('addMemberPopup.searching', false);
+Session.setDefault('addMemberPopup.noResults', false);
+Session.setDefault('addMemberPopup.loading', false);
+Session.setDefault('addMemberPopup.error', '');
+
+
BlazeComponent.extendComponent({
onCreated() {
- this.error = new ReactiveVar('');
- this.loading = new ReactiveVar(false);
+ // Use Session variables
+ this.searchTimeout = null;
},
onRendered() {
- this.find('.js-search-member input').focus();
+ this.find('.js-search-member-input').focus();
this.setLoading(false);
},
isBoardMember() {
- const userId = this.currentData().__originalId;
+ const userId = this.currentData()._id;
const user = ReactiveCache.getUser(userId);
return user && user.isBoardMember();
},
@@ -1512,15 +1474,35 @@ BlazeComponent.extendComponent({
},
setError(error) {
- this.error.set(error);
+ Session.set('addMemberPopup.error', error);
},
setLoading(w) {
- this.loading.set(w);
+ Session.set('addMemberPopup.loading', w);
},
isLoading() {
- return this.loading.get();
+ return Session.get('addMemberPopup.loading');
+ },
+
+ performSearch(query) {
+ if (!query || query.length < 2) {
+ Session.set('addMemberPopup.searchResults', []);
+ Session.set('addMemberPopup.noResults', false);
+ return;
+ }
+
+ Session.set('addMemberPopup.searching', true);
+ Session.set('addMemberPopup.noResults', false);
+
+ // Use the fallback search
+ const results = UserSearchIndex.search(query, { limit: 20 }).fetch();
+ Session.set('addMemberPopup.searchResults', results);
+ Session.set('addMemberPopup.searching', false);
+
+ if (results.length === 0) {
+ Session.set('addMemberPopup.noResults', true);
+ }
},
inviteUser(idNameEmail) {
@@ -1538,18 +1520,30 @@ BlazeComponent.extendComponent({
events() {
return [
{
- 'keyup input'() {
+ 'keyup .js-search-member-input'(event) {
this.setError('');
+ const query = event.target.value.trim();
+ this.searchQuery.set(query);
+
+ // Clear previous timeout
+ if (this.searchTimeout) {
+ clearTimeout(this.searchTimeout);
+ }
+
+ // Debounce search
+ this.searchTimeout = setTimeout(() => {
+ this.performSearch(query);
+ }, 300);
},
'click .js-select-member'() {
- const userId = this.currentData().__originalId;
+ const userId = this.currentData()._id;
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard.hasMember(userId)) {
this.inviteUser(userId);
}
},
'click .js-email-invite'() {
- const idNameEmail = $('.js-search-member input').val();
+ const idNameEmail = $('.js-search-member-input').val();
if (idNameEmail.indexOf('@') < 0 || this.isValidEmail(idNameEmail)) {
this.inviteUser(idNameEmail);
} else this.setError('email-invalid');
@@ -1560,7 +1554,35 @@ BlazeComponent.extendComponent({
}).register('addMemberPopup');
Template.addMemberPopup.helpers({
- searchIndex: () => UserSearchIndex,
+ searchResults() {
+ const results = Session.get('addMemberPopup.searchResults');
+ console.log('searchResults helper called, returning:', results);
+ return results;
+ },
+ searching() {
+ return Session.get('addMemberPopup.searching');
+ },
+ noResults() {
+ return Session.get('addMemberPopup.noResults');
+ },
+ loading() {
+ return Session.get('addMemberPopup.loading');
+ },
+ error() {
+ return Session.get('addMemberPopup.error');
+ },
+ isBoardMember() {
+ const userId = this._id;
+ const user = ReactiveCache.getUser(userId);
+ return user && user.isBoardMember();
+ }
+})
+
+Template.addMemberPopupTest.helpers({
+ searchResults() {
+ console.log('addMemberPopupTest searchResults helper called');
+ return Session.get('addMemberPopup.searchResults') || [];
+ }
})
BlazeComponent.extendComponent({
diff --git a/client/components/sidebar/sidebarCustomFields.jade b/client/components/sidebar/sidebarCustomFields.jade
index 1cc270681..0d16559f8 100644
--- a/client/components/sidebar/sidebarCustomFields.jade
+++ b/client/components/sidebar/sidebarCustomFields.jade
@@ -95,3 +95,7 @@ template(name="createCustomFieldPopup")
template(name="deleteCustomFieldPopup")
p {{_ "custom-field-delete-pop"}}
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
+
+// Reuse the create form for editing to satisfy popup template lookup
+template(name="editCustomFieldPopup")
+ +Template.dynamic(template="createCustomFieldPopup")
diff --git a/client/components/sidebar/sidebarMigrations.jade b/client/components/sidebar/sidebarMigrations.jade
new file mode 100644
index 000000000..f5f7f08f8
--- /dev/null
+++ b/client/components/sidebar/sidebarMigrations.jade
@@ -0,0 +1,109 @@
+template(name='migrationsSidebar')
+ if currentUser.isBoardAdmin
+ .sidebar-migrations
+ h3
+ | 🔧
+ | {{_ 'migrations'}}
+ p.quiet {{_ 'migrations-description'}}
+
+ .migrations-list
+ h4 {{_ 'board-migrations'}}
+ .migration-item
+ a.js-run-migration(data-migration="comprehensive")
+ .migration-name
+ | {{_ 'comprehensive-board-migration'}}
+ .migration-status
+ if comprehensiveMigrationNeeded
+ span.badge.badge-warning {{_ 'migration-needed'}}
+ else
+ span.badge.badge-success {{_ 'migration-complete'}}
+
+ .migration-item
+ a.js-run-migration(data-migration="fixMissingLists")
+ .migration-name
+ | {{_ 'fix-missing-lists-migration'}}
+ .migration-status
+ if fixMissingListsNeeded
+ span.badge.badge-warning {{_ 'migration-needed'}}
+ else
+ span.badge.badge-success {{_ 'migration-complete'}}
+
+ .migration-item
+ a.js-run-migration(data-migration="deleteDuplicateEmptyLists")
+ .migration-name
+ | {{_ 'delete-duplicate-empty-lists-migration'}}
+ .migration-status
+ if deleteDuplicateEmptyListsNeeded
+ span.badge.badge-warning {{_ 'migration-needed'}}
+ else
+ span.badge.badge-success {{_ 'migration-complete'}}
+
+ .migration-item
+ a.js-run-migration(data-migration="restoreLostCards")
+ .migration-name
+ | {{_ 'restore-lost-cards-migration'}}
+ .migration-status
+ if restoreLostCardsNeeded
+ span.badge.badge-warning {{_ 'migration-needed'}}
+ else
+ span.badge.badge-success {{_ 'migration-complete'}}
+
+ .migration-item
+ a.js-run-migration(data-migration="restoreAllArchived")
+ .migration-name
+ | {{_ 'restore-all-archived-migration'}}
+ .migration-status
+ if restoreAllArchivedNeeded
+ span.badge.badge-warning {{_ 'migration-needed'}}
+ else
+ span.badge.badge-success {{_ 'migration-complete'}}
+
+ .migration-item
+ a.js-run-migration(data-migration="fixAvatarUrls")
+ .migration-name
+ | {{_ 'fix-avatar-urls-migration'}}
+ .migration-status
+ if fixAvatarUrlsNeeded
+ span.badge.badge-warning {{_ 'migration-needed'}}
+ else
+ span.badge.badge-success {{_ 'migration-complete'}}
+
+ .migration-item
+ a.js-run-migration(data-migration="fixAllFileUrls")
+ .migration-name
+ | {{_ 'fix-all-file-urls-migration'}}
+ .migration-status
+ if fixAllFileUrlsNeeded
+ span.badge.badge-warning {{_ 'migration-needed'}}
+ else
+ span.badge.badge-success {{_ 'migration-complete'}}
+ else
+ p.quiet {{_ 'migrations-admin-only'}}
+
+template(name='runComprehensiveMigrationPopup')
+ p {{_ 'run-comprehensive-migration-confirm'}}
+ button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runFixMissingListsMigrationPopup')
+ p {{_ 'run-fix-missing-lists-migration-confirm'}}
+ button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runDeleteDuplicateEmptyListsMigrationPopup')
+ p {{_ 'run-delete-duplicate-empty-lists-migration-confirm'}}
+ button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runRestoreLostCardsMigrationPopup')
+ p {{_ 'run-restore-lost-cards-migration-confirm'}}
+ button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runRestoreAllArchivedMigrationPopup')
+ p {{_ 'run-restore-all-archived-migration-confirm'}}
+ button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runFixAvatarUrlsMigrationPopup')
+ p {{_ 'run-fix-avatar-urls-migration-confirm'}}
+ button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runFixAllFileUrlsMigrationPopup')
+ p {{_ 'run-fix-all-file-urls-migration-confirm'}}
+ button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
diff --git a/client/components/sidebar/sidebarMigrations.js b/client/components/sidebar/sidebarMigrations.js
new file mode 100644
index 000000000..89d3343ec
--- /dev/null
+++ b/client/components/sidebar/sidebarMigrations.js
@@ -0,0 +1,341 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+import { TAPi18n } from '/imports/i18n';
+import { migrationProgressManager } from '/client/components/migrationProgress';
+
+BlazeComponent.extendComponent({
+ onCreated() {
+ this.migrationStatuses = new ReactiveVar({});
+ this.loadMigrationStatuses();
+ },
+
+ loadMigrationStatuses() {
+ const boardId = Session.get('currentBoard');
+ if (!boardId) return;
+
+ // Check comprehensive migration
+ Meteor.call('comprehensiveBoardMigration.needsMigration', boardId, (err, res) => {
+ if (!err) {
+ const statuses = this.migrationStatuses.get();
+ statuses.comprehensive = res;
+ this.migrationStatuses.set(statuses);
+ }
+ });
+
+ // Check fix missing lists migration
+ Meteor.call('fixMissingListsMigration.needsMigration', boardId, (err, res) => {
+ if (!err) {
+ const statuses = this.migrationStatuses.get();
+ statuses.fixMissingLists = res;
+ this.migrationStatuses.set(statuses);
+ }
+ });
+
+ // Check delete duplicate empty lists migration
+ Meteor.call('deleteDuplicateEmptyLists.needsMigration', boardId, (err, res) => {
+ if (!err) {
+ const statuses = this.migrationStatuses.get();
+ statuses.deleteDuplicateEmptyLists = res;
+ this.migrationStatuses.set(statuses);
+ }
+ });
+
+ // Check restore lost cards migration
+ Meteor.call('restoreLostCards.needsMigration', boardId, (err, res) => {
+ if (!err) {
+ const statuses = this.migrationStatuses.get();
+ statuses.restoreLostCards = res;
+ this.migrationStatuses.set(statuses);
+ }
+ });
+
+ // Check restore all archived migration
+ Meteor.call('restoreAllArchived.needsMigration', boardId, (err, res) => {
+ if (!err) {
+ const statuses = this.migrationStatuses.get();
+ statuses.restoreAllArchived = res;
+ this.migrationStatuses.set(statuses);
+ }
+ });
+
+ // Check fix avatar URLs migration (board-specific)
+ Meteor.call('fixAvatarUrls.needsMigration', boardId, (err, res) => {
+ if (!err) {
+ const statuses = this.migrationStatuses.get();
+ statuses.fixAvatarUrls = res;
+ this.migrationStatuses.set(statuses);
+ }
+ });
+
+ // Check fix all file URLs migration (board-specific)
+ Meteor.call('fixAllFileUrls.needsMigration', boardId, (err, res) => {
+ if (!err) {
+ const statuses = this.migrationStatuses.get();
+ statuses.fixAllFileUrls = res;
+ this.migrationStatuses.set(statuses);
+ }
+ });
+ },
+
+ comprehensiveMigrationNeeded() {
+ return this.migrationStatuses.get().comprehensive === true;
+ },
+
+ fixMissingListsNeeded() {
+ return this.migrationStatuses.get().fixMissingLists === true;
+ },
+
+ deleteDuplicateEmptyListsNeeded() {
+ return this.migrationStatuses.get().deleteDuplicateEmptyLists === true;
+ },
+
+ restoreLostCardsNeeded() {
+ return this.migrationStatuses.get().restoreLostCards === true;
+ },
+
+ restoreAllArchivedNeeded() {
+ return this.migrationStatuses.get().restoreAllArchived === true;
+ },
+
+ fixAvatarUrlsNeeded() {
+ return this.migrationStatuses.get().fixAvatarUrls === true;
+ },
+
+ fixAllFileUrlsNeeded() {
+ return this.migrationStatuses.get().fixAllFileUrls === true;
+ },
+
+ // Simulate migration progress updates using the global progress popup
+ async simulateMigrationProgress(progressSteps) {
+ const totalSteps = progressSteps.length;
+ for (let i = 0; i < progressSteps.length; i++) {
+ const step = progressSteps[i];
+ const overall = Math.round(((i + 1) / totalSteps) * 100);
+
+ // Start step
+ migrationProgressManager.updateProgress({
+ overallProgress: overall,
+ currentStep: i + 1,
+ totalSteps,
+ stepName: step.step,
+ stepProgress: 0,
+ stepStatus: `Starting ${step.name}...`,
+ stepDetails: null,
+ boardId: Session.get('currentBoard'),
+ });
+
+ const stepDuration = step.duration;
+ const updateInterval = 100;
+ const totalUpdates = Math.max(1, Math.floor(stepDuration / updateInterval));
+ for (let j = 0; j < totalUpdates; j++) {
+ const per = Math.round(((j + 1) / totalUpdates) * 100);
+ migrationProgressManager.updateProgress({
+ overallProgress: overall,
+ currentStep: i + 1,
+ totalSteps,
+ stepName: step.step,
+ stepProgress: per,
+ stepStatus: `Processing ${step.name}...`,
+ stepDetails: { progress: `${per}%` },
+ boardId: Session.get('currentBoard'),
+ });
+ // eslint-disable-next-line no-await-in-loop
+ await new Promise((r) => setTimeout(r, updateInterval));
+ }
+
+ // Complete step
+ migrationProgressManager.updateProgress({
+ overallProgress: overall,
+ currentStep: i + 1,
+ totalSteps,
+ stepName: step.step,
+ stepProgress: 100,
+ stepStatus: `${step.name} completed`,
+ stepDetails: { status: 'completed' },
+ boardId: Session.get('currentBoard'),
+ });
+ }
+ },
+
+ runMigration(migrationType) {
+ const boardId = Session.get('currentBoard');
+
+ let methodName;
+ let methodArgs = [];
+
+ switch (migrationType) {
+ case 'comprehensive':
+ methodName = 'comprehensiveBoardMigration.execute';
+ methodArgs = [boardId];
+ break;
+
+ case 'fixMissingLists':
+ methodName = 'fixMissingListsMigration.execute';
+ methodArgs = [boardId];
+ break;
+
+ case 'deleteDuplicateEmptyLists':
+ methodName = 'deleteDuplicateEmptyLists.execute';
+ methodArgs = [boardId];
+ break;
+
+ case 'restoreLostCards':
+ methodName = 'restoreLostCards.execute';
+ methodArgs = [boardId];
+ break;
+
+ case 'restoreAllArchived':
+ methodName = 'restoreAllArchived.execute';
+ methodArgs = [boardId];
+ break;
+
+ case 'fixAvatarUrls':
+ methodName = 'fixAvatarUrls.execute';
+ methodArgs = [boardId];
+ break;
+
+ case 'fixAllFileUrls':
+ methodName = 'fixAllFileUrls.execute';
+ methodArgs = [boardId];
+ break;
+ }
+
+ if (methodName) {
+ // Define simulated steps per migration type
+ const stepsByType = {
+ comprehensive: [
+ { step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 800 },
+ { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 1200 },
+ { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 1000 },
+ { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 800 },
+ { step: 'validate_migration', name: 'Validate Migration', duration: 800 },
+ { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 600 },
+ { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 600 },
+ ],
+ fixMissingLists: [
+ { step: 'analyze_lists', name: 'Analyze Lists', duration: 600 },
+ { step: 'create_missing_lists', name: 'Create Missing Lists', duration: 900 },
+ { step: 'update_cards', name: 'Update Cards', duration: 900 },
+ { step: 'finalize', name: 'Finalize', duration: 400 },
+ ],
+ deleteDuplicateEmptyLists: [
+ { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 700 },
+ { step: 'delete_duplicate_empty_lists', name: 'Delete Duplicate Empty Lists', duration: 800 },
+ ],
+ restoreLostCards: [
+ { step: 'ensure_lost_cards_swimlane', name: 'Ensure Lost Cards Swimlane', duration: 600 },
+ { step: 'restore_lists', name: 'Restore Lists', duration: 800 },
+ { step: 'restore_cards', name: 'Restore Cards', duration: 1000 },
+ ],
+ restoreAllArchived: [
+ { step: 'restore_swimlanes', name: 'Restore Swimlanes', duration: 800 },
+ { step: 'restore_lists', name: 'Restore Lists', duration: 900 },
+ { step: 'restore_cards', name: 'Restore Cards', duration: 1000 },
+ { step: 'fix_missing_ids', name: 'Fix Missing IDs', duration: 600 },
+ ],
+ fixAvatarUrls: [
+ { step: 'scan_users', name: 'Checking board member avatars', duration: 500 },
+ { step: 'fix_urls', name: 'Fixing avatar URLs', duration: 900 },
+ ],
+ fixAllFileUrls: [
+ { step: 'scan_files', name: 'Checking board file attachments', duration: 600 },
+ { step: 'fix_urls', name: 'Fixing file URLs', duration: 1000 },
+ ],
+ };
+
+ const steps = stepsByType[migrationType] || [
+ { step: 'running', name: 'Running Migration', duration: 1000 },
+ ];
+
+ // Kick off popup and simulated progress
+ migrationProgressManager.startMigration();
+ const progressPromise = this.simulateMigrationProgress(steps);
+
+ // Start migration call
+ const callPromise = new Promise((resolve, reject) => {
+ Meteor.call(methodName, ...methodArgs, (err, result) => {
+ if (err) return reject(err);
+ return resolve(result);
+ });
+ });
+
+ Promise.allSettled([callPromise, progressPromise]).then(([callRes]) => {
+ if (callRes.status === 'rejected') {
+ migrationProgressManager.failMigration(callRes.reason);
+ } else {
+ const result = callRes.value;
+ // Summarize result details in the popup
+ let summary = {};
+ if (result && result.results) {
+ // Comprehensive returns {success, results}
+ const r = result.results;
+ summary = {
+ totalCardsProcessed: r.totalCardsProcessed,
+ totalListsProcessed: r.totalListsProcessed,
+ totalListsCreated: r.totalListsCreated,
+ };
+ } else if (result && result.changes) {
+ // Many migrations return a changes string array
+ summary = { changes: result.changes.join(' | ') };
+ } else if (result && typeof result === 'object') {
+ summary = result;
+ }
+
+ migrationProgressManager.updateProgress({
+ overallProgress: 100,
+ currentStep: steps.length,
+ totalSteps: steps.length,
+ stepName: 'completed',
+ stepProgress: 100,
+ stepStatus: 'Migration completed',
+ stepDetails: summary,
+ boardId: Session.get('currentBoard'),
+ });
+
+ migrationProgressManager.completeMigration();
+
+ // Refresh status badges slightly after
+ Meteor.setTimeout(() => {
+ this.loadMigrationStatuses();
+ }, 1000);
+ }
+ });
+ }
+ },
+
+ events() {
+ const self = this; // Capture component reference
+
+ return [
+ {
+ 'click .js-run-migration[data-migration="comprehensive"]': Popup.afterConfirm('runComprehensiveMigration', function() {
+ self.runMigration('comprehensive');
+ Popup.back();
+ }),
+ 'click .js-run-migration[data-migration="fixMissingLists"]': Popup.afterConfirm('runFixMissingListsMigration', function() {
+ self.runMigration('fixMissingLists');
+ Popup.back();
+ }),
+ 'click .js-run-migration[data-migration="deleteDuplicateEmptyLists"]': Popup.afterConfirm('runDeleteDuplicateEmptyListsMigration', function() {
+ self.runMigration('deleteDuplicateEmptyLists');
+ Popup.back();
+ }),
+ 'click .js-run-migration[data-migration="restoreLostCards"]': Popup.afterConfirm('runRestoreLostCardsMigration', function() {
+ self.runMigration('restoreLostCards');
+ Popup.back();
+ }),
+ 'click .js-run-migration[data-migration="restoreAllArchived"]': Popup.afterConfirm('runRestoreAllArchivedMigration', function() {
+ self.runMigration('restoreAllArchived');
+ Popup.back();
+ }),
+ 'click .js-run-migration[data-migration="fixAvatarUrls"]': Popup.afterConfirm('runFixAvatarUrlsMigration', function() {
+ self.runMigration('fixAvatarUrls');
+ Popup.back();
+ }),
+ 'click .js-run-migration[data-migration="fixAllFileUrls"]': Popup.afterConfirm('runFixAllFileUrlsMigration', function() {
+ self.runMigration('fixAllFileUrls');
+ Popup.back();
+ }),
+ },
+ ];
+ },
+}).register('migrationsSidebar');
diff --git a/client/components/swimlanes/swimlaneHeader.jade b/client/components/swimlanes/swimlaneHeader.jade
index bd9245e05..a0a44eb7f 100644
--- a/client/components/swimlanes/swimlaneHeader.jade
+++ b/client/components/swimlanes/swimlaneHeader.jade
@@ -23,27 +23,28 @@ template(name="swimlaneFixedHeader")
+viewer
| {{isTitleDefault title}}
.swimlane-header-menu
- unless currentUser.isCommentOnly
- a.js-open-add-swimlane-menu.swimlane-header-plus-icon(title="{{_ 'add-swimlane'}}")
- | ➕
- a.js-open-swimlane-menu(title="{{_ 'swimlaneActionPopup-title'}}")
- | ☰
- //// TODO: Collapse Swimlane: make button working, etc.
- //unless collapsed
- // a.js-collapse-swimlane(title="{{_ 'collapse'}}")
- // i.fa.fa-arrow-down.swimlane-header-collapse-down
- // ⬆️.swimlane-header-collapse-up
- //if collapsed
- // a.js-collapse-swimlane(title="{{_ 'uncollapse'}}")
- // ⬆️.swimlane-header-collapse-up
- // i.fa.fa-arrow-down.swimlane-header-collapse-down
- unless isTouchScreen
- if isShowDesktopDragHandles
- a.swimlane-header-handle.handle.js-swimlane-header-handle
- | ↕️
- if isTouchScreen
- a.swimlane-header-miniscreen-handle.handle.js-swimlane-header-handle
- | ↕️
+ if currentUser
+ unless currentUser.isCommentOnly
+ unless currentUser.isWorker
+ a.js-open-add-swimlane-menu.swimlane-header-plus-icon(title="{{_ 'add-swimlane'}}")
+ | ➕
+ a.js-open-swimlane-menu(title="{{_ 'swimlaneActionPopup-title'}}")
+ | ☰
+ //// TODO: Collapse Swimlane: make button working, etc.
+ //unless collapsed
+ // a.js-collapse-swimlane(title="{{_ 'collapse'}}")
+ // i.fa.fa-arrow-down.swimlane-header-collapse-down
+ // ⬆️.swimlane-header-collapse-up
+ //if collapsed
+ // a.js-collapse-swimlane(title="{{_ 'uncollapse'}}")
+ // ⬆️.swimlane-header-collapse-up
+ // i.fa.fa-arrow-down.swimlane-header-collapse-down
+ unless isTouchScreen
+ a.swimlane-header-handle.handle.js-swimlane-header-handle
+ | ↕️
+ if isTouchScreen
+ a.swimlane-header-miniscreen-handle.handle.js-swimlane-header-handle
+ | ↕️
template(name="editSwimlaneTitleForm")
.list-composer
@@ -54,44 +55,46 @@ template(name="editSwimlaneTitleForm")
| ❌
template(name="swimlaneActionPopup")
- unless currentUser.isCommentOnly
- ul.pop-over-list
- if currentUser.isBoardAdmin
- li: a.js-set-swimlane-color
- | 🎨
- | {{_ 'select-color'}}
- li: a.js-set-swimlane-height
- | ↕️
- | {{_ 'set-swimlane-height'}}
- if currentUser.isBoardAdmin
- unless this.isTemplateContainer
- hr
- ul.pop-over-list
- li: a.js-close-swimlane
- | ▶️
- | 📦
- | {{_ 'archive-swimlane'}}
- ul.pop-over-list
- li: a.js-copy-swimlane
- | 📋
- | {{_ 'copy-swimlane'}}
- ul.pop-over-list
- li: a.js-move-swimlane
- | ⬆️
- | {{_ 'move-swimlane'}}
+ if currentUser
+ unless currentUser.isCommentOnly
+ ul.pop-over-list
+ if currentUser.isBoardAdmin
+ li: a.js-set-swimlane-color
+ | 🎨
+ | {{_ 'select-color'}}
+ li: a.js-set-swimlane-height
+ | ↕️
+ | {{_ 'set-swimlane-height'}}
+ if currentUser.isBoardAdmin
+ unless this.isTemplateContainer
+ hr
+ ul.pop-over-list
+ li: a.js-close-swimlane
+ | ▶️
+ | 📦
+ | {{_ 'archive-swimlane'}}
+ ul.pop-over-list
+ li: a.js-copy-swimlane
+ | 📋
+ | {{_ 'copy-swimlane'}}
+ ul.pop-over-list
+ li: a.js-move-swimlane
+ | ⬆️
+ | {{_ 'move-swimlane'}}
template(name="swimlaneAddPopup")
- unless currentUser.isCommentOnly
- form
- input.swimlane-name-input.full-line(type="text" placeholder="{{_ 'add-swimlane'}}"
- autocomplete="off" autofocus)
- .edit-controls.clearfix
- button.primary.confirm(type="submit") {{_ 'add'}}
- unless currentBoard.isTemplatesBoard
- unless currentBoard.isTemplateBoard
- span.quiet
- | {{_ 'or'}}
- a.js-swimlane-template {{_ 'template'}}
+ if currentUser
+ unless currentUser.isCommentOnly
+ form
+ input.swimlane-name-input.full-line(type="text" placeholder="{{_ 'add-swimlane'}}"
+ autocomplete="off" autofocus)
+ .edit-controls.clearfix
+ button.primary.confirm(type="submit") {{_ 'add'}}
+ unless currentBoard.isTemplatesBoard
+ unless currentBoard.isTemplateBoard
+ span.quiet
+ | {{_ 'or'}}
+ a.js-swimlane-template {{_ 'template'}}
template(name="setSwimlaneColorPopup")
form.edit-label.swimlane-color-popup
diff --git a/client/components/swimlanes/swimlaneHeader.js b/client/components/swimlanes/swimlaneHeader.js
index 17988f454..c0ef35453 100644
--- a/client/components/swimlanes/swimlaneHeader.js
+++ b/client/components/swimlanes/swimlaneHeader.js
@@ -178,6 +178,11 @@ BlazeComponent.extendComponent({
events() {
return [
{
+ 'submit form'(event) {
+ event.preventDefault();
+ this.currentSwimlane.setColor(this.currentColor.get());
+ Popup.back();
+ },
'click .js-palette-color'() {
this.currentColor.set(this.currentData().color);
},
diff --git a/client/components/swimlanes/swimlanes.css b/client/components/swimlanes/swimlanes.css
index 4c20cb0f4..83540549f 100644
--- a/client/components/swimlanes/swimlanes.css
+++ b/client/components/swimlanes/swimlanes.css
@@ -112,7 +112,7 @@
padding: 7px;
top: 50%;
transform: translateY(-50%);
- left: 87vw;
+ right: 10px;
font-size: 24px;
cursor: move;
z-index: 15;
diff --git a/client/components/swimlanes/swimlanes.jade b/client/components/swimlanes/swimlanes.jade
index 29d8fb62d..25e634573 100644
--- a/client/components/swimlanes/swimlanes.jade
+++ b/client/components/swimlanes/swimlanes.jade
@@ -72,21 +72,23 @@ template(name="addListForm")
| ➕
template(name="moveSwimlanePopup")
- unless currentUser.isWorker
- label {{_ 'boards'}}:
- select.js-select-boards(autofocus)
- each toBoard in toBoards
- option(value="{{toBoard._id}}") {{toBoard.title}}
+ if currentUser
+ unless currentUser.isWorker
+ label {{_ 'boards'}}:
+ select.js-select-boards(autofocus)
+ each toBoard in toBoards
+ option(value="{{toBoard._id}}") {{toBoard.title}}
- .edit-controls.clearfix
- button.primary.confirm.js-done {{_ 'done'}}
+ .edit-controls.clearfix
+ button.primary.confirm.js-done {{_ 'done'}}
template(name="copySwimlanePopup")
- unless currentUser.isWorker
- label {{_ 'boards'}}:
- select.js-select-boards(autofocus)
- each toBoard in toBoards
- option(value="{{toBoard._id}}" selected="{{#if $eq toBoard.title board.title}}1{{/if}}") {{toBoard.title}}
+ if currentUser
+ unless currentUser.isWorker
+ label {{_ 'boards'}}:
+ select.js-select-boards(autofocus)
+ each toBoard in toBoards
+ option(value="{{toBoard._id}}" selected="{{#if $eq toBoard.title board.title}}1{{/if}}") {{toBoard.title}}
- .edit-controls.clearfix
- button.primary.confirm.js-done {{_ 'done'}}
+ .edit-controls.clearfix
+ button.primary.confirm.js-done {{_ 'done'}}
diff --git a/client/components/swimlanes/swimlanes.js b/client/components/swimlanes/swimlanes.js
index dc149c48c..e0dd896d5 100644
--- a/client/components/swimlanes/swimlanes.js
+++ b/client/components/swimlanes/swimlanes.js
@@ -1,6 +1,9 @@
import { ReactiveCache } from '/imports/reactiveCache';
+import dragscroll from '@wekanteam/dragscroll';
const { calculateIndex } = Utils;
+
+
function currentListIsInThisSwimlane(swimlaneId) {
const currentList = Utils.getCurrentList();
return (
@@ -43,6 +46,18 @@ function currentCardIsInThisList(listId, swimlaneId) {
}
function initSortable(boardComponent, $listsDom) {
+ // Safety check: ensure we have valid DOM elements
+ if (!$listsDom || $listsDom.length === 0) {
+ console.error('initSortable: No valid DOM elements provided');
+ return;
+ }
+
+ // Check if sortable is already initialized
+ if ($listsDom.data('uiSortable') || $listsDom.data('sortable')) {
+ $listsDom.sortable('destroy');
+ }
+
+
// We want to animate the card details window closing. We rely on CSS
// transition for the actual animation.
$listsDom._uihooks = {
@@ -62,18 +77,68 @@ function initSortable(boardComponent, $listsDom) {
},
};
- $listsDom.sortable({
- connectWith: '.js-swimlane, .js-lists',
- tolerance: 'pointer',
- helper: 'clone',
- items: '.js-list:not(.js-list-composer)',
- placeholder: 'js-list placeholder',
- distance: 7,
+
+ // Add click debugging for drag handles
+ $listsDom.on('mousedown', '.js-list-handle', function(e) {
+ e.stopPropagation();
+ });
+
+ $listsDom.on('mousedown', '.js-list-header', function(e) {
+ });
+
+ // Add debugging for any mousedown on lists
+ $listsDom.on('mousedown', '.js-list', function(e) {
+ });
+
+ // Add debugging for sortable events
+ $listsDom.on('sortstart', function(e, ui) {
+ });
+
+ $listsDom.on('sortbeforestop', function(e, ui) {
+ });
+
+ $listsDom.on('sortstop', function(e, ui) {
+ });
+
+ try {
+ $listsDom.sortable({
+ connectWith: '.js-swimlane, .js-lists',
+ tolerance: 'pointer',
+ appendTo: '.board-canvas',
+ helper(evt, item) {
+ const helper = item.clone();
+ helper.css('z-index', 1000);
+ return helper;
+ },
+ items: '.js-list:not(.js-list-composer)',
+ placeholder: 'list placeholder',
+ distance: 3,
+ forcePlaceholderSize: true,
+ cursor: 'move',
start(evt, ui) {
+ ui.helper.css('z-index', 1000);
ui.placeholder.height(ui.helper.height());
ui.placeholder.width(ui.helper.width());
EscapeActions.executeUpTo('popup-close');
boardComponent.setIsDragging(true);
+
+ // Add visual feedback for list being dragged
+ ui.item.addClass('ui-sortable-helper');
+
+ // Disable dragscroll during list dragging to prevent interference
+ try {
+ dragscroll.reset();
+ } catch (e) {
+ }
+
+ // Also disable dragscroll on all swimlanes during list dragging
+ $('.js-swimlane').each(function() {
+ $(this).removeClass('dragscroll');
+ });
+ },
+ beforeStop(evt, ui) {
+ // Clean up visual feedback
+ ui.item.removeClass('ui-sortable-helper');
},
stop(evt, ui) {
// To attribute the new index number, we need to get the DOM element
@@ -83,15 +148,37 @@ function initSortable(boardComponent, $listsDom) {
const sortIndex = calculateIndex(prevListDom, nextListDom, 1);
const listDomElement = ui.item.get(0);
- const list = Blaze.getData(listDomElement);
+ if (!listDomElement) {
+ console.error('List DOM element not found during drag stop');
+ return;
+ }
+
+ let list;
+ try {
+ list = Blaze.getData(listDomElement);
+ } catch (error) {
+ console.error('Error getting list data:', error);
+ return;
+ }
+
+ if (!list) {
+ console.error('List data not found for element:', listDomElement);
+ return;
+ }
// Detect if the list was dropped in a different swimlane
const targetSwimlaneDom = ui.item.closest('.js-swimlane');
let targetSwimlaneId = null;
+
if (targetSwimlaneDom.length > 0) {
// List was dropped in a swimlane
- targetSwimlaneId = targetSwimlaneDom.attr('id').replace('swimlane-', '');
+ try {
+ targetSwimlaneId = targetSwimlaneDom.attr('id').replace('swimlane-', '');
+ } catch (error) {
+ console.error('Error getting target swimlane ID:', error);
+ return;
+ }
} else {
// List was dropped in lists view (not swimlanes view)
// In this case, assign to the default swimlane
@@ -127,9 +214,6 @@ function initSortable(boardComponent, $listsDom) {
// If the list was dropped in a different swimlane, update the swimlaneId
if (isDifferentSwimlane) {
updateData.swimlaneId = targetSwimlaneId;
- if (process.env.DEBUG === 'true') {
- console.log(`Moving list "${list.title}" from swimlane ${originalSwimlaneId} to swimlane ${targetSwimlaneId}`);
- }
// Move all cards in the list to the new swimlane
const cardsInList = ReactiveCache.getCards({
@@ -141,59 +225,103 @@ function initSortable(boardComponent, $listsDom) {
card.move(list.boardId, targetSwimlaneId, list._id);
});
- if (process.env.DEBUG === 'true') {
- console.log(`Moved ${cardsInList.length} cards to swimlane ${targetSwimlaneId}`);
- }
// Don't cancel the sortable when moving to a different swimlane
// The DOM move should be allowed to complete
- } else {
- // If staying in the same swimlane, cancel the sortable to prevent DOM manipulation issues
- $listsDom.sortable('cancel');
+ }
+ // Allow reordering within the same swimlane by not canceling the sortable
+
+ try {
+ Lists.update(list._id, {
+ $set: updateData,
+ });
+ } catch (error) {
+ console.error('Error updating list:', error);
+ return;
}
- Lists.update(list._id, {
- $set: updateData,
- });
-
boardComponent.setIsDragging(false);
+
+ // Re-enable dragscroll after list dragging is complete
+ try {
+ dragscroll.reset();
+ } catch (e) {
+ }
+
+ // Re-enable dragscroll on all swimlanes
+ $('.js-swimlane').each(function() {
+ $(this).addClass('dragscroll');
+ });
},
});
+ } catch (error) {
+ console.error('Error initializing list sortable:', error);
+ return;
+ }
+
+
+ // Check if drag handles exist
+ const dragHandles = $listsDom.find('.js-list-handle');
+
+ // Check if lists exist
+ const lists = $listsDom.find('.js-list');
- boardComponent.autorun(() => {
- if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
- $listsDom.sortable({
- handle: '.js-list-handle',
- connectWith: '.js-swimlane, .js-lists',
- });
- } else {
- $listsDom.sortable({
- handle: '.js-list-header',
- connectWith: '.js-swimlane, .js-lists',
- });
- }
-
- const $listDom = $listsDom;
- if ($listDom.data('uiSortable') || $listDom.data('sortable')) {
- $listsDom.sortable(
- 'option',
- 'disabled',
- !ReactiveCache.getCurrentUser()?.isBoardAdmin(),
- );
- }
- });
+ // Skip the complex autorun and options for now
}
BlazeComponent.extendComponent({
onRendered() {
const boardComponent = this.parentComponent();
const $listsDom = this.$('.js-lists');
+
if (!Utils.getCurrentCardId()) {
boardComponent.scrollLeft();
}
- initSortable(boardComponent, $listsDom);
+ // Try a simpler approach - initialize sortable directly like cards do
+
+ // Wait for DOM to be ready
+ setTimeout(() => {
+ const $lists = this.$('.js-list');
+
+ const $parent = $lists.parent();
+
+ if ($lists.length > 0) {
+
+ // Check for drag handles
+ const $handles = $parent.find('.js-list-handle');
+
+ // Test if drag handles are clickable
+ $handles.on('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ });
+
+ $parent.sortable({
+ connectWith: '.js-swimlane, .js-lists',
+ tolerance: 'pointer',
+ appendTo: '.board-canvas',
+ helper: 'clone',
+ items: '.js-list:not(.js-list-composer)',
+ placeholder: 'list placeholder',
+ distance: 7,
+ handle: '.js-list-handle',
+ disabled: !Utils.canModifyBoard(),
+ start(evt, ui) {
+ ui.helper.css('z-index', 1000);
+ ui.placeholder.height(ui.helper.height());
+ ui.placeholder.width(ui.helper.width());
+ EscapeActions.executeUpTo('popup-close');
+ boardComponent.setIsDragging(true);
+ },
+ stop(evt, ui) {
+ boardComponent.setIsDragging(false);
+ }
+ });
+ } else {
+ }
+ }, 100);
},
onCreated() {
this.draggingActive = new ReactiveVar(false);
@@ -256,7 +384,6 @@ BlazeComponent.extendComponent({
const isInNoDragArea = $(evt.target).closest(noDragInside.join(',')).length > 0;
if (isResizeHandle) {
- console.log('Board drag prevented - resize handle clicked');
return;
}
@@ -294,7 +421,31 @@ BlazeComponent.extendComponent({
swimlaneHeight() {
const user = ReactiveCache.getCurrentUser();
const swimlane = Template.currentData();
- const height = user.getSwimlaneHeightFromStorage(swimlane.boardId, swimlane._id);
+
+ let height;
+ if (user) {
+ // For logged-in users, get from user profile
+ height = user.getSwimlaneHeightFromStorage(swimlane.boardId, swimlane._id);
+ } else {
+ // For non-logged-in users, get from localStorage
+ try {
+ const stored = localStorage.getItem('wekan-swimlane-heights');
+ if (stored) {
+ const heights = JSON.parse(stored);
+ if (heights[swimlane.boardId] && heights[swimlane.boardId][swimlane._id]) {
+ height = heights[swimlane.boardId][swimlane._id];
+ } else {
+ height = -1;
+ }
+ } else {
+ height = -1;
+ }
+ } catch (e) {
+ console.warn('Error reading swimlane height from localStorage:', e);
+ height = -1;
+ }
+ }
+
return height == -1 ? "auto" : (height + 5 + "px");
},
@@ -408,15 +559,36 @@ BlazeComponent.extendComponent({
if (process.env.DEBUG === 'true') {
}
- // Use the new storage method that handles both logged-in and non-logged-in users
- Meteor.call('applySwimlaneHeightToStorage', boardId, swimlaneId, finalHeight, (error, result) => {
- if (error) {
- console.error('Error saving swimlane height:', error);
- } else {
+ const currentUser = ReactiveCache.getCurrentUser();
+ if (currentUser) {
+ // For logged-in users, use server method
+ Meteor.call('applySwimlaneHeightToStorage', boardId, swimlaneId, finalHeight, (error, result) => {
+ if (error) {
+ console.error('Error saving swimlane height:', error);
+ } else {
+ if (process.env.DEBUG === 'true') {
+ }
+ }
+ });
+ } else {
+ // For non-logged-in users, save to localStorage directly
+ try {
+ const stored = localStorage.getItem('wekan-swimlane-heights');
+ let heights = stored ? JSON.parse(stored) : {};
+
+ if (!heights[boardId]) {
+ heights[boardId] = {};
+ }
+ heights[boardId][swimlaneId] = finalHeight;
+
+ localStorage.setItem('wekan-swimlane-heights', JSON.stringify(heights));
+
if (process.env.DEBUG === 'true') {
}
+ } catch (e) {
+ console.warn('Error saving swimlane height to localStorage:', e);
}
- });
+ }
e.preventDefault();
};
@@ -440,6 +612,7 @@ BlazeComponent.extendComponent({
},
}).register('swimlane');
+
BlazeComponent.extendComponent({
onCreated() {
this.currentBoard = Utils.getCurrentBoard();
@@ -507,8 +680,305 @@ Template.swimlane.helpers({
canSeeAddList() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
+
+ lists() {
+ // Return per-swimlane lists for this swimlane
+ return this.myLists();
+ }
});
+// Initialize sortable on DOM elements
+setTimeout(() => {
+ const $swimlaneElements = $('.swimlane');
+ const $listsGroupElements = $('.list-group');
+
+ // Initialize sortable on ALL swimlane elements (even empty ones)
+ $swimlaneElements.each(function(index) {
+ const $swimlane = $(this);
+ const $lists = $swimlane.find('.js-list');
+
+ // Only initialize on swimlanes that have the .js-lists class (the container for lists)
+ if ($swimlane.hasClass('js-lists')) {
+ $swimlane.sortable({
+ connectWith: '.js-swimlane, .js-lists',
+ tolerance: 'pointer',
+ appendTo: '.board-canvas',
+ helper: 'clone',
+ items: '.js-list:not(.js-list-composer)',
+ placeholder: 'list placeholder',
+ distance: 7,
+ handle: '.js-list-handle',
+ disabled: !Utils.canModifyBoard(),
+ start(evt, ui) {
+ ui.helper.css('z-index', 1000);
+ ui.placeholder.height(ui.helper.height());
+ ui.placeholder.width(ui.helper.width());
+ EscapeActions.executeUpTo('popup-close');
+ // Try to get board component
+ try {
+ const boardComponent = BlazeComponent.getComponentForElement(ui.item[0]);
+ if (boardComponent && boardComponent.setIsDragging) {
+ boardComponent.setIsDragging(true);
+ }
+ } catch (e) {
+ // Silent fail
+ }
+ },
+ stop(evt, ui) {
+ // To attribute the new index number, we need to get the DOM element
+ // of the previous and the following list -- if any.
+ const prevListDom = ui.item.prev('.js-list').get(0);
+ const nextListDom = ui.item.next('.js-list').get(0);
+ const sortIndex = calculateIndex(prevListDom, nextListDom, 1);
+
+ const listDomElement = ui.item.get(0);
+ if (!listDomElement) {
+ return;
+ }
+
+ let list;
+ try {
+ list = Blaze.getData(listDomElement);
+ } catch (error) {
+ return;
+ }
+
+ if (!list) {
+ return;
+ }
+
+ // Detect if the list was dropped in a different swimlane
+ const targetSwimlaneDom = ui.item.closest('.js-swimlane');
+ let targetSwimlaneId = null;
+
+ if (targetSwimlaneDom.length > 0) {
+ // List was dropped in a swimlane
+ try {
+ targetSwimlaneId = targetSwimlaneDom.attr('id').replace('swimlane-', '');
+ } catch (error) {
+ return;
+ }
+ } else {
+ // List was dropped in lists view (not swimlanes view)
+ // In this case, assign to the default swimlane
+ const currentBoard = ReactiveCache.getBoard(Session.get('currentBoard'));
+ if (currentBoard) {
+ const defaultSwimlane = currentBoard.getDefaultSwimline();
+ if (defaultSwimlane) {
+ targetSwimlaneId = defaultSwimlane._id;
+ }
+ }
+ }
+
+ // Get the original swimlane ID of the list (handle backward compatibility)
+ const originalSwimlaneId = list.getEffectiveSwimlaneId ? list.getEffectiveSwimlaneId() : (list.swimlaneId || null);
+
+ // Prepare update object
+ const updateData = {
+ sort: sortIndex.base,
+ };
+
+ // Check if the list was dropped in a different swimlane
+ const isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId;
+
+ // If the list was dropped in a different swimlane, update the swimlaneId
+ if (isDifferentSwimlane) {
+ updateData.swimlaneId = targetSwimlaneId;
+
+ // Move all cards in the list to the new swimlane
+ const cardsInList = ReactiveCache.getCards({
+ listId: list._id,
+ archived: false
+ });
+
+ cardsInList.forEach(card => {
+ card.move(list.boardId, targetSwimlaneId, list._id);
+ });
+
+ // Don't cancel the sortable when moving to a different swimlane
+ // The DOM move should be allowed to complete
+ }
+ // Allow reordering within the same swimlane by not canceling the sortable
+
+ try {
+ Lists.update(list._id, {
+ $set: updateData,
+ });
+ } catch (error) {
+ return;
+ }
+
+ // Try to get board component
+ try {
+ const boardComponent = BlazeComponent.getComponentForElement(ui.item[0]);
+ if (boardComponent && boardComponent.setIsDragging) {
+ boardComponent.setIsDragging(false);
+ }
+ } catch (e) {
+ // Silent fail
+ }
+
+ // Re-enable dragscroll after list dragging is complete
+ try {
+ dragscroll.reset();
+ } catch (e) {
+ // Silent fail
+ }
+
+ // Re-enable dragscroll on all swimlanes
+ $('.js-swimlane').each(function() {
+ $(this).addClass('dragscroll');
+ });
+ }
+ });
+ }
+ });
+
+ // Initialize sortable on ALL listsGroup elements (even empty ones)
+ $listsGroupElements.each(function(index) {
+ const $listsGroup = $(this);
+ const $lists = $listsGroup.find('.js-list');
+
+ // Only initialize on listsGroup elements that have the .js-lists class
+ if ($listsGroup.hasClass('js-lists')) {
+ $listsGroup.sortable({
+ connectWith: '.js-swimlane, .js-lists',
+ tolerance: 'pointer',
+ appendTo: '.board-canvas',
+ helper: 'clone',
+ items: '.js-list:not(.js-list-composer)',
+ placeholder: 'list placeholder',
+ distance: 7,
+ handle: '.js-list-handle',
+ disabled: !Utils.canModifyBoard(),
+ start(evt, ui) {
+ ui.helper.css('z-index', 1000);
+ ui.placeholder.height(ui.helper.height());
+ ui.placeholder.width(ui.helper.width());
+ EscapeActions.executeUpTo('popup-close');
+ // Try to get board component
+ try {
+ const boardComponent = BlazeComponent.getComponentForElement(ui.item[0]);
+ if (boardComponent && boardComponent.setIsDragging) {
+ boardComponent.setIsDragging(true);
+ }
+ } catch (e) {
+ // Silent fail
+ }
+ },
+ stop(evt, ui) {
+ // To attribute the new index number, we need to get the DOM element
+ // of the previous and the following list -- if any.
+ const prevListDom = ui.item.prev('.js-list').get(0);
+ const nextListDom = ui.item.next('.js-list').get(0);
+ const sortIndex = calculateIndex(prevListDom, nextListDom, 1);
+
+ const listDomElement = ui.item.get(0);
+ if (!listDomElement) {
+ return;
+ }
+
+ let list;
+ try {
+ list = Blaze.getData(listDomElement);
+ } catch (error) {
+ return;
+ }
+
+ if (!list) {
+ return;
+ }
+
+ // Detect if the list was dropped in a different swimlane
+ const targetSwimlaneDom = ui.item.closest('.js-swimlane');
+ let targetSwimlaneId = null;
+
+ if (targetSwimlaneDom.length > 0) {
+ // List was dropped in a swimlane
+ try {
+ targetSwimlaneId = targetSwimlaneDom.attr('id').replace('swimlane-', '');
+ } catch (error) {
+ return;
+ }
+ } else {
+ // List was dropped in lists view (not swimlanes view)
+ // In this case, assign to the default swimlane
+ const currentBoard = ReactiveCache.getBoard(Session.get('currentBoard'));
+ if (currentBoard) {
+ const defaultSwimlane = currentBoard.getDefaultSwimline();
+ if (defaultSwimlane) {
+ targetSwimlaneId = defaultSwimlane._id;
+ }
+ }
+ }
+
+ // Get the original swimlane ID of the list (handle backward compatibility)
+ const originalSwimlaneId = list.getEffectiveSwimlaneId ? list.getEffectiveSwimlaneId() : (list.swimlaneId || null);
+
+ // Prepare update object
+ const updateData = {
+ sort: sortIndex.base,
+ };
+
+ // Check if the list was dropped in a different swimlane
+ const isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId;
+
+ // If the list was dropped in a different swimlane, update the swimlaneId
+ if (isDifferentSwimlane) {
+ updateData.swimlaneId = targetSwimlaneId;
+
+ // Move all cards in the list to the new swimlane
+ const cardsInList = ReactiveCache.getCards({
+ listId: list._id,
+ archived: false
+ });
+
+ cardsInList.forEach(card => {
+ card.move(list.boardId, targetSwimlaneId, list._id);
+ });
+
+ // Don't cancel the sortable when moving to a different swimlane
+ // The DOM move should be allowed to complete
+ }
+ // Allow reordering within the same swimlane by not canceling the sortable
+
+ try {
+ Lists.update(list._id, {
+ $set: updateData,
+ });
+ } catch (error) {
+ return;
+ }
+
+ // Try to get board component
+ try {
+ const boardComponent = BlazeComponent.getComponentForElement(ui.item[0]);
+ if (boardComponent && boardComponent.setIsDragging) {
+ boardComponent.setIsDragging(false);
+ }
+ } catch (e) {
+ // Silent fail
+ }
+
+ // Re-enable dragscroll after list dragging is complete
+ try {
+ dragscroll.reset();
+ } catch (e) {
+ // Silent fail
+ }
+
+ // Re-enable dragscroll on all swimlanes
+ $('.js-swimlane').each(function() {
+ $(this).addClass('dragscroll');
+ });
+ }
+ });
+ }
+ });
+}, 1000);
+
+
+
BlazeComponent.extendComponent({
currentCardIsInThisList(listId, swimlaneId) {
return currentCardIsInThisList(listId, swimlaneId);
@@ -538,15 +1008,59 @@ BlazeComponent.extendComponent({
onRendered() {
const boardComponent = this.parentComponent();
const $listsDom = this.$('.js-lists');
+
if (!Utils.getCurrentCardId()) {
boardComponent.scrollLeft();
}
- initSortable(boardComponent, $listsDom);
+ // Try a simpler approach for listsGroup too
+
+ // Wait for DOM to be ready
+ setTimeout(() => {
+ const $lists = this.$('.js-list');
+
+ const $parent = $lists.parent();
+
+ if ($lists.length > 0) {
+
+ // Check for drag handles
+ const $handles = $parent.find('.js-list-handle');
+
+ // Test if drag handles are clickable
+ $handles.on('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ });
+
+ $parent.sortable({
+ connectWith: '.js-swimlane, .js-lists',
+ tolerance: 'pointer',
+ appendTo: '.board-canvas',
+ helper: 'clone',
+ items: '.js-list:not(.js-list-composer)',
+ placeholder: 'list placeholder',
+ distance: 7,
+ handle: '.js-list-handle',
+ disabled: !Utils.canModifyBoard(),
+ start(evt, ui) {
+ ui.helper.css('z-index', 1000);
+ ui.placeholder.height(ui.helper.height());
+ ui.placeholder.width(ui.helper.width());
+ EscapeActions.executeUpTo('popup-close');
+ boardComponent.setIsDragging(true);
+ },
+ stop(evt, ui) {
+ boardComponent.setIsDragging(false);
+ }
+ });
+ } else {
+ }
+ }, 100);
},
}).register('listsGroup');
+
class MoveSwimlaneComponent extends BlazeComponent {
serverMethod = 'moveSwimlane';
diff --git a/client/components/users/userAvatar.jade b/client/components/users/userAvatar.jade
index b1bc7e2d4..e00fc188f 100644
--- a/client/components/users/userAvatar.jade
+++ b/client/components/users/userAvatar.jade
@@ -1,7 +1,7 @@
template(name="userAvatar")
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}}" title="{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}")
if userData.profile.avatarUrl
- img.avatar.avatar-image(src="{{userData.profile.avatarUrl}}")
+ img.avatar.avatar-image(src="{{avatarUrl}}")
else
+userAvatarInitials(userId=userData._id)
@@ -87,7 +87,7 @@ template(name="changeAvatarPopup")
each uploadedAvatars
li: a.js-select-avatar
.member
- img.avatar.avatar-image(src="{{link}}?auth=false&brokenIsFine=true")
+ img.avatar.avatar-image(src="{{link}}")
| {{_ 'uploaded-avatar'}}
if isSelected
| ✅
diff --git a/client/components/users/userAvatar.js b/client/components/users/userAvatar.js
index 98ebc901e..f2db90ee3 100644
--- a/client/components/users/userAvatar.js
+++ b/client/components/users/userAvatar.js
@@ -15,6 +15,21 @@ Template.userAvatar.helpers({
});
},
+ avatarUrl() {
+ const user = ReactiveCache.getUser(this.userId, { fields: { profile: 1 } });
+ const base = (user && user.profile && user.profile.avatarUrl) || '';
+ if (!base) return '';
+ // Append current boardId when available so public viewers can access avatars on public boards
+ try {
+ const boardId = Utils.getCurrentBoardId && Utils.getCurrentBoardId();
+ if (boardId) {
+ const sep = base.includes('?') ? '&' : '?';
+ return `${base}${sep}boardId=${encodeURIComponent(boardId)}`;
+ }
+ } catch (_) {}
+ return base;
+ },
+
memberType() {
const user = ReactiveCache.getUser(this.userId);
return user && user.isBoardAdmin() ? 'admin' : 'normal';
@@ -179,7 +194,7 @@ BlazeComponent.extendComponent({
isSelected() {
const userProfile = ReactiveCache.getCurrentUser().profile;
const avatarUrl = userProfile && userProfile.avatarUrl;
- const currentAvatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`;
+ const currentAvatarUrl = this.currentData().link();
return avatarUrl === currentAvatarUrl;
},
@@ -220,7 +235,7 @@ BlazeComponent.extendComponent({
}
},
'click .js-select-avatar'() {
- const avatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`;
+ const avatarUrl = this.currentData().link();
this.setAvatar(avatarUrl);
},
'click .js-select-initials'() {
diff --git a/client/components/users/userHeader.jade b/client/components/users/userHeader.jade
index 8934ddbc4..7ee64d138 100644
--- a/client/components/users/userHeader.jade
+++ b/client/components/users/userHeader.jade
@@ -12,11 +12,6 @@ template(name="headerUserBar")
template(name="memberMenuPopup")
ul.pop-over-list
- // Bookmarks at the very top
- li
- a.js-open-bookmarks
- | 🔖
- | {{_ 'bookmarks'}}
with currentUser
li
a.js-my-cards(href="{{pathFor 'my-cards'}}")
@@ -32,6 +27,7 @@ template(name="memberMenuPopup")
| {{_ 'globalSearch-title'}}
li
a(href="{{pathFor 'home'}}")
+ | 🏠
| 🏠
| {{_ 'all-boards'}}
li
diff --git a/client/config/blazeHelpers.js b/client/config/blazeHelpers.js
index 967b83059..333913dfc 100644
--- a/client/config/blazeHelpers.js
+++ b/client/config/blazeHelpers.js
@@ -73,6 +73,10 @@ Blaze.registerHelper('canModifyCard', () =>
Utils.canModifyCard(),
);
+Blaze.registerHelper('canMoveCard', () =>
+ Utils.canMoveCard(),
+);
+
Blaze.registerHelper('canModifyBoard', () =>
Utils.canModifyBoard(),
);
diff --git a/client/lib/boardMultiSelection.js b/client/lib/boardMultiSelection.js
new file mode 100644
index 000000000..036c312b6
--- /dev/null
+++ b/client/lib/boardMultiSelection.js
@@ -0,0 +1,73 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+
+BoardMultiSelection = {
+ _selectedBoards: new ReactiveVar([]),
+
+ _isActive: new ReactiveVar(false),
+
+ reset() {
+ this._selectedBoards.set([]);
+ },
+
+ isActive() {
+ return this._isActive.get();
+ },
+
+ count() {
+ return this._selectedBoards.get().length;
+ },
+
+ isEmpty() {
+ return this.count() === 0;
+ },
+
+ getSelectedBoardIds() {
+ return this._selectedBoards.get();
+ },
+
+ activate() {
+ if (!this.isActive()) {
+ this._isActive.set(true);
+ Tracker.flush();
+ }
+ },
+
+ disable() {
+ if (this.isActive()) {
+ this._isActive.set(false);
+ this.reset();
+ }
+ },
+
+ add(boardIds) {
+ return this.toggle(boardIds, { add: true, remove: false });
+ },
+
+ remove(boardIds) {
+ return this.toggle(boardIds, { add: false, remove: true });
+ },
+
+ toogle(boardIds) {
+ return this.toggle(boardIds, { add: true, remove: true });
+ },
+
+ toggle(boardIds, { add, remove } = {}) {
+ boardIds = _.isString(boardIds) ? [boardIds] : boardIds;
+ let selectedBoards = this._selectedBoards.get();
+
+ boardIds.forEach(boardId => {
+ const index = selectedBoards.indexOf(boardId);
+ if (index > -1 && remove) {
+ selectedBoards = selectedBoards.filter(id => id !== boardId);
+ } else if (index === -1 && add) {
+ selectedBoards.push(boardId);
+ }
+ });
+
+ this._selectedBoards.set(selectedBoards);
+ },
+
+ isSelected(boardId) {
+ return this._selectedBoards.get().includes(boardId);
+ },
+};
diff --git a/client/lib/cardSearch.js b/client/lib/cardSearch.js
index 4803f815b..a143965cf 100644
--- a/client/lib/cardSearch.js
+++ b/client/lib/cardSearch.js
@@ -29,21 +29,86 @@ export class CardSearchPagedComponent extends BlazeComponent {
const that = this;
this.subscriptionCallbacks = {
onReady() {
- that.getResults();
- that.searching.set(false);
- that.hasResults.set(true);
- that.serverError.set(false);
+ if (process.env.DEBUG === 'true') {
+ console.log('Subscription ready, getting results...');
+ console.log('Subscription ready - sessionId:', that.sessionId);
+ }
+
+ // Wait for session data to be available (with timeout)
+ let waitCount = 0;
+ const maxWaitCount = 50; // 10 seconds max wait
+
+ const waitForSessionData = () => {
+ waitCount++;
+ const sessionData = that.getSessionData();
+ if (process.env.DEBUG === 'true') {
+ console.log('waitForSessionData - attempt', waitCount, 'session data:', sessionData);
+ }
+
+ if (sessionData) {
+ const results = that.getResults();
+ if (process.env.DEBUG === 'true') {
+ console.log('Search results count:', results ? results.length : 0);
+ }
+
+ // If no results and this is a due cards search, try to retry
+ if ((!results || results.length === 0) && that.searchRetryCount !== undefined && that.searchRetryCount < that.maxRetries) {
+ if (process.env.DEBUG === 'true') {
+ console.log('No results found, retrying search...');
+ }
+ that.searchRetryCount++;
+ Meteor.setTimeout(() => {
+ if (that.performSearch) {
+ that.performSearch();
+ }
+ }, 500);
+ return;
+ }
+
+ that.searching.set(false);
+ that.hasResults.set(true);
+ that.serverError.set(false);
+ } else if (waitCount < maxWaitCount) {
+ // Session data not available yet, wait a bit more
+ if (process.env.DEBUG === 'true') {
+ console.log('Session data not available yet, waiting... (attempt', waitCount, 'of', maxWaitCount, ')');
+ }
+ Meteor.setTimeout(waitForSessionData, 200);
+ } else {
+ // Timeout reached, try fallback search
+ if (process.env.DEBUG === 'true') {
+ console.log('Timeout reached waiting for session data, trying fallback search');
+ }
+ const results = that.getResults();
+ if (process.env.DEBUG === 'true') {
+ console.log('Fallback search results count:', results ? results.length : 0);
+ }
+
+ if (results && results.length > 0) {
+ that.searching.set(false);
+ that.hasResults.set(true);
+ that.serverError.set(false);
+ } else {
+ that.searching.set(false);
+ that.hasResults.set(false);
+ that.serverError.set(true);
+ }
+ }
+ };
+
+ // Start waiting for session data
+ Meteor.setTimeout(waitForSessionData, 100);
},
onError(error) {
+ if (process.env.DEBUG === 'true') {
+ console.log('Subscription error:', error);
+ console.log('Error.reason:', error.reason);
+ console.log('Error.message:', error.message);
+ console.log('Error.stack:', error.stack);
+ }
that.searching.set(false);
that.hasResults.set(false);
that.serverError.set(true);
- // eslint-disable-next-line no-console
- //console.log('Error.reason:', error.reason);
- // eslint-disable-next-line no-console
- //console.log('Error.message:', error.message);
- // eslint-disable-next-line no-console
- //console.log('Error.stack:', error.stack);
},
};
}
@@ -62,9 +127,28 @@ export class CardSearchPagedComponent extends BlazeComponent {
}
getSessionData(sessionId) {
- return ReactiveCache.getSessionData({
- sessionId: sessionId || SessionData.getSessionId(),
+ const sessionIdToUse = sessionId || SessionData.getSessionId();
+ if (process.env.DEBUG === 'true') {
+ console.log('getSessionData - looking for sessionId:', sessionIdToUse);
+ }
+
+ // Try using the raw SessionData collection instead of ReactiveCache
+ const sessionData = SessionData.findOne({
+ sessionId: sessionIdToUse,
});
+ if (process.env.DEBUG === 'true') {
+ console.log('getSessionData - found session data (raw):', sessionData);
+ }
+
+ // Also try ReactiveCache for comparison
+ const reactiveSessionData = ReactiveCache.getSessionData({
+ sessionId: sessionIdToUse,
+ });
+ if (process.env.DEBUG === 'true') {
+ console.log('getSessionData - found session data (reactive):', reactiveSessionData);
+ }
+
+ return sessionData || reactiveSessionData;
}
getResults() {
@@ -72,33 +156,87 @@ export class CardSearchPagedComponent extends BlazeComponent {
// console.log('getting results');
this.sessionData = this.getSessionData();
// eslint-disable-next-line no-console
- console.log('session data:', this.sessionData);
+ if (process.env.DEBUG === 'true') {
+ console.log('getResults - sessionId:', this.sessionId);
+ console.log('getResults - session data:', this.sessionData);
+ }
const cards = [];
- this.sessionData.cards.forEach(cardId => {
- cards.push(ReactiveCache.getCard(cardId));
- });
- this.queryErrors = this.sessionData.errors;
+
+ if (this.sessionData && this.sessionData.cards) {
+ if (process.env.DEBUG === 'true') {
+ console.log('getResults - cards array length:', this.sessionData.cards.length);
+ }
+ this.sessionData.cards.forEach(cardId => {
+ const card = ReactiveCache.getCard(cardId);
+ if (process.env.DEBUG === 'true') {
+ console.log('getResults - card:', cardId, card);
+ }
+ cards.push(card);
+ });
+ this.queryErrors = this.sessionData.errors || [];
+ } else {
+ if (process.env.DEBUG === 'true') {
+ console.log('getResults - no sessionData or no cards array, trying direct card search');
+ }
+ // Fallback: try to get cards directly from the client-side collection
+ // Use a more efficient query with limit and sort
+ const selector = {
+ type: 'cardType-card',
+ dueAt: { $exists: true, $nin: [null, ''] }
+ };
+ const options = {
+ sort: { dueAt: 1 }, // Sort by due date ascending (oldest first)
+ limit: 100 // Limit to 100 cards for performance
+ };
+ const allCards = Cards.find(selector, options).fetch();
+ if (process.env.DEBUG === 'true') {
+ console.log('getResults - direct card search found:', allCards ? allCards.length : 0, 'cards');
+ }
+
+ if (allCards && allCards.length > 0) {
+ allCards.forEach(card => {
+ if (card && card._id) {
+ cards.push(card);
+ }
+ });
+ }
+
+ this.queryErrors = [];
+ }
if (this.queryErrors.length) {
// console.log('queryErrors:', this.queryErrorMessages());
this.hasQueryErrors.set(true);
// return null;
}
- this.debug.set(new QueryDebug(this.sessionData.debug));
- console.log('debug:', this.debug.get().get());
- console.log('debug.show():', this.debug.get().show());
- console.log('debug.showSelector():', this.debug.get().showSelector());
+ this.debug.set(new QueryDebug(this.sessionData ? this.sessionData.debug : null));
+ if (process.env.DEBUG === 'true') {
+ console.log('debug:', this.debug.get().get());
+ console.log('debug.show():', this.debug.get().show());
+ console.log('debug.showSelector():', this.debug.get().showSelector());
+ }
if (cards) {
- this.totalHits = this.sessionData.totalHits;
- this.resultsCount = cards.length;
- this.resultsStart = this.sessionData.lastHit - this.resultsCount + 1;
- this.resultsEnd = this.sessionData.lastHit;
- this.resultsHeading.set(this.getResultsHeading());
- this.results.set(cards);
- this.hasNextPage.set(this.sessionData.lastHit < this.sessionData.totalHits);
- this.hasPreviousPage.set(
- this.sessionData.lastHit - this.sessionData.resultsCount > 0,
- );
+ if (this.sessionData) {
+ this.totalHits = this.sessionData.totalHits || 0;
+ this.resultsCount = cards.length;
+ this.resultsStart = this.sessionData.lastHit - this.resultsCount + 1;
+ this.resultsEnd = this.sessionData.lastHit;
+ this.resultsHeading.set(this.getResultsHeading());
+ this.results.set(cards);
+ this.hasNextPage.set(this.sessionData.lastHit < this.sessionData.totalHits);
+ this.hasPreviousPage.set(
+ this.sessionData.lastHit - this.sessionData.resultsCount > 0,
+ );
+ } else {
+ this.totalHits = cards.length;
+ this.resultsCount = cards.length;
+ this.resultsStart = 1;
+ this.resultsEnd = cards.length;
+ this.resultsHeading.set(this.getResultsHeading());
+ this.results.set(cards);
+ this.hasNextPage.set(false);
+ this.hasPreviousPage.set(false);
+ }
return cards;
}
@@ -113,13 +251,29 @@ export class CardSearchPagedComponent extends BlazeComponent {
}
getSubscription(queryParams) {
- return Meteor.subscribe(
+ if (process.env.DEBUG === 'true') {
+ console.log('Subscribing to globalSearch with:', {
+ sessionId: this.sessionId,
+ params: queryParams.params,
+ text: queryParams.text
+ });
+ }
+
+ // Subscribe to both globalSearch and sessionData
+ const globalSearchHandle = Meteor.subscribe(
'globalSearch',
this.sessionId,
queryParams.params,
queryParams.text,
this.subscriptionCallbacks,
);
+
+ const sessionDataHandle = Meteor.subscribe('sessionData', this.sessionId);
+ if (process.env.DEBUG === 'true') {
+ console.log('Subscribed to sessionData with sessionId:', this.sessionId);
+ }
+
+ return globalSearchHandle;
}
runGlobalSearch(queryParams) {
diff --git a/client/lib/datepicker.js b/client/lib/datepicker.js
index 08b15b2eb..fa2ff8129 100644
--- a/client/lib/datepicker.js
+++ b/client/lib/datepicker.js
@@ -1,32 +1,27 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
-import {
- formatDateTime,
- formatDate,
- formatTime,
- getISOWeek,
- isValidDate,
- isBefore,
- isAfter,
- isSame,
- add,
- subtract,
- startOf,
- endOf,
- format,
- parseDate,
- now,
- createDate,
- fromNow,
- calendar
-} from '/imports/lib/dateUtils';
-// Helper function to get time format for 24 hours
-function adjustedTimeFormat() {
- return 'HH:mm';
+// Helper to check if a date is valid
+function isValidDate(date) {
+ return date instanceof Date && !isNaN(date);
}
-// .replace(/HH/i, 'H');
+// Format date as YYYY-MM-DD
+function formatDate(date) {
+ if (!isValidDate(date)) return '';
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+}
+
+// Format time as HH:mm
+function formatTime(date) {
+ if (!isValidDate(date)) return '';
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ return `${hours}:${minutes}`;
+}
export class DatePicker extends BlazeComponent {
template() {
@@ -76,10 +71,10 @@ export class DatePicker extends BlazeComponent {
return '';
}
dateFormat() {
- return 'L';
+ return 'YYYY-MM-DD';
}
timeFormat() {
- return 'LT';
+ return 'HH:mm';
}
events() {
@@ -89,7 +84,8 @@ export class DatePicker extends BlazeComponent {
// Native HTML date input validation
const dateValue = this.find('#date').value;
if (dateValue) {
- const dateObj = new Date(dateValue);
+ // HTML date input format is always YYYY-MM-DD
+ const dateObj = new Date(dateValue + 'T12:00:00');
if (isValidDate(dateObj)) {
this.error.set('');
} else {
@@ -101,7 +97,8 @@ export class DatePicker extends BlazeComponent {
// Native HTML time input validation
const timeValue = this.find('#time').value;
if (timeValue) {
- const timeObj = new Date(`1970-01-01T${timeValue}`);
+ // HTML time input format is always HH:mm
+ const timeObj = new Date(`1970-01-01T${timeValue}:00`);
if (isValidDate(timeObj)) {
this.error.set('');
} else {
@@ -121,7 +118,9 @@ export class DatePicker extends BlazeComponent {
return;
}
- const newCompleteDate = new Date(`${dateValue}T${timeValue}`);
+ // Combine date and time: HTML date input is YYYY-MM-DD, time input is HH:mm
+ const dateTimeString = `${dateValue}T${timeValue}:00`;
+ const newCompleteDate = new Date(dateTimeString);
if (!isValidDate(newCompleteDate)) {
this.error.set('invalid');
diff --git a/client/lib/fixDuplicateLists.js b/client/lib/fixDuplicateLists.js
new file mode 100644
index 000000000..06faefbdc
--- /dev/null
+++ b/client/lib/fixDuplicateLists.js
@@ -0,0 +1,94 @@
+import { Meteor } from 'meteor/meteor';
+
+/**
+ * Client-side interface for fixing duplicate lists
+ */
+export const fixDuplicateLists = {
+
+ /**
+ * Get a report of all boards with duplicate lists/swimlanes
+ */
+ async getReport() {
+ try {
+ const result = await Meteor.callAsync('fixDuplicateLists.getReport');
+ return result;
+ } catch (error) {
+ console.error('Error getting duplicate lists report:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Fix duplicate lists for a specific board
+ */
+ async fixBoard(boardId) {
+ try {
+ const result = await Meteor.callAsync('fixDuplicateLists.fixBoard', boardId);
+ console.log(`Fixed duplicate lists for board ${boardId}:`, result);
+ return result;
+ } catch (error) {
+ console.error(`Error fixing board ${boardId}:`, error);
+ throw error;
+ }
+ },
+
+ /**
+ * Fix duplicate lists for all boards
+ */
+ async fixAllBoards() {
+ try {
+ console.log('Starting fix for all boards...');
+ const result = await Meteor.callAsync('fixDuplicateLists.fixAllBoards');
+ console.log('Fix completed:', result);
+ return result;
+ } catch (error) {
+ console.error('Error fixing all boards:', error);
+ throw error;
+ }
+ },
+
+ /**
+ * Interactive fix with user confirmation
+ */
+ async interactiveFix() {
+ try {
+ // Get report first
+ console.log('Getting duplicate lists report...');
+ const report = await this.getReport();
+
+ if (report.boardsWithDuplicates === 0) {
+ console.log('No duplicate lists found!');
+ return { message: 'No duplicate lists found!' };
+ }
+
+ console.log(`Found ${report.boardsWithDuplicates} boards with duplicate lists:`);
+ report.report.forEach(board => {
+ console.log(`- Board "${board.boardTitle}" (${board.boardId}): ${board.duplicateSwimlanes} duplicate swimlanes, ${board.duplicateLists} duplicate lists`);
+ });
+
+ // Ask for confirmation
+ const confirmed = confirm(
+ `Found ${report.boardsWithDuplicates} boards with duplicate lists. ` +
+ `This will fix ${report.report.reduce((sum, board) => sum + board.duplicateSwimlanes + board.duplicateLists, 0)} duplicates. ` +
+ 'Continue?'
+ );
+
+ if (!confirmed) {
+ return { message: 'Fix cancelled by user' };
+ }
+
+ // Perform the fix
+ const result = await this.fixAllBoards();
+ return result;
+ } catch (error) {
+ console.error('Error in interactive fix:', error);
+ throw error;
+ }
+ }
+};
+
+// Make it available globally for console access
+if (typeof window !== 'undefined') {
+ window.fixDuplicateLists = fixDuplicateLists;
+}
+
diff --git a/client/lib/migrationManager.js b/client/lib/migrationManager.js
index c178b69e7..19ea53f10 100644
--- a/client/lib/migrationManager.js
+++ b/client/lib/migrationManager.js
@@ -646,6 +646,23 @@ class MigrationManager {
}
}
+ /**
+ * Fix boards that are stuck in migration loop
+ */
+ fixStuckBoards() {
+ try {
+ Meteor.call('boardMigration.fixStuckBoards', (error, result) => {
+ if (error) {
+ console.error('Failed to fix stuck boards:', error);
+ } else {
+ console.log('Fix stuck boards result:', result);
+ }
+ });
+ } catch (error) {
+ console.error('Error fixing stuck boards:', error);
+ }
+ }
+
/**
* Start migration process using cron system
*/
diff --git a/client/lib/popup.js b/client/lib/popup.js
index 5cc5ec27c..6825f7032 100644
--- a/client/lib/popup.js
+++ b/client/lib/popup.js
@@ -101,6 +101,10 @@ window.Popup = new (class {
// our internal dependency, and since we just changed the top element of
// our internal stack, the popup will be updated with the new data.
if (!self.isOpen()) {
+ if (!Template[popupName]) {
+ console.error('Template not found:', popupName);
+ return;
+ }
self.current = Blaze.renderWithData(
self.template,
() => {
@@ -214,6 +218,22 @@ window.Popup = new (class {
const viewportHeight = $(window).height();
const popupWidth = Math.min(380, viewportWidth * 0.55) + 15; // Add 15px for margin
+ // Check if this is an admin panel edit popup
+ const isAdminEditPopup = $element.hasClass('edit-user') ||
+ $element.hasClass('edit-org') ||
+ $element.hasClass('edit-team');
+
+ if (isAdminEditPopup) {
+ // Center the popup horizontally and use full height
+ const centeredLeft = (viewportWidth - popupWidth) / 2;
+
+ return {
+ left: Math.max(10, centeredLeft), // Ensure popup doesn't go off screen
+ top: 10, // Start from top with small margin
+ maxHeight: viewportHeight - 20, // Use full height minus small margins
+ };
+ }
+
// Calculate available height for popup
const popupTop = offset.top + $element.outerHeight();
diff --git a/client/lib/secureDOMPurify.js b/client/lib/secureDOMPurify.js
index c4e352e87..323c3b6c0 100644
--- a/client/lib/secureDOMPurify.js
+++ b/client/lib/secureDOMPurify.js
@@ -3,43 +3,25 @@ import DOMPurify from 'dompurify';
// Centralized secure DOMPurify configuration to prevent XSS and CSS injection attacks
export function getSecureDOMPurifyConfig() {
return {
- // Block dangerous elements that can cause XSS and CSS injection
- FORBID_TAGS: [
- 'svg', 'defs', 'use', 'g', 'symbol', 'marker', 'pattern', 'mask', 'clipPath',
- 'linearGradient', 'radialGradient', 'stop', 'animate', 'animateTransform',
- 'animateMotion', 'set', 'switch', 'foreignObject', 'script', 'style', 'link',
- 'meta', 'iframe', 'object', 'embed', 'applet', 'form', 'input', 'textarea',
- 'select', 'option', 'button', 'label', 'fieldset', 'legend', 'frameset',
- 'frame', 'noframes', 'base', 'basefont', 'isindex', 'dir', 'menu', 'menuitem'
- ],
- // Block dangerous attributes that can cause XSS and CSS injection
- FORBID_ATTR: [
- 'xlink:href', 'href', 'onload', 'onerror', 'onclick', 'onmouseover',
- 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset', 'onselect',
- 'onunload', 'onresize', 'onscroll', 'onkeydown', 'onkeyup', 'onkeypress',
- 'onmousedown', 'onmouseup', 'onmouseover', 'onmouseout', 'onmousemove',
- 'ondblclick', 'oncontextmenu', 'onwheel', 'ontouchstart', 'ontouchend',
- 'ontouchmove', 'ontouchcancel', 'onabort', 'oncanplay', 'oncanplaythrough',
- 'ondurationchange', 'onemptied', 'onended', 'onerror', 'onloadeddata',
- 'onloadedmetadata', 'onloadstart', 'onpause', 'onplay', 'onplaying',
- 'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled',
- 'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'onbeforeunload',
- 'onhashchange', 'onpagehide', 'onpageshow', 'onpopstate', 'onstorage',
- 'onunload', 'style', 'class', 'id', 'data-*', 'aria-*'
- ],
- // Allow only safe image formats and protocols
+ // Allow common markdown elements including anchor tags
+ ALLOWED_TAGS: ['a', 'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'div', 'span', 's'],
+ // Allow safe attributes including href for anchor tags
+ ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'width', 'height', 'target', 'rel'],
+ // Allow safe protocols for links
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
- // Remove dangerous protocols
+ // Allow unknown protocols but be cautious
ALLOW_UNKNOWN_PROTOCOLS: false,
- // Sanitize URLs to prevent malicious content loading
+ // Sanitize DOM for security
SANITIZE_DOM: true,
- // Remove dangerous elements completely
- KEEP_CONTENT: false,
- // Additional security measures
- ADD_ATTR: [],
+ // Keep content but sanitize it
+ KEEP_CONTENT: true,
+ // Block dangerous elements that can cause XSS
+ FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'applet', 'svg', 'defs', 'use', 'g', 'symbol', 'marker', 'pattern', 'mask', 'clipPath', 'linearGradient', 'radialGradient', 'stop', 'animate', 'animateTransform', 'animateMotion', 'set', 'switch', 'foreignObject', 'link', 'meta', 'form', 'input', 'textarea', 'select', 'option', 'button', 'label', 'fieldset', 'legend', 'frameset', 'frame', 'noframes', 'base', 'basefont', 'isindex', 'dir', 'menu', 'menuitem'],
+ // Block dangerous attributes but allow safe href
+ FORBID_ATTR: ['xlink:href', 'onload', 'onerror', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset', 'onselect', 'onunload', 'onresize', 'onscroll', 'onkeydown', 'onkeyup', 'onkeypress', 'onmousedown', 'onmouseup', 'onmouseover', 'onmouseout', 'onmousemove', 'ondblclick', 'oncontextmenu', 'onwheel', 'ontouchstart', 'ontouchend', 'ontouchmove', 'ontouchcancel', 'onabort', 'oncanplay', 'oncanplaythrough', 'ondurationchange', 'onemptied', 'onended', 'onerror', 'onloadeddata', 'onloadedmetadata', 'onloadstart', 'onpause', 'onplay', 'onplaying', 'onprogress', 'onratechange', 'onseeked', 'onseeking', 'onstalled', 'onsuspend', 'ontimeupdate', 'onvolumechange', 'onwaiting', 'onbeforeunload', 'onhashchange', 'onpagehide', 'onpageshow', 'onpopstate', 'onstorage', 'onunload', 'style', 'class', 'id', 'data-*', 'aria-*'],
// Block data URIs that could contain malicious content
ALLOW_DATA_ATTR: false,
- // Custom hook to further sanitize content
+ // Custom hooks for additional security
HOOKS: {
uponSanitizeElement: function(node, data) {
// Block any remaining dangerous elements
@@ -51,14 +33,37 @@ export function getSecureDOMPurifyConfig() {
return false;
}
- // Block img tags with SVG data URIs
+ // Block img tags with SVG data URIs that could contain malicious JavaScript
if (node.tagName && node.tagName.toLowerCase() === 'img') {
const src = node.getAttribute('src');
- if (src && (src.startsWith('data:image/svg') || src.endsWith('.svg'))) {
- if (process.env.DEBUG === 'true') {
- console.warn('Blocked potentially malicious SVG image:', src);
+ if (src) {
+ // Block all SVG data URIs to prevent XSS via embedded JavaScript
+ if (src.startsWith('data:image/svg') || src.endsWith('.svg')) {
+ if (process.env.DEBUG === 'true') {
+ console.warn('Blocked potentially malicious SVG image:', src);
+ }
+ return false;
+ }
+
+ // Additional check for base64 encoded SVG with script tags
+ if (src.startsWith('data:image/svg+xml;base64,')) {
+ try {
+ const base64Content = src.split(',')[1];
+ const decodedContent = atob(base64Content);
+ if (decodedContent.includes('