From 58f4884ad603e4f8c68a8819dfb1440234da70b6 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Tue, 23 Dec 2025 06:47:02 +0200 Subject: [PATCH] Collapse Swimlane, List, Opened Card. Opened Card window X and Y position can be moved freely from drag handle. Fix some dragging not possible. Fix iPhone Safari. Thanks to xet7 ! Fixes #6040, fixes #6027, fixes #6021, fixes #6002 --- client/00-startup.js | 8 + client/components/boards/boardBody.css | 53 +++ client/components/boards/boardBody.jade | 4 + client/components/boards/boardBody.js | 19 + client/components/boards/boardsList.css | 28 +- client/components/cards/cardCustomFields.jade | 5 +- client/components/cards/cardCustomFields.js | 1 + client/components/cards/cardDetails.css | 254 +++++++++++++- client/components/cards/cardDetails.jade | 33 +- client/components/cards/cardDetails.js | 123 ++++++- client/components/cards/checklists.css | 52 ++- client/components/cards/checklists.jade | 5 +- client/components/cards/checklists.js | 3 +- client/components/cards/subtasks.css | 28 ++ client/components/cards/subtasks.jade | 4 +- client/components/cards/subtasks.js | 14 +- client/components/forms/forms.css | 7 + client/components/lists/list.css | 107 ++++-- client/components/lists/list.jade | 5 +- client/components/lists/list.js | 14 +- client/components/lists/listHeader.jade | 42 +-- client/components/lists/listHeader.js | 7 +- client/components/main/header.css | 16 +- client/components/main/layouts.css | 55 +++ client/components/main/layouts.jade | 4 +- client/components/main/popup.css | 29 ++ client/components/settings/settingBody.css | 9 +- client/components/sidebar/sidebar.css | 13 + .../components/swimlanes/swimlaneHeader.jade | 5 + client/components/swimlanes/swimlaneHeader.js | 11 +- client/components/swimlanes/swimlanes.css | 23 ++ client/components/swimlanes/swimlanes.js | 53 ++- client/lib/utils.js | 127 ++++++- config/router.js | 10 + models/lists.js | 17 + models/swimlanes.js | 15 + models/users.js | 324 +++++++++++++++++- 37 files changed, 1415 insertions(+), 112 deletions(-) diff --git a/client/00-startup.js b/client/00-startup.js index 3230b3a6b..d59ea3afe 100644 --- a/client/00-startup.js +++ b/client/00-startup.js @@ -70,4 +70,12 @@ Meteor.startup(() => { Meteor.subscribe('userGreyIcons'); } }); + + // Initialize mobile mode on startup for iOS devices + // This ensures mobile mode is applied correctly on page load + Tracker.afterFlush(() => { + if (typeof Utils !== 'undefined' && Utils.initializeUserSettings) { + Utils.initializeUserSettings(); + } + }); }); diff --git a/client/components/boards/boardBody.css b/client/components/boards/boardBody.css index f65cbaffc..b23d7f4d8 100644 --- a/client/components/boards/boardBody.css +++ b/client/components/boards/boardBody.css @@ -231,6 +231,30 @@ font-size: 1em !important; /* Keep original icon size */ } +/* Mobile iPhone: scale card details text and icons to 2x */ +body.mobile-mode.iphone-device .card-details { + font-size: 2em !important; +} +body.mobile-mode.iphone-device .card-details .fa, +body.mobile-mode.iphone-device .card-details .icon, +body.mobile-mode.iphone-device .card-details i, +body.mobile-mode.iphone-device .card-details .emoji-icon, +body.mobile-mode.iphone-device .card-details a, +body.mobile-mode.iphone-device .card-details p, +body.mobile-mode.iphone-device .card-details span, +body.mobile-mode.iphone-device .card-details div, +body.mobile-mode.iphone-device .card-details button, +body.mobile-mode.iphone-device .card-details input, +body.mobile-mode.iphone-device .card-details select, +body.mobile-mode.iphone-device .card-details textarea { + font-size: inherit !important; +} +/* Section titles slightly larger than content but not as big as card title */ +body.mobile-mode.iphone-device .card-details .card-details-item-title { + font-size: 1.1em !important; + font-weight: bold; +} + /* Ensure scrollbars are positioned correctly */ #content[style*="overflow-x: auto"]::-webkit-scrollbar:vertical { width: 12px; @@ -263,6 +287,35 @@ animation: fadeIn 0.2s; z-index: 16; } + +/* Fix for mobile Safari: ensure overlay stays behind card details */ +@media screen and (max-width: 800px) { + .board-wrapper .board-canvas .board-overlay { + z-index: 17 !important; + } + + /* In desktop mode on small screens, still keep overlay behind card */ + body.desktop-mode .board-wrapper .board-canvas .board-overlay { + z-index: 17 !important; + } +} + +/* In mobile mode, lower the overlay z-index to stay behind card details */ +body.mobile-mode .board-wrapper .board-canvas .board-overlay { + z-index: 17 !important; +} + +/* iPhone in desktop mode: remove overlay to avoid blocking card */ +body.desktop-mode.iphone-device .board-wrapper .board-canvas .board-overlay { + display: none !important; + pointer-events: none !important; +} + +/* Desktop mode: hide overlay to allow multiple cards and board interaction */ +body.desktop-mode .board-wrapper .board-canvas .board-overlay { + display: none !important; + pointer-events: none !important; +} .board-wrapper .board-canvas.is-dragging-active .open-minicard-composer, .board-wrapper .board-canvas.is-dragging-active .minicard-wrapper.is-checked { display: none; diff --git a/client/components/boards/boardBody.jade b/client/components/boards/boardBody.jade index ce29541f6..3f6e9dcb5 100644 --- a/client/components/boards/boardBody.jade +++ b/client/components/boards/boardBody.jade @@ -58,6 +58,10 @@ template(name="boardBody") +swimlane(this) else +listsGroup(currentBoard) + //- Render multiple open cards in desktop mode + unless isMiniScreen + each openCards + +cardDetails(this cardIndex=@index) +sidebar template(name="calendarView") diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index 18ff7dc59..8070f3019 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -516,6 +516,16 @@ BlazeComponent.extendComponent({ return isMiniScreen && currentCardId; }, + openCards() { + // In desktop mode, return array of all open cards + const isMobile = Utils.getMobileMode(); + if (!isMobile) { + const openCardIds = Session.get('openCards') || []; + return openCardIds.map(id => ReactiveCache.getCard(id)).filter(card => card); + } + return []; + }, + goHome() { FlowRouter.go('home'); }, @@ -1642,6 +1652,15 @@ BlazeComponent.extendComponent({ // Open card the same way as clicking a minicard - set currentCard session // This shows the full card details overlay, not a popup + // In desktop mode, add to openCards array to support multiple cards + const isMobile = Utils.getMobileMode(); + if (!isMobile) { + const openCards = Session.get('openCards') || []; + if (!openCards.includes(cardId)) { + openCards.push(cardId); + Session.set('openCards', openCards); + } + } Session.set('currentCard', cardId); }); }); diff --git a/client/components/boards/boardsList.css b/client/components/boards/boardsList.css index d85299078..e0f716932 100644 --- a/client/components/boards/boardsList.css +++ b/client/components/boards/boardsList.css @@ -583,9 +583,9 @@ } .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); + background: #3cb500; + border-color: #3cb500; + box-shadow: 0 2px 8px rgba(60, 181, 0, 0.6); width: 24px !important; height: 24px !important; top: auto !important; @@ -601,10 +601,22 @@ font-weight: bold; } +/* Grey checkboxes when grey icons setting is enabled */ +body.grey-icons-enabled .board-list .board-list-item .multi-selection-checkbox.is-checked { + background: #7a7a7a; + border-color: #7a7a7a; + box-shadow: 0 2px 8px rgba(122, 122, 122, 0.6); +} + +body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checked { + outline: 4px solid #7a7a7a; + box-shadow: 0 4px 12px rgba(122, 122, 122, 0.4); +} + .board-list.is-multiselection-active .js-board.is-checked { - outline: 4px solid #2196F3; + outline: 4px solid #3cb500; outline-offset: -4px; - box-shadow: 0 4px 12px rgba(33, 150, 243, 0.4); + box-shadow: 0 4px 12px rgba(60, 181, 0, 0.4); } /* Visual hint when multiselection is active */ @@ -645,7 +657,11 @@ } .board-backgrounds-list .board-background-select .background-box i.fa-check { font-size: 25px; - color: #fff; + color: #3cb500; +} +/* Grey check icons when grey icons setting is enabled */ +body.grey-icons-enabled .board-backgrounds-list .board-background-select .background-box i.fa-check { + color: #7a7a7a; } /* Prevent Grey Icons from affecting checkmarks in background color list */ diff --git a/client/components/cards/cardCustomFields.jade b/client/components/cards/cardCustomFields.jade index 35afa772b..5534c9c77 100644 --- a/client/components/cards/cardCustomFields.jade +++ b/client/components/cards/cardCustomFields.jade @@ -55,10 +55,9 @@ template(name="cardCustomField-number") template(name="cardCustomField-checkbox") .js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}") if canModifyCard - .check-box-container - .check-box.materialCheckBox(class="{{#if data.value }}is-checked{{/if}}") + span.check-box-unicode {{#if data.value }}✅{{else}}⬜{{/if}} else - .materialCheckBox(class="{{#if data.value }}is-checked{{/if}}") + span.check-box-unicode {{#if data.value }}✅{{else}}⬜{{/if}} template(name="cardCustomField-currency") if canModifyCard diff --git a/client/components/cards/cardCustomFields.js b/client/components/cards/cardCustomFields.js index 82c025503..f519e4d3c 100644 --- a/client/components/cards/cardCustomFields.js +++ b/client/components/cards/cardCustomFields.js @@ -112,6 +112,7 @@ CardCustomField.register('cardCustomField'); events() { return [ { + 'click .js-checklist-item .check-box-unicode': this.toggleItem, 'click .js-checklist-item .check-box-container': this.toggleItem, }, ]; diff --git a/client/components/cards/cardDetails.css b/client/components/cards/cardDetails.css index 14a25d4f2..f89019cf8 100644 --- a/client/components/cards/cardDetails.css +++ b/client/components/cards/cardDetails.css @@ -118,6 +118,65 @@ transition: flex-basis 0.1s; box-sizing: border-box; } + +/* Desktop mode: position card below board header */ +body.desktop-mode .card-details:not(.card-details-popup) { + position: fixed; + width: auto; + max-width: 800px; + flex-basis: auto; + border-radius: 8px; + z-index: 100; +} + +/* Default position for first card or when dragged */ +body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) { + top: 50px; + left: 20px; + right: 20px; + bottom: 20px; +} + +/* Stagger positions for multiple cards using nth-of-type */ +body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) { + top: 50px; + left: 20px; +} +body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) { + top: 80px; + left: 50px; +} +body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) { + top: 110px; + left: 80px; +} +body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) { + top: 140px; + left: 110px; +} +body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) { + top: 170px; + left: 140px; +} + +/* For expanded cards, set dimensions */ +body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) { + right: 20px; + bottom: 20px; +} + +/* Collapsed card state - hide content and set height to title row only */ +.card-details.card-details-collapsed .card-details-canvas > *:not(.card-details-header) { + display: none; +} +.card-details.card-details-collapsed { + height: auto !important; + bottom: auto !important; + overflow: visible; +} +body.desktop-mode .card-details.card-details-collapsed { + bottom: auto !important; +} .card-details .mCustomScrollBox { padding-left: 0; } @@ -139,6 +198,49 @@ display: inline-block; margin-right: 5px; } + +/* Collapse toggle triangle */ +.card-details .card-details-header .card-collapse-toggle { + float: left; + font-size: 20px; + padding: 7px 10px; + margin-left: -10px; + margin-right: 5px; + cursor: pointer; + user-select: none; + color: #000; +} + +/* Bring to front / Send to back buttons */ +.card-details .card-details-header .card-bring-to-front, +.card-details .card-details-header .card-send-to-back { + float: right; + font-size: 18px; + padding: 7px 8px; + margin-right: 5px; + cursor: pointer; + user-select: none; + color: #333; +} + +.card-details .card-details-header .card-bring-to-front:hover, +.card-details .card-details-header .card-send-to-back:hover { + color: #000; + background: rgba(0,0,0,0.05); + border-radius: 3px; +} + +/* Drag handle */ +.card-details .card-details-header .card-drag-handle { + font-size: 20px; + padding: 8px 10px; + margin-right: 10px; + cursor: move; + user-select: none; + display: inline-block; + float: right; +} + .card-details .card-details-header .close-card-details, .card-details .card-details-header .maximize-card-details, .card-details .card-details-header .minimize-card-details, @@ -156,11 +258,16 @@ font-size: 24px; padding: 5px 10px 5px 10px; margin-right: -8px; + cursor: pointer; + user-select: none; } -.card-details .card-details-header .close-card-details-mobile-web { +.card-details .card-details-header .close-card-details-mobile-web, +.card-details .card-details-header .card-mobile-desktop-toggle { font-size: 24px; padding: 5px; - margin-right: 40px; + margin-right: 5px; + cursor: pointer; + user-select: none; } .card-details .card-details-header .card-copy-button { font-size: 17px; @@ -181,6 +288,36 @@ padding: 10px; margin-right: 30px; } +.card-details .card-details-header .card-mobile-desktop-toggle, +.card-details .card-details-header .card-zoom-in, +.card-details .card-details-header .card-zoom-out { + font-size: 24px; + padding: 5px 10px 5px 10px; + margin-right: 5px; + cursor: pointer; + user-select: none; + float: right; +} + +/* Unify all card text to match title size */ +.card-details { + font-size: 1em; +} +.card-details p, +.card-details span, +.card-details div, +.card-details a, +.card-details label, +.card-details input, +.card-details textarea, +.card-details select, +.card-details button, +.card-details .card-details-item-title, +.card-details .card-label, +.card-details .viewer { + font-size: inherit; + line-height: 1.4; +} .card-details .card-details-header .card-details-watch { font-size: 17px; padding-left: 7px; @@ -284,6 +421,19 @@ position: fixed; resize: both; } + + /* Override for mobile mode even on larger screens */ + body.mobile-mode .card-details { + width: 100vw !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + height: 100vh !important; + max-height: 100vh !important; + resize: none !important; + } + .card-details-maximized { padding: 0; flex-shrink: 0; @@ -335,19 +485,53 @@ input[type="submit"].attachment-add-link-submit { } @media screen and (max-width: 800px) { .card-details { - width: calc(100% - 1px); - padding: 0px 20px 0px 20px; - margin: 0px; + width: 100% !important; + padding: 0px 0px 0px 0px !important; + margin: 0px !important; transition: none; - overflow-y: revert; - overflow-x: revert; + overflow-y: auto; + overflow-x: hidden; + /* iOS Safari specific fixes */ + -webkit-overflow-scrolling: touch; + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + z-index: 100 !important; + height: 100vh !important; + max-height: 100vh !important; + border-radius: 0 !important; + box-shadow: none !important; + } + + /* Ensure card details are above everything on mobile */ + body.mobile-mode .card-details { + z-index: 100 !important; + width: 100vw !important; + left: 0 !important; + right: 0 !important; } .card-details .card-details-canvas { width: 100%; padding-left: 0px; + padding: 0 15px; } .card-details .card-details-header .close-card-details { margin-right: 0px; + display: block !important; + } + .card-details .card-details-header .close-card-details-mobile-web { + display: block !important; + margin-right: 5px !important; + } + .card-details .card-details-header .card-mobile-desktop-toggle { + display: block !important; + margin-right: 5px !important; + } + .card-details .card-details-header .card-mobile-desktop-toggle { + display: block !important; + margin-right: 5px !important; } .card-details .card-details-header .card-details-menu { margin-right: 40px; @@ -373,6 +557,62 @@ input[type="submit"].attachment-add-link-submit { .pop-over > .content-wrapper > .popup-container-depth-0 .card-details-header { margin: 0; } + /* iPhone mobile: enlarge header buttons and increase spacing */ + body.mobile-mode.iphone-device .card-details .card-details-header { + padding-right: 16px; + } + body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details, + body.mobile-mode.iphone-device .card-details .card-details-header .maximize-card-details, + body.mobile-mode.iphone-device .card-details .card-details-header .minimize-card-details, + body.mobile-mode.iphone-device .card-details .card-details-header .card-details-menu-mobile-web, + body.mobile-mode.iphone-device .card-details .card-details-header .card-copy-mobile-button, + body.mobile-mode.iphone-device .card-details .card-details-header .card-mobile-desktop-toggle, + body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-in, + body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-out { + font-size: 2em !important; /* 2x bigger */ + padding: 0.3em !important; + margin-right: 0.75em !important; /* 2x space compared to default */ + margin-left: 0 !important; + } + /* Avoid clipping of the close button on the right edge */ + body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details { + margin-right: 0.75em !important; + } + /* Enlarge the header title too */ + body.mobile-mode.iphone-device .card-details .card-details-header .card-details-title { + font-size: 1.2em !important; + font-weight: bold; + } +} + +/* Mobile mode styles - apply when body has mobile-mode class regardless of screen size */ +body.mobile-mode .card-details { + width: 100vw !important; + padding: 0px !important; + margin: 0px !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + z-index: 100 !important; + height: 100vh !important; + max-height: 100vh !important; + border-radius: 0 !important; + box-shadow: none !important; + overflow-y: auto !important; + overflow-x: hidden !important; + -webkit-overflow-scrolling: touch; +} + +body.mobile-mode .card-details .card-details-canvas { + width: 100% !important; + padding: 0 15px !important; +} + +body.mobile-mode .card-details .card-details-header .close-card-details, +body.mobile-mode .card-details .card-details-header .close-card-details-mobile-web { + display: block !important; } .card-details-white { background: #fff !important; diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade index 112c17745..e5bf2de4f 100644 --- a/client/components/cards/cardDetails.jade +++ b/client/components/cards/cardDetails.jade @@ -5,16 +5,25 @@ template(name="cardDetails") +attachmentViewer - section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}'): .card-details-canvas + section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}' class='{{#if cardCollapsed}}card-details-collapsed{{/if}}'): .card-details-canvas .card-details-header(class='{{#if colorClass}}card-details-{{colorClass}}{{/if}}') +inlinedForm(classNames="js-card-details-title") +editCardTitleForm else unless isMiniScreen unless isPopup + span.card-collapse-toggle.js-card-collapse-toggle(title="{{_ 'collapse-card'}}") + if cardCollapsed + | ▶ + else + | 🔽 a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}") | ❌ if canModifyCard + a.card-bring-to-front.js-card-bring-to-front(title="Bring to front") + | ⏫ + a.card-send-to-back.js-card-send-to-back(title="Send to back") + | ⏬ if cardMaximized a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}") | 🔽 @@ -30,12 +39,28 @@ template(name="cardDetails") href="{{ originRelativeUrl }}" ) span.emoji-icon 🔗 + span.card-drag-handle.js-card-drag-handle(title="Drag card") + | ↕️ span.copied-tooltip {{_ 'copied'}} else - unless isPopup - a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}") - | ❌ + a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}") + | ❌ + a.card-zoom-out.js-card-zoom-out(title="{{_ 'zoom-out'}}") + | 🔍➖ + a.card-zoom-in.js-card-zoom-in(title="{{_ 'zoom-in'}}") + | 🔍➕ + a.card-mobile-desktop-toggle.js-card-mobile-desktop-toggle(title="{{_ 'mobile-desktop-toggle'}}") + if mobileMode + | 🖥️ + else + | 📱 if canModifyCard + if cardMaximized + a.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}") + | 🔽 + else + a.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}") + | 🔼 a.card-details-menu-mobile-web.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}") | ☰ a.card-copy-mobile-button.js-copy-link( diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index e0854d3f7..c104ca143 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -63,7 +63,11 @@ BlazeComponent.extendComponent({ const boardBody = this.parentComponent().parentComponent(); //in Miniview parent is Board, not BoardBody. if (boardBody !== null) { - boardBody.showOverlay.set(true); + // Only show overlay in mobile mode, not in desktop mode + const isMobile = Utils.getMobileMode(); + if (isMobile) { + boardBody.showOverlay.set(true); + } boardBody.mouseHasEnterCardDetails = false; } } @@ -93,6 +97,18 @@ BlazeComponent.extendComponent({ return !Utils.getPopupCardId() && ReactiveCache.getCurrentUser().hasCardMaximized(); }, + cardCollapsed() { + const user = ReactiveCache.getCurrentUser(); + if (user && user.profile) { + return !!user.profile.cardCollapsed; + } + if (Users.getPublicCardCollapsed) { + const stored = Users.getPublicCardCollapsed(); + if (typeof stored === 'boolean') return stored; + } + return false; + }, + presentParentTask() { let result = this.currentBoard.presentParentTask; if (result === null || result === undefined) { @@ -296,13 +312,88 @@ BlazeComponent.extendComponent({ return [ { ...events, + 'click .js-card-collapse-toggle'() { + const user = ReactiveCache.getCurrentUser(); + const currentState = user && user.profile ? !!user.profile.cardCollapsed : !!Users.getPublicCardCollapsed(); + if (user) { + Meteor.call('setCardCollapsed', !currentState); + } else if (Users.setPublicCardCollapsed) { + Users.setPublicCardCollapsed(!currentState); + } + }, + 'click .js-card-bring-to-front'(event) { + event.preventDefault(); + const $card = $(event.target).closest('.card-details'); + // Find the highest z-index among all cards + let maxZ = 100; + $('.card-details').each(function() { + const z = parseInt($(this).css('z-index')) || 100; + if (z > maxZ) maxZ = z; + }); + // Set this card's z-index to be higher + $card.css('z-index', maxZ + 1); + }, + 'click .js-card-send-to-back'(event) { + event.preventDefault(); + const $card = $(event.target).closest('.card-details'); + // Find the lowest z-index among all cards + let minZ = 100; + $('.card-details').each(function() { + const z = parseInt($(this).css('z-index')) || 100; + if (z < minZ) minZ = z; + }); + // Set this card's z-index to be lower + $card.css('z-index', minZ - 1); + }, + 'mousedown .js-card-drag-handle'(event) { + event.preventDefault(); + const $card = $(event.target).closest('.card-details'); + const startX = event.clientX; + const startY = event.clientY; + const startLeft = $card.offset().left; + const startTop = $card.offset().top; + + const onMouseMove = (e) => { + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + $card.css({ + left: startLeft + deltaX + 'px', + top: startTop + deltaY + 'px' + }); + }; + + const onMouseUp = () => { + $(document).off('mousemove', onMouseMove); + $(document).off('mouseup', onMouseUp); + }; + + $(document).on('mousemove', onMouseMove); + $(document).on('mouseup', onMouseUp); + }, 'click .js-close-card-details'() { // Get board ID from either the card data or current board in session const card = this.currentData() || this.data(); const boardId = (card && card.boardId) || Utils.getCurrentBoard()._id; + const cardId = card && card._id; if (boardId) { - // Clear the current card session to close the card + // In desktop mode, remove from openCards array + const isMobile = Utils.getMobileMode(); + if (!isMobile && cardId) { + const openCards = Session.get('openCards') || []; + const filtered = openCards.filter(id => id !== cardId); + Session.set('openCards', filtered); + + // If this was the current card, clear it + if (Session.get('currentCard') === cardId) { + Session.set('currentCard', null); + } + + // Don't navigate away in desktop mode - just close the card + return; + } + + // Mobile mode: Clear the current card session to close the card Session.set('currentCard', null); // Navigate back to board without card @@ -327,6 +418,34 @@ BlazeComponent.extendComponent({ Meteor.call('changeDateFormat', dateFormat); }, 'click .js-open-card-details-menu': Popup.open('cardDetailsActions'), + // Mobile: switch to desktop popup view (maximize) + 'click .js-mobile-switch-to-desktop'(event) { + event.preventDefault(); + // Switch global mode to desktop so the card appears as desktop popup + Utils.setMobileMode(false); + }, + 'click .js-card-zoom-in'(event) { + event.preventDefault(); + const current = Utils.getCardZoom(); + const newZoom = Math.min(3.0, current + 0.1); + Utils.setCardZoom(newZoom); + }, + 'click .js-card-zoom-out'(event) { + event.preventDefault(); + const current = Utils.getCardZoom(); + const newZoom = Math.max(0.5, current - 0.1); + Utils.setCardZoom(newZoom); + }, + 'click .js-card-mobile-desktop-toggle'(event) { + event.preventDefault(); + const currentMode = Utils.getMobileMode(); + Utils.setMobileMode(!currentMode); + }, + 'click .js-card-mobile-desktop-toggle'(event) { + event.preventDefault(); + const currentMode = Utils.getMobileMode(); + Utils.setMobileMode(!currentMode); + }, 'submit .js-card-description'(event) { event.preventDefault(); const description = this.currentComponent().getValue(); diff --git a/client/components/cards/checklists.css b/client/components/cards/checklists.css index 05d937085..78cba4610 100644 --- a/client/components/cards/checklists.css +++ b/client/components/cards/checklists.css @@ -37,14 +37,23 @@ textarea.js-edit-checklist-item { .checklist-progress-bar-container .checklist-progress-bar { width: 80%; height: 10px; + background-color: #d6ebff !important; + border-radius: 16px; } .checklist-progress-bar-container .checklist-progress-bar .checklist-progress { color: #fff !important; - background-color: #2196f3 !important; + background-color: #3cb500 !important; padding: 0.01em 16px; border-radius: 16px; height: 100%; } +/* Grey progress bar when grey icons setting is enabled */ +body.grey-icons-enabled .checklist-progress-bar-container .checklist-progress-bar { + background-color: #d9d9d9; +} +body.grey-icons-enabled .checklist-progress-bar-container .checklist-progress-bar .checklist-progress { + background-color: #7a7a7a !important; +} .checklist-title { padding: 10px; } @@ -105,6 +114,25 @@ textarea.js-edit-checklist-item { height: auto; overflow: hidden; } + +/* iPhone mobile: larger checklist titles and more spacing between items */ +body.mobile-mode.iphone-device .checklist-title .title { + font-size: 1.3em !important; + font-weight: bold; +} + +body.mobile-mode.iphone-device .checklist-item { + margin-top: 12px !important; + margin-bottom: 8px !important; + padding: 8px 4px !important; + min-height: 44px; /* iOS recommended touch target size */ +} + +body.mobile-mode.iphone-device .checklist-item span.checklistitem-handle { + font-size: 1.5em !important; + padding-right: 15px !important; + width: 1.5em !important; +} .checklist-item.is-checked.invisible { opacity: 0; height: 0; @@ -134,6 +162,27 @@ textarea.js-edit-checklist-item { border-bottom: 2px solid #3cb500; border-right: 2px solid #3cb500; } +/* Unicode checkbox icons styling */ +.checklist-item .check-box-unicode, +.cardCustomField-checkbox .check-box-unicode { + font-size: 1.3em; + margin-right: 8px; + cursor: pointer; + display: inline-block; + vertical-align: middle; + line-height: 1; +} +/* Grey checkmarks when grey icons setting is enabled */ +body.grey-icons-enabled .checklist-item .check-box.is-checked { + border-bottom: 2px solid #7a7a7a; + border-right: 2px solid #7a7a7a; +} +body.grey-icons-enabled .checklist-item .check-box-unicode, +body.grey-icons-enabled .cardCustomField-checkbox .check-box-unicode { + filter: grayscale(100%); + -webkit-filter: grayscale(100%); + opacity: 0.85; +} .checklist-item .item-title { flex: 1; } @@ -155,6 +204,7 @@ textarea.js-edit-checklist-item { width: 1.2em; text-align: center; color: #999; + cursor: pointer; } .js-delete-checklist-item, .js-convert-checklist-item-to-card { diff --git a/client/components/cards/checklists.jade b/client/components/cards/checklists.jade index 2bd16d88b..83fc54508 100644 --- a/client/components/cards/checklists.jade +++ b/client/components/cards/checklists.jade @@ -125,14 +125,13 @@ template(name='checklistItemDetail') .js-checklist-item.checklist-item(class="{{#if item.isFinished }}is-checked{{#if checklist.hideCheckedChecklistItems}} invisible{{/if}}{{/if}}{{#if checklist.hideAllChecklistItems}} is-checked invisible{{/if}}" role="checkbox" aria-checked="{{#if item.isFinished }}true{{else}}false{{/if}}" tabindex="0") if canModifyCard - .check-box-container - .check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}") + span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}} span.checklistitem-handle(title="{{_ 'dragChecklistItem'}}") ↕️ .item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}") +viewer = item.title else - .materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}") + span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}} .item-title(class="{{#if item.isFinished }}is-checked{{/if}}") +viewer = item.title diff --git a/client/components/cards/checklists.js b/client/components/cards/checklists.js index 40faa6262..ade73818f 100644 --- a/client/components/cards/checklists.js +++ b/client/components/cards/checklists.js @@ -65,7 +65,7 @@ BlazeComponent.extendComponent({ $(self.itemsDom).sortable('option', 'disabled', !userIsMember()); if (Utils.isTouchScreenOrShowDesktopDragHandles()) { $(self.itemsDom).sortable({ - handle: 'span.fa.checklistitem-handle', + handle: 'span.checklistitem-handle', }); } } @@ -360,6 +360,7 @@ BlazeComponent.extendComponent({ events() { return [ { + 'click .js-checklist-item .check-box-unicode': this.toggleItem, 'click .js-checklist-item .check-box-container': this.toggleItem, }, ]; diff --git a/client/components/cards/subtasks.css b/client/components/cards/subtasks.css index 08f5122c2..ba89ad2b0 100644 --- a/client/components/cards/subtasks.css +++ b/client/components/cards/subtasks.css @@ -87,6 +87,15 @@ textarea.js-edit-subtask-item { top: 0; bottom: -600px; right: 0; + z-index: 15; +} + +/* Fix for mobile Safari: ensure this doesn't block card interaction */ +@media screen and (max-width: 800px) { + #card-details-overlay { + z-index: 15; + pointer-events: none; + } } .subtasks { background: #f7f7f7; @@ -127,6 +136,25 @@ textarea.js-edit-subtask-item { border-bottom: 2px solid #3cb500; border-right: 2px solid #3cb500; } +/* Unicode checkbox icons styling */ +.subtasks-item .check-box-unicode { + font-size: 1.3em; + margin-right: 8px; + cursor: pointer; + display: inline-block; + vertical-align: middle; + line-height: 1; +} +/* Grey checkmarks when grey icons setting is enabled */ +body.grey-icons-enabled .subtasks-item .check-box.is-checked { + border-bottom: 2px solid #7a7a7a; + border-right: 2px solid #7a7a7a; +} +body.grey-icons-enabled .subtasks-item .check-box-unicode { + filter: grayscale(100%); + -webkit-filter: grayscale(100%); + opacity: 0.85; +} .subtasks-item .item-title { flex: 1; padding-left: 10px; diff --git a/client/components/cards/subtasks.jade b/client/components/cards/subtasks.jade index ceb860e6e..987235f2a 100644 --- a/client/components/cards/subtasks.jade +++ b/client/components/cards/subtasks.jade @@ -74,12 +74,12 @@ template(name="subtasksItems") template(name='subtaskItemDetail') .js-subtasks-item.subtasks-item if canModifyCard - .check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}") + span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}} .item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}") +viewer = item.title else - .materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}") + span.check-box-unicode {{#if item.isFinished }}✅{{else}}⬜{{/if}} .item-title(class="{{#if item.isFinished }}is-checked{{/if}}") +viewer = item.title diff --git a/client/components/cards/subtasks.js b/client/components/cards/subtasks.js index af5654802..d1c390883 100644 --- a/client/components/cards/subtasks.js +++ b/client/components/cards/subtasks.js @@ -104,7 +104,19 @@ BlazeComponent.extendComponent({ }).register('subtasks'); BlazeComponent.extendComponent({ - // ... + toggleItem() { + const item = this.currentData().item; + if (item && item._id) { + item.toggleItem(); + } + }, + events() { + return [ + { + 'click .js-subtasks-item .check-box-unicode': this.toggleItem, + }, + ]; + }, }).register('subtaskItemDetail'); BlazeComponent.extendComponent({ diff --git a/client/components/forms/forms.css b/client/components/forms/forms.css index 3b1566514..f2fd7fbc4 100644 --- a/client/components/forms/forms.css +++ b/client/components/forms/forms.css @@ -315,11 +315,18 @@ textarea::-moz-placeholder { margin-right: 6px; border-top: 2px solid transparent; border-left: 2px solid transparent; + border-bottom: 2px solid #3cb500; + border-right: 2px solid #3cb500; transform: rotate(40deg); -webkit-backface-visibility: hidden; backface-visibility: hidden; transform-origin: 100% 100%; } +/* Grey checkmarks when grey icons setting is enabled */ +body.grey-icons-enabled .materialCheckBox.is-checked { + border-bottom: 2px solid #7a7a7a; + border-right: 2px solid #7a7a7a; +} .button-link { background: #fff; background: linear-gradient(#fff, #f5f5f5); diff --git a/client/components/lists/list.css b/client/components/lists/list.css index 72e728bea..5e916ffcb 100644 --- a/client/components/lists/list.css +++ b/client/components/lists/list.css @@ -282,7 +282,7 @@ body.list-resizing-active * { margin: 0 auto; } .list.list-collapsed .list-header .js-collapse { - margin: 0 auto 20px auto; + margin: 0 auto 0 auto; z-index: 10; padding: 8px 12px; font-size: 12px; @@ -290,6 +290,12 @@ body.list-resizing-active * { display: block; width: fit-content; } +.list.list-collapsed .list-header .list-header-handle { + position: absolute !important; + top: 30px !important; + right: 1.5vw !important; + z-index: 15 !important; +} .list.list-collapsed .list-header .list-rotated { width: auto !important; height: auto !important; @@ -297,7 +303,6 @@ body.list-resizing-active * { position: relative !important; overflow: visible !important; } - .list.list-collapsed .list-header .list-rotated h2.list-header-name { text-align: left; overflow: visible; @@ -308,15 +313,15 @@ body.list-resizing-active * { color: #333; background-color: rgba(255, 255, 255, 0.95); border: 1px solid #ddd; - padding: 8px 4px; + padding: 0; border-radius: 4px; - margin: 0 auto; - width: 25vh; - height: 60vh; + margin: 0; + width: 100vh; + height: 30px; position: absolute; - left: 50%; + left: 40px; top: 50%; - transform: translate(calc(-50% + 50px), -50%) rotate(0deg); + transform: translateY(calc(-50% + 20px)) rotate(0deg); z-index: 10; visibility: visible !important; opacity: 1 !important; @@ -415,22 +420,42 @@ body.list-resizing-active * { color: #a6a6a6; margin-right: 15px; } +/* List header collapse button styling */ +.list-header .list-header-collapse-container { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 10px; + flex: 1; + min-width: 0; +} + .list-header .js-collapse { color: #a6a6a6; - margin-right: 15px; display: inline-block; vertical-align: middle; padding: 5px 8px; - border: 1px solid #ccc; - border-radius: 4px; - background-color: #f5f5f5; + border: none; + border-radius: 0; + background-color: transparent; cursor: pointer; - font-size: 14px; + font-size: 18px; + line-height: 1; + min-width: 30px; + text-align: center; + flex-shrink: 0; + text-decoration: none; + margin: 0; } .list-header .js-collapse:hover { - background-color: #e0e0e0; + background-color: transparent; color: #333; } + +.list-header .list-header-collapse-container > div { + flex: 1; + min-width: 0; +} .list.list-collapsed .list-header .js-collapse { display: inline-block !important; visibility: visible !important; @@ -459,17 +484,18 @@ body.list-resizing-active * { position: relative !important; } .list.list-collapsed .list-header .list-rotated h2.list-header-name { - width: 15vh; + width: 100vh; font-size: 12px; height: 30px; line-height: 1.2; - padding: 8px 4px; - margin: 0 auto; + padding: 0; + margin: 0; + overflow: visible; position: absolute; - left: 50%; + left: 40px; top: 50%; - transform: translate(calc(-50% + 50px), -50%) rotate(0deg); - text-align: left; + transform: translateY(calc(-50% + 120px)) rotate(0deg); + text-align: center; visibility: visible !important; opacity: 1 !important; display: block !important; @@ -499,17 +525,18 @@ body.list-resizing-active * { position: relative !important; } .list.list-collapsed .list-header .list-rotated h2.list-header-name { - width: 15vh; + width: 100vh; font-size: 12px; height: 30px; line-height: 1.2; - padding: 8px 4px; - margin: 0 auto; + padding: 0; + margin: 0; + overflow: visible; position: absolute; - left: 50%; + left: 40px; top: 50%; - transform: translate(calc(-50% + 50px), -50%) rotate(0deg); - text-align: left; + transform: translateY(calc(-50% + 120px)) rotate(0deg); + text-align: center; visibility: visible !important; opacity: 1 !important; display: block !important; @@ -539,16 +566,17 @@ body.list-resizing-active * { position: relative !important; } .list.list-collapsed .list-header .list-rotated h2.list-header-name { - width: 15vh; + width: 100vh; font-size: 12px; height: 30px; line-height: 1.2; - padding: 8px 4px; - margin: 0 auto; + padding: 0; + margin: 0; + overflow: visible; position: absolute; - left: 50%; + left: 40px; top: 50%; - transform: translate(calc(-50% + 50px), -50%) rotate(0deg); + transform: translateY(calc(-50% + 40px)) rotate(0deg); text-align: left; visibility: visible !important; opacity: 1 !important; @@ -1053,6 +1081,23 @@ body.list-resizing-active * { grid-row: 1/3 !important; grid-column: 1 !important; } + +/* Allow long list titles to expand on desktop (non-mobile, non-collapsed) */ +.list:not(.mobile-view):not(.list-collapsed) .list-header { + overflow: visible !important; +} + +.list:not(.mobile-view):not(.list-collapsed) .list-header .list-header-name { + /* Permit wrapping and full visibility */ + white-space: normal !important; + overflow: visible !important; + text-overflow: clip !important; + display: inline-block !important; + /* Reserve space for right-side controls (menu, handle, count) */ + max-width: calc(100% - 120px) !important; + /* Break long words to avoid overflow */ + word-break: break-word !important; +} .link-board-wrapper { display: flex; align-items: baseline; diff --git a/client/components/lists/list.jade b/client/components/lists/list.jade index eed4d67f9..c28dd1a9c 100644 --- a/client/components/lists/list.jade +++ b/client/components/lists/list.jade @@ -3,8 +3,9 @@ template(name='list') style="{{#unless collapsed}}min-width:{{listWidth}}px;max-width:{{listConstraint}}px;{{/unless}}" class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}") +listHeader - +listBody - .list-resize-handle.js-list-resize-handle.nodragscroll + unless collapsed + +listBody + .list-resize-handle.js-list-resize-handle.nodragscroll template(name='miniList') a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}") diff --git a/client/components/lists/list.js b/client/components/lists/list.js index 7501886ae..2938b2be3 100644 --- a/client/components/lists/list.js +++ b/client/components/lists/list.js @@ -279,7 +279,8 @@ BlazeComponent.extendComponent({ // Only enable resize for non-collapsed, non-auto-width lists const isAutoWidth = this.autoWidth(); - if (list.collapsed || isAutoWidth) { + const isCollapsed = Utils.getListCollapseState(list); + if (isCollapsed || isAutoWidth) { $resizeHandle.hide(); return; } @@ -433,9 +434,10 @@ BlazeComponent.extendComponent({ }); - // Reactively update resize handle visibility when auto-width changes + // Reactively update resize handle visibility when auto-width or collapse changes component.autorun(() => { - if (component.autoWidth()) { + const collapsed = Utils.getListCollapseState(list); + if (component.autoWidth() || collapsed) { $resizeHandle.hide(); } else { $resizeHandle.show(); @@ -452,6 +454,12 @@ BlazeComponent.extendComponent({ }, }).register('list'); +Template.list.helpers({ + collapsed() { + return Utils.getListCollapseState(this); + }, +}); + Template.miniList.events({ 'click .js-select-list'() { const listId = this._id; diff --git a/client/components/lists/listHeader.jade b/client/components/lists/listHeader.jade index 3e1567d2d..a3f7e239c 100644 --- a/client/components/lists/listHeader.jade +++ b/client/components/lists/listHeader.jade @@ -30,20 +30,22 @@ template(name="listHeader") |   span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}} else - if collapsed - a.js-collapse(title="{{_ 'uncollapse'}}") - | ⬅️ - | ➡️ - div(class="{{#if collapsed}}list-rotated{{/if}}") - h2.list-header-name( - title="{{ moment modifiedAt 'LLL' }}" - class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}") - +viewer - = title - if wipLimit.enabled - | ( - span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}} - |/#{wipLimit.value}) + div.list-header-collapse-container + a.list-collapse-indicator.js-collapse(title="{{_ 'collapse'}}") + if collapsed + | ▶ + else + | 🔽 + div(class="{{#if collapsed}}list-rotated{{/if}}") + h2.list-header-name( + title="{{ moment modifiedAt 'LLL' }}" + class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}") + +viewer + = title + if wipLimit.enabled + | ( + span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}} + |/#{wipLimit.value}) unless collapsed if showCardsCountForList cards.length span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}} @@ -64,6 +66,10 @@ template(name="listHeader") unless currentUser.isWorker a.list-header-handle.handle.js-list-handle ↕️ else if currentUser.isBoardMember + if currentUser.isBoardMember + unless currentUser.isCommentOnly + unless currentUser.isWorker + a.list-header-handle.handle.js-list-handle ↕️ if isWatching i.list-header-watch-icon | 👁️ unless collapsed @@ -73,14 +79,8 @@ template(name="listHeader") // 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-collapse(title="{{_ 'collapse'}}") - | ⬅️ - | ➡️ + 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/lists/listHeader.js b/client/components/lists/listHeader.js index 2999a06c7..b42bbfffa 100644 --- a/client/components/lists/listHeader.js +++ b/client/components/lists/listHeader.js @@ -34,13 +34,14 @@ BlazeComponent.extendComponent({ }, collapsed(check = undefined) { const list = Template.currentData(); - const status = list.isCollapsed(); + const status = Utils.getListCollapseState(list); if (check === undefined) { // just check return status; } else { - list.collapse(!status); - return !status; + const next = typeof check === 'boolean' ? check : !status; + Utils.setListCollapseState(list, next); + return next; } }, editTitle(event) { diff --git a/client/components/main/header.css b/client/components/main/header.css index 609941320..ee17d00c3 100644 --- a/client/components/main/header.css +++ b/client/components/main/header.css @@ -339,15 +339,20 @@ width: 100%; min-width: 3vw; font-size: clamp(12px, 2vw, 14px); + box-sizing: border-box; + -webkit-appearance: none; + appearance: none; + flex: 0 0 auto; } /* Make zoom input wider on all mobile screens */ @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 */ - font-size: 14px !important; /* Slightly larger text */ + min-width: 80px !important; /* Wider on mobile to show 3 digits */ + width: 80px !important; /* Fixed width to show 100 fully */ + font-size: 16px !important; /* Slightly larger text */ + flex: 0 0 80px !important; /* Prevent shrinking in flex */ } } @@ -850,8 +855,9 @@ #header-quick-access .zoom-controls .zoom-input { font-size: 16px !important; /* Larger input text */ padding: 0.5vh 0.8vw !important; - min-width: 6vw !important; /* Much wider for mobile */ - width: 60px !important; /* Fixed width to show all numbers */ + min-width: 80px !important; /* Wider to fit 100 */ + width: 80px !important; /* Fixed width to show 100 fully */ + flex: 0 0 80px !important; /* Prevent shrinking in flex */ } /* Make mobile mode toggle larger */ diff --git a/client/components/main/layouts.css b/client/components/main/layouts.css index 367881f3c..8847291fb 100644 --- a/client/components/main/layouts.css +++ b/client/components/main/layouts.css @@ -81,6 +81,27 @@ body { display: flex; flex-direction: column; height: 100vh; + /* iOS Safari fixes */ + -webkit-overflow-scrolling: touch; +} + +/* Mobile mode specific fixes for iOS Safari */ +body.mobile-mode { + overflow-x: hidden; + position: fixed; + width: 100%; + height: 100vh; + /* Prevent iOS Safari bounce scroll */ + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; +} + +/* Ensure content area is scrollable in mobile mode */ +body.mobile-mode #content { + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + height: calc(100vh - 48px); } #content { position: relative; @@ -899,6 +920,40 @@ a:not(.disabled).is-active i.fa { height: 100%; } } + +/* iOS Safari Mobile Mode Fixes */ +@media screen and (max-width: 800px) { + /* Prevent scrolling issues on iOS Safari when card popup is open */ + body.mobile-mode { + overflow: hidden; + position: fixed; + width: 100%; + height: 100vh; + } + + /* Fix z-index stacking for mobile Safari */ + body.mobile-mode .board-wrapper { + z-index: 1; + } + + body.mobile-mode .board-wrapper .board-canvas .board-overlay { + z-index: 17 !important; + } + + body.mobile-mode .card-details { + z-index: 100 !important; + } + + body.mobile-mode .pop-over { + z-index: 999; + } + + /* Ensure smooth scrolling on iOS */ + body.mobile-mode .card-details, + body.mobile-mode .pop-over .content-wrapper { + -webkit-overflow-scrolling: touch; + } +} @-moz-keyframes lds-roller { 0% { transform: rotate(0deg); diff --git a/client/components/main/layouts.jade b/client/components/main/layouts.jade index 469524e04..a42c646ad 100644 --- a/client/components/main/layouts.jade +++ b/client/components/main/layouts.jade @@ -2,8 +2,10 @@ template(name="main") html(lang="{{TAPi18n.getLanguage}}") head title - meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes") + meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover") meta(http-equiv="X-UA-Compatible" content="IE=edge") + meta(name="apple-mobile-web-app-capable" content="yes") + meta(name="apple-mobile-web-app-status-bar-style" content="black-translucent") //- XXX We should use pathFor in the following `href` to support the case where the application is deployed with a path prefix, but it seems to be difficult to do that cleanly with Blaze -- at least without adding extra diff --git a/client/components/main/popup.css b/client/components/main/popup.css index c0ad60e6d..2fed6211b 100644 --- a/client/components/main/popup.css +++ b/client/components/main/popup.css @@ -538,6 +538,7 @@ position: absolute; top: 6px; right: 12px; + color: #3cb500; } .pop-over-list .pop-over-list.checkable li.active a { padding-right: 28px; @@ -545,6 +546,10 @@ .pop-over-list .pop-over-list.checkable li.active a .fa-check { display: block; } +/* Grey check icons when grey icons setting is enabled */ +body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check { + color: #7a7a7a; +} .pop-over.miniprofile .header { border-bottom-color: transparent; height: 30px; @@ -590,6 +595,10 @@ overflow: hidden; margin-top: 0px; border: 0px solid #dbdbdb; + /* Ensure popups appear above card details on mobile */ + z-index: 999999 !important; + /* iOS Safari scrolling fix */ + -webkit-overflow-scrolling: touch; } .pop-over .header { color: #fff; @@ -674,3 +683,23 @@ transform: none !important; } } + +/* Force full-screen popups in mobile mode regardless of screen width */ +body.mobile-mode .pop-over { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: 100vh !important; + max-width: 100vw !important; + max-height: 100vh !important; +} +body.mobile-mode .pop-over .content-wrapper { + width: 100% !important; + height: calc(100vh - 48px) !important; + max-height: calc(100vh - 48px) !important; + overflow-y: auto !important; + overflow-x: hidden !important; +} diff --git a/client/components/settings/settingBody.css b/client/components/settings/settingBody.css index 765baa77c..b69914713 100644 --- a/client/components/settings/settingBody.css +++ b/client/components/settings/settingBody.css @@ -137,8 +137,13 @@ padding: 0.5rem 0.5rem; } .setting-content .content-body .main-body ul li a .is-checked { - border-bottom: 2px solid #2980b9; - border-right: 2px solid #2980b9; + border-bottom: 2px solid #3cb500; + border-right: 2px solid #3cb500; +} +/* Grey checkmarks when grey icons setting is enabled */ +body.grey-icons-enabled .setting-content .content-body .main-body ul li a .is-checked { + border-bottom: 2px solid #7a7a7a; + border-right: 2px solid #7a7a7a; } .setting-content .content-body .main-body ul li a span { padding: 0 0.5rem; diff --git a/client/components/sidebar/sidebar.css b/client/components/sidebar/sidebar.css index 7867aec6d..f6b237978 100644 --- a/client/components/sidebar/sidebar.css +++ b/client/components/sidebar/sidebar.css @@ -68,6 +68,14 @@ transform-origin: 100% 100% !important; } +/* Grey checkmarks when grey icons setting is enabled */ +body.grey-icons-enabled .sidebar .materialCheckBox.is-checked, +body.grey-icons-enabled .boardCardSettingsPopup .materialCheckBox.is-checked, +body.grey-icons-enabled .boardSubtaskSettingsPopup .materialCheckBox.is-checked { + border-bottom: 2px solid #7a7a7a !important; + border-right: 2px solid #7a7a7a !important; +} + /* Card Settings 3-column grid layout */ .card-settings-grid { display: grid; @@ -130,6 +138,11 @@ } .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-check { margin: 0 4px; + color: #3cb500; +} +/* Grey check icons when grey icons setting is enabled */ +body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-check { + color: #7a7a7a; } .sidebar .sidebar-content ul.sidebar-list li .minicard { padding: 6px 8px 4px; diff --git a/client/components/swimlanes/swimlaneHeader.jade b/client/components/swimlanes/swimlaneHeader.jade index 11560a01b..4fb717463 100644 --- a/client/components/swimlanes/swimlaneHeader.jade +++ b/client/components/swimlanes/swimlaneHeader.jade @@ -26,6 +26,11 @@ template(name="swimlaneFixedHeader") if currentUser unless currentUser.isCommentOnly unless currentUser.isWorker + a.swimlane-collapse-indicator.js-collapse-swimlane.swimlane-header-collapse(title="{{_ 'collapse'}}") + if collapseSwimlane + | ▶ + else + | 🔽 a.js-open-add-swimlane-menu.swimlane-header-plus-icon(title="{{_ 'add-swimlane'}}") | ➕ unless isTouchScreen diff --git a/client/components/swimlanes/swimlaneHeader.js b/client/components/swimlanes/swimlaneHeader.js index c0ef35453..1caeda34c 100644 --- a/client/components/swimlanes/swimlaneHeader.js +++ b/client/components/swimlanes/swimlaneHeader.js @@ -20,13 +20,14 @@ BlazeComponent.extendComponent({ }, collapsed(check = undefined) { const swimlane = Template.currentData(); - const status = swimlane.isCollapsed(); + const status = Utils.getSwimlaneCollapseState(swimlane); if (check === undefined) { // just check return status; } else { - swimlane.collapse(!status); - return !status; + const next = typeof check === 'boolean' ? check : !status; + Utils.setSwimlaneCollapseState(swimlane, next); + return next; } }, @@ -49,6 +50,10 @@ Template.swimlaneFixedHeader.helpers({ isBoardAdmin() { return ReactiveCache.getCurrentUser().isBoardAdmin(); }, + collapseSwimlane() { + const swimlane = Template.currentData(); + return Utils.getSwimlaneCollapseState(swimlane); + }, isTitleDefault(title) { // https://github.com/wekan/wekan/issues/4763 // https://github.com/wekan/wekan/issues/4742 diff --git a/client/components/swimlanes/swimlanes.css b/client/components/swimlanes/swimlanes.css index 6d4ad3d0e..e801654a4 100644 --- a/client/components/swimlanes/swimlanes.css +++ b/client/components/swimlanes/swimlanes.css @@ -130,6 +130,29 @@ pointer-events: auto; } +/* Swimlane collapse button styling - matches list collapse button */ +.swimlane .swimlane-header-wrap .swimlane-header-menu .swimlane-collapse-indicator { + color: #a6a6a6; + display: inline-block; + vertical-align: middle; + padding: 5px 8px; + border: none; + border-radius: 0; + background-color: transparent; + cursor: pointer; + font-size: 18px; + line-height: 1; + min-width: 30px; + text-align: center; + text-decoration: none; + margin: 0; + flex-shrink: 0; +} +.swimlane .swimlane-header-wrap .swimlane-header-menu .swimlane-collapse-indicator:hover { + background-color: transparent; + color: #333; +} + #js-swimlane-height-edit .swimlane-height-error { display: none; } diff --git a/client/components/swimlanes/swimlanes.js b/client/components/swimlanes/swimlanes.js index e0dd896d5..d0b238d52 100644 --- a/client/components/swimlanes/swimlanes.js +++ b/client/components/swimlanes/swimlanes.js @@ -283,6 +283,9 @@ BlazeComponent.extendComponent({ // Wait for DOM to be ready setTimeout(() => { + const handleSelector = Utils.isTouchScreenOrShowDesktopDragHandles() + ? '.js-list-handle' + : '.js-list-header'; const $lists = this.$('.js-list'); const $parent = $lists.parent(); @@ -306,7 +309,7 @@ BlazeComponent.extendComponent({ items: '.js-list:not(.js-list-composer)', placeholder: 'list placeholder', distance: 7, - handle: '.js-list-handle', + handle: handleSelector, disabled: !Utils.canModifyBoard(), start(evt, ui) { ui.helper.css('z-index', 1000); @@ -319,6 +322,15 @@ BlazeComponent.extendComponent({ boardComponent.setIsDragging(false); } }); + // Reactively update handle when user toggles desktop drag handles + this.autorun(() => { + const newHandle = Utils.isTouchScreenOrShowDesktopDragHandles() + ? '.js-list-handle' + : '.js-list-header'; + if ($parent.data('uiSortable') || $parent.data('sortable')) { + try { $parent.sortable('option', 'handle', newHandle); } catch (e) {} + } + }); } else { } }, 100); @@ -684,6 +696,10 @@ Template.swimlane.helpers({ lists() { // Return per-swimlane lists for this swimlane return this.myLists(); + }, + + collapseSwimlane() { + return Utils.getSwimlaneCollapseState(this); } }); @@ -691,6 +707,9 @@ Template.swimlane.helpers({ setTimeout(() => { const $swimlaneElements = $('.swimlane'); const $listsGroupElements = $('.list-group'); + const computeHandle = () => ( + Utils.isTouchScreenOrShowDesktopDragHandles() ? '.js-list-handle' : '.js-list-header' + ); // Initialize sortable on ALL swimlane elements (even empty ones) $swimlaneElements.each(function(index) { @@ -707,7 +726,7 @@ setTimeout(() => { items: '.js-list:not(.js-list-composer)', placeholder: 'list placeholder', distance: 7, - handle: '.js-list-handle', + handle: computeHandle(), disabled: !Utils.canModifyBoard(), start(evt, ui) { ui.helper.css('z-index', 1000); @@ -831,6 +850,13 @@ setTimeout(() => { }); } }); + // Reactively adjust handle when setting changes + Tracker.autorun(() => { + const newHandle = computeHandle(); + if ($swimlane.data('uiSortable') || $swimlane.data('sortable')) { + try { $swimlane.sortable('option', 'handle', newHandle); } catch (e) {} + } + }); } }); @@ -849,7 +875,7 @@ setTimeout(() => { items: '.js-list:not(.js-list-composer)', placeholder: 'list placeholder', distance: 7, - handle: '.js-list-handle', + handle: computeHandle(), disabled: !Utils.canModifyBoard(), start(evt, ui) { ui.helper.css('z-index', 1000); @@ -973,6 +999,13 @@ setTimeout(() => { }); } }); + // Reactively adjust handle when setting changes + Tracker.autorun(() => { + const newHandle = computeHandle(); + if ($listsGroup.data('uiSortable') || $listsGroup.data('sortable')) { + try { $listsGroup.sortable('option', 'handle', newHandle); } catch (e) {} + } + }); } }); }, 1000); @@ -1018,6 +1051,9 @@ BlazeComponent.extendComponent({ // Wait for DOM to be ready setTimeout(() => { + const handleSelector = Utils.isTouchScreenOrShowDesktopDragHandles() + ? '.js-list-handle' + : '.js-list-header'; const $lists = this.$('.js-list'); const $parent = $lists.parent(); @@ -1041,7 +1077,7 @@ BlazeComponent.extendComponent({ items: '.js-list:not(.js-list-composer)', placeholder: 'list placeholder', distance: 7, - handle: '.js-list-handle', + handle: handleSelector, disabled: !Utils.canModifyBoard(), start(evt, ui) { ui.helper.css('z-index', 1000); @@ -1054,6 +1090,15 @@ BlazeComponent.extendComponent({ boardComponent.setIsDragging(false); } }); + // Reactively update handle when user toggles desktop drag handles + this.autorun(() => { + const newHandle = Utils.isTouchScreenOrShowDesktopDragHandles() + ? '.js-list-handle' + : '.js-list-header'; + if ($parent.data('uiSortable') || $parent.data('sortable')) { + try { $parent.sortable('option', 'handle', newHandle); } catch (e) {} + } + }); } else { } }, 100); diff --git a/client/lib/utils.js b/client/lib/utils.js index ad64a4057..26535b939 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -79,13 +79,21 @@ Utils = { }, getMobileMode() { + // Check localStorage first - user's explicit preference takes priority + const stored = localStorage.getItem('wekan-mobile-mode'); + if (stored !== null) { + return stored === 'true'; + } + + // Then check user profile const user = ReactiveCache.getCurrentUser(); if (user && user.profile && user.profile.mobileMode !== undefined) { return user.profile.mobileMode; } - // For non-logged-in users, check localStorage - const stored = localStorage.getItem('wekan-mobile-mode'); - return stored ? stored === 'true' : false; + + // Default to mobile mode for iPhone/iPod + const isIPhone = /iPhone|iPod/i.test(navigator.userAgent); + return isIPhone; }, setMobileMode(enabled) { @@ -93,13 +101,41 @@ Utils = { if (user) { // Update user profile user.setMobileMode(enabled); - } else { - // Store in localStorage for non-logged-in users - localStorage.setItem('wekan-mobile-mode', enabled.toString()); } + // Always store in localStorage for persistence across sessions + localStorage.setItem('wekan-mobile-mode', enabled.toString()); Utils.applyMobileMode(enabled); // Trigger reactive updates for UI components Session.set('wekan-mobile-mode', enabled); + // Re-apply zoom level to ensure proper rendering + const zoomLevel = Utils.getZoomLevel(); + Utils.applyZoomLevel(zoomLevel); + }, + + getCardZoom() { + const user = ReactiveCache.getCurrentUser(); + if (user && user.profile && user.profile.cardZoom !== undefined) { + return user.profile.cardZoom; + } + const stored = localStorage.getItem('wekan-card-zoom'); + return stored ? parseFloat(stored) : 1.0; + }, + + setCardZoom(level) { + const user = ReactiveCache.getCurrentUser(); + if (user) { + user.setCardZoom(level); + } + localStorage.setItem('wekan-card-zoom', level.toString()); + Utils.applyCardZoom(level); + Session.set('wekan-card-zoom', level); + }, + + applyCardZoom(level) { + const cardDetails = document.querySelector('.card-details'); + if (cardDetails) { + cardDetails.style.fontSize = `${level}em`; + } }, applyZoomLevel(level) { @@ -301,6 +337,85 @@ Utils = { } }, + getListCollapseState(list) { + if (!list) return false; + const key = `collapsedList-${list._id}`; + const sessionVal = Session.get(key); + if (typeof sessionVal === 'boolean') { + return sessionVal; + } + + const user = ReactiveCache.getCurrentUser(); + let stored = null; + if (user && user.getCollapsedListFromStorage) { + stored = user.getCollapsedListFromStorage(list.boardId, list._id); + } else if (Users.getPublicCollapsedList) { + stored = Users.getPublicCollapsedList(list.boardId, list._id); + } + + if (typeof stored === 'boolean') { + Session.setDefault(key, stored); + return stored; + } + + const fallback = typeof list.collapsed === 'boolean' ? list.collapsed : false; + Session.setDefault(key, fallback); + return fallback; + }, + + setListCollapseState(list, collapsed) { + if (!list) return; + const key = `collapsedList-${list._id}`; + Session.set(key, !!collapsed); + const user = ReactiveCache.getCurrentUser(); + if (user) { + Meteor.call('setListCollapsedState', list.boardId, list._id, !!collapsed); + } else if (Users.setPublicCollapsedList) { + Users.setPublicCollapsedList(list.boardId, list._id, !!collapsed); + } + }, + + getSwimlaneCollapseState(swimlane) { + if (!swimlane) return false; + const key = `collapsedSwimlane-${swimlane._id}`; + const sessionVal = Session.get(key); + if (typeof sessionVal === 'boolean') { + return sessionVal; + } + + const user = ReactiveCache.getCurrentUser(); + let stored = null; + if (user && user.getCollapsedSwimlaneFromStorage) { + stored = user.getCollapsedSwimlaneFromStorage( + swimlane.boardId, + swimlane._id, + ); + } else if (Users.getPublicCollapsedSwimlane) { + stored = Users.getPublicCollapsedSwimlane(swimlane.boardId, swimlane._id); + } + + if (typeof stored === 'boolean') { + Session.setDefault(key, stored); + return stored; + } + + const fallback = typeof swimlane.collapsed === 'boolean' ? swimlane.collapsed : false; + Session.setDefault(key, fallback); + return fallback; + }, + + setSwimlaneCollapseState(swimlane, collapsed) { + if (!swimlane) return; + const key = `collapsedSwimlane-${swimlane._id}`; + Session.set(key, !!collapsed); + const user = ReactiveCache.getCurrentUser(); + if (user) { + Meteor.call('setSwimlaneCollapsedState', swimlane.boardId, swimlane._id, !!collapsed); + } else if (Users.setPublicCollapsedSwimlane) { + Users.setPublicCollapsedSwimlane(swimlane.boardId, swimlane._id, !!collapsed); + } + }, + myCardsSort() { let sort = window.localStorage.getItem('myCardsSort'); diff --git a/config/router.js b/config/router.js index 20c9ebf8a..3d303abce 100644 --- a/config/router.js +++ b/config/router.js @@ -165,6 +165,16 @@ FlowRouter.route('/b/:boardId/:slug/:cardId', { Session.set('currentCard', params.cardId); Session.set('popupCardId', null); Session.set('popupCardBoardId', null); + + // In desktop mode, add to openCards array to support multiple cards + const isMobile = Utils.getMobileMode(); + if (!isMobile) { + const openCards = Session.get('openCards') || []; + if (!openCards.includes(params.cardId)) { + openCards.push(params.cardId); + Session.set('openCards', openCards); + } + } Utils.manageCustomUI(); Utils.manageMatomo(); diff --git a/models/lists.js b/models/lists.js index 3c5087d03..f02d57c91 100644 --- a/models/lists.js +++ b/models/lists.js @@ -297,6 +297,23 @@ Lists.helpers({ }, isCollapsed() { + if (Meteor.isClient) { + const user = ReactiveCache.getCurrentUser(); + // Logged-in users: prefer profile/cookie-backed state + if (user && user.getCollapsedListFromStorage) { + const stored = user.getCollapsedListFromStorage(this.boardId, this._id); + if (typeof stored === 'boolean') { + return stored; + } + } + // Public users: fallback to cookie if available + if (!user && Users.getPublicCollapsedList) { + const stored = Users.getPublicCollapsedList(this.boardId, this._id); + if (typeof stored === 'boolean') { + return stored; + } + } + } return this.collapsed === true; }, diff --git a/models/swimlanes.js b/models/swimlanes.js index 659111d04..e9f26645c 100644 --- a/models/swimlanes.js +++ b/models/swimlanes.js @@ -246,6 +246,21 @@ Swimlanes.helpers({ }, isCollapsed() { + if (Meteor.isClient) { + const user = ReactiveCache.getCurrentUser(); + if (user && user.getCollapsedSwimlaneFromStorage) { + const stored = user.getCollapsedSwimlaneFromStorage(this.boardId, this._id); + if (typeof stored === 'boolean') { + return stored; + } + } + if (!user && Users.getPublicCollapsedSwimlane) { + const stored = Users.getPublicCollapsedSwimlane(this.boardId, this._id); + if (typeof stored === 'boolean') { + return stored; + } + } + } return this.collapsed === true; }, diff --git a/models/users.js b/models/users.js index b8952a1c4..89942ecf4 100644 --- a/models/users.js +++ b/models/users.js @@ -11,6 +11,83 @@ const isSandstorm = Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm; Users = Meteor.users; +// Public-board collapse persistence helpers (cookie-based for non-logged-in users) +if (Meteor.isClient) { + const readCookieMap = name => { + try { + const stored = typeof document !== 'undefined' ? document.cookie : ''; + const cookies = stored.split(';').map(c => c.trim()); + let json = '{}'; + for (const c of cookies) { + if (c.startsWith(name + '=')) { + json = decodeURIComponent(c.substring(name.length + 1)); + break; + } + } + return JSON.parse(json || '{}'); + } catch (e) { + console.warn('Error parsing collapse cookie', name, e); + return {}; + } + }; + + const writeCookieMap = (name, data) => { + try { + const serialized = encodeURIComponent(JSON.stringify(data || {})); + const maxAge = 60 * 60 * 24 * 365; // 1 year + document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`; + } catch (e) { + console.warn('Error writing collapse cookie', name, e); + } + }; + + Users.getPublicCollapsedList = (boardId, listId) => { + if (!boardId || !listId) return null; + const data = readCookieMap('wekan-collapsed-lists'); + if (data[boardId] && typeof data[boardId][listId] === 'boolean') { + return data[boardId][listId]; + } + return null; + }; + + Users.setPublicCollapsedList = (boardId, listId, collapsed) => { + if (!boardId || !listId) return false; + const data = readCookieMap('wekan-collapsed-lists'); + if (!data[boardId]) data[boardId] = {}; + data[boardId][listId] = !!collapsed; + writeCookieMap('wekan-collapsed-lists', data); + return true; + }; + + Users.getPublicCollapsedSwimlane = (boardId, swimlaneId) => { + if (!boardId || !swimlaneId) return null; + const data = readCookieMap('wekan-collapsed-swimlanes'); + if (data[boardId] && typeof data[boardId][swimlaneId] === 'boolean') { + return data[boardId][swimlaneId]; + } + return null; + }; + + Users.setPublicCollapsedSwimlane = (boardId, swimlaneId, collapsed) => { + if (!boardId || !swimlaneId) return false; + const data = readCookieMap('wekan-collapsed-swimlanes'); + if (!data[boardId]) data[boardId] = {}; + data[boardId][swimlaneId] = !!collapsed; + writeCookieMap('wekan-collapsed-swimlanes', data); + return true; + }; + + Users.getPublicCardCollapsed = () => { + const data = readCookieMap('wekan-card-collapsed'); + return typeof data.state === 'boolean' ? data.state : null; + }; + + Users.setPublicCardCollapsed = collapsed => { + writeCookieMap('wekan-card-collapsed', { state: !!collapsed }); + return true; + }; +} + const allowedSortValues = [ '-modifiedAt', 'modifiedAt', @@ -187,6 +264,13 @@ Users.attachSchema( type: Boolean, optional: true, }, + 'profile.cardCollapsed': { + /** + * has user collapsed the card details? + */ + type: Boolean, + optional: true, + }, 'profile.customFieldsGrid': { /** * has user at card Custom Fields have Grid (false) or one per row (true) layout? @@ -476,6 +560,24 @@ Users.attachSchema( defaultValue: {}, blackbox: true, }, + 'profile.collapsedLists': { + /** + * Per-user collapsed state for lists. + * profile[boardId][listId] = true|false + */ + type: Object, + defaultValue: {}, + blackbox: true, + }, + 'profile.collapsedSwimlanes': { + /** + * Per-user collapsed state for swimlanes. + * profile[boardId][swimlaneId] = true|false + */ + type: Object, + defaultValue: {}, + blackbox: true, + }, 'profile.keyboardShortcuts': { /** * User-specified state of keyboard shortcut activation. @@ -522,6 +624,15 @@ Users.attachSchema( type: Boolean, defaultValue: false, }, + 'profile.cardZoom': { + /** + * User-specified zoom level for card details (1.0 = 100%, 1.5 = 150%, etc.) + */ + type: Number, + defaultValue: 1.0, + min: 0.5, + max: 3.0, + }, services: { /** * services field of the user @@ -602,7 +713,7 @@ Users.attachSchema( ); // Security helpers for user updates -export const USER_UPDATE_ALLOWED_EXACT = ['username']; +export const USER_UPDATE_ALLOWED_EXACT = ['username', 'profile']; export const USER_UPDATE_ALLOWED_PREFIXES = ['profile.']; export const USER_UPDATE_FORBIDDEN_PREFIXES = [ 'services', @@ -1311,6 +1422,135 @@ Users.helpers({ return false; } }, + // Per-user collapsed state helpers for lists/swimlanes + getCollapsedList(boardId, listId) { + const { collapsedLists = {} } = this.profile || {}; + if (collapsedLists[boardId] && typeof collapsedLists[boardId][listId] === 'boolean') { + return collapsedLists[boardId][listId]; + } + return null; + }, + getCollapsedSwimlane(boardId, swimlaneId) { + const { collapsedSwimlanes = {} } = this.profile || {}; + if (collapsedSwimlanes[boardId] && typeof collapsedSwimlanes[boardId][swimlaneId] === 'boolean') { + return collapsedSwimlanes[boardId][swimlaneId]; + } + return null; + }, + setCollapsedListToStorage(boardId, listId, collapsed) { + // Logged-in users: save to profile + if (this._id) { + return this.setCollapsedList(boardId, listId, collapsed); + } + // Public users: save to cookie + try { + const name = 'wekan-collapsed-lists'; + const stored = (typeof document !== 'undefined') ? document.cookie : ''; + const cookies = stored.split(';').map(c => c.trim()); + let json = '{}'; + for (const c of cookies) { + if (c.startsWith(name + '=')) { + json = decodeURIComponent(c.substring(name.length + 1)); + break; + } + } + let data = {}; + try { data = JSON.parse(json || '{}'); } catch (e) { data = {}; } + if (!data[boardId]) data[boardId] = {}; + data[boardId][listId] = !!collapsed; + const serialized = encodeURIComponent(JSON.stringify(data)); + const maxAge = 60 * 60 * 24 * 365; // 1 year + document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`; + return true; + } catch (e) { + console.warn('Error saving collapsed list to cookie:', e); + return false; + } + }, + getCollapsedListFromStorage(boardId, listId) { + // Logged-in users: read from profile + if (this._id) { + const v = this.getCollapsedList(boardId, listId); + return v; + } + // Public users: read from cookie + try { + const name = 'wekan-collapsed-lists'; + const stored = (typeof document !== 'undefined') ? document.cookie : ''; + const cookies = stored.split(';').map(c => c.trim()); + let json = '{}'; + for (const c of cookies) { + if (c.startsWith(name + '=')) { + json = decodeURIComponent(c.substring(name.length + 1)); + break; + } + } + const data = JSON.parse(json || '{}'); + if (data[boardId] && typeof data[boardId][listId] === 'boolean') { + return data[boardId][listId]; + } + } catch (e) { + console.warn('Error reading collapsed list from cookie:', e); + } + return null; + }, + setCollapsedSwimlaneToStorage(boardId, swimlaneId, collapsed) { + // Logged-in users: save to profile + if (this._id) { + return this.setCollapsedSwimlane(boardId, swimlaneId, collapsed); + } + // Public users: save to cookie + try { + const name = 'wekan-collapsed-swimlanes'; + const stored = (typeof document !== 'undefined') ? document.cookie : ''; + const cookies = stored.split(';').map(c => c.trim()); + let json = '{}'; + for (const c of cookies) { + if (c.startsWith(name + '=')) { + json = decodeURIComponent(c.substring(name.length + 1)); + break; + } + } + let data = {}; + try { data = JSON.parse(json || '{}'); } catch (e) { data = {}; } + if (!data[boardId]) data[boardId] = {}; + data[boardId][swimlaneId] = !!collapsed; + const serialized = encodeURIComponent(JSON.stringify(data)); + const maxAge = 60 * 60 * 24 * 365; // 1 year + document.cookie = `${name}=${serialized}; path=/; max-age=${maxAge}`; + return true; + } catch (e) { + console.warn('Error saving collapsed swimlane to cookie:', e); + return false; + } + }, + getCollapsedSwimlaneFromStorage(boardId, swimlaneId) { + // Logged-in users: read from profile + if (this._id) { + const v = this.getCollapsedSwimlane(boardId, swimlaneId); + return v; + } + // Public users: read from cookie + try { + const name = 'wekan-collapsed-swimlanes'; + const stored = (typeof document !== 'undefined') ? document.cookie : ''; + const cookies = stored.split(';').map(c => c.trim()); + let json = '{}'; + for (const c of cookies) { + if (c.startsWith(name + '=')) { + json = decodeURIComponent(c.substring(name.length + 1)); + break; + } + } + const data = JSON.parse(json || '{}'); + if (data[boardId] && typeof data[boardId][swimlaneId] === 'boolean') { + return data[boardId][swimlaneId]; + } + } catch (e) { + console.warn('Error reading collapsed swimlane from cookie:', e); + } + return null; + }, }); Users.mutations({ @@ -1485,6 +1725,14 @@ Users.mutations({ }; }, + toggleCardCollapsed(value = false) { + return { + $set: { + 'profile.cardCollapsed': !value, + }, + }; + }, + toggleLabelText(value = false) { return { $set: { @@ -1621,6 +1869,26 @@ Users.mutations({ }, }; }, + setCollapsedList(boardId, listId, collapsed) { + const current = (this.profile && this.profile.collapsedLists) || {}; + if (!current[boardId]) current[boardId] = {}; + current[boardId][listId] = !!collapsed; + return { + $set: { + 'profile.collapsedLists': current, + }, + }; + }, + setCollapsedSwimlane(boardId, swimlaneId, collapsed) { + const current = (this.profile && this.profile.collapsedSwimlanes) || {}; + if (!current[boardId]) current[boardId] = {}; + current[boardId][swimlaneId] = !!collapsed; + return { + $set: { + 'profile.collapsedSwimlanes': current, + }, + }; + }, setZoomLevel(level) { return { @@ -1637,6 +1905,14 @@ Users.mutations({ }, }; }, + + setCardZoom(level) { + return { + $set: { + 'profile.cardZoom': level, + }, + }; + }, }); Meteor.methods({ @@ -1809,6 +2085,11 @@ Meteor.methods({ const user = ReactiveCache.getCurrentUser(); user.toggleCardMaximized(user.hasCardMaximized()); }, + setCardCollapsed(value) { + check(value, Boolean); + if (!this.userId) throw new Meteor.Error('not-logged-in'); + Users.update(this.userId, { $set: { 'profile.cardCollapsed': value } }); + }, toggleMinicardLabelText() { const user = ReactiveCache.getCurrentUser(); user.toggleLabelText(user.hasHiddenMinicardLabelText()); @@ -1838,6 +2119,26 @@ Meteor.methods({ user.setListWidth(boardId, listId, width); user.setListConstraint(boardId, listId, constraint); }, + setListCollapsedState(boardId, listId, collapsed) { + check(boardId, String); + check(listId, String); + check(collapsed, Boolean); + if (!this.userId) { + throw new Meteor.Error('not-logged-in', 'User must be logged in'); + } + const user = Users.findOne(this.userId); + if (!user) { + throw new Meteor.Error('user-not-found', 'User not found'); + } + const current = (user.profile && user.profile.collapsedLists) || {}; + if (!current[boardId]) current[boardId] = {}; + current[boardId][listId] = !!collapsed; + Users.update(this.userId, { + $set: { + 'profile.collapsedLists': current, + }, + }); + }, applySwimlaneHeight(boardId, swimlaneId, height) { check(boardId, String); check(swimlaneId, String); @@ -1846,6 +2147,27 @@ Meteor.methods({ user.setSwimlaneHeight(boardId, swimlaneId, height); }, + setSwimlaneCollapsedState(boardId, swimlaneId, collapsed) { + check(boardId, String); + check(swimlaneId, String); + check(collapsed, Boolean); + if (!this.userId) { + throw new Meteor.Error('not-logged-in', 'User must be logged in'); + } + const user = Users.findOne(this.userId); + if (!user) { + throw new Meteor.Error('user-not-found', 'User not found'); + } + const current = (user.profile && user.profile.collapsedSwimlanes) || {}; + if (!current[boardId]) current[boardId] = {}; + current[boardId][swimlaneId] = !!collapsed; + Users.update(this.userId, { + $set: { + 'profile.collapsedSwimlanes': current, + }, + }); + }, + applySwimlaneHeightToStorage(boardId, swimlaneId, height) { check(boardId, String); check(swimlaneId, String);