Reverted New UI Design of WeKan v8.29 and added more fixes and performance improvements.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2026-02-08 00:48:39 +02:00
parent d152d8fc1b
commit 1b8b8d2eef
196 changed files with 17659 additions and 10028 deletions

View file

@ -1,22 +1,16 @@
.activity-title {
margin: 0 0.7vw 1vh;
display: flex;
gap: 0.5lh;
justify-content: space-between;
}
.reactions-popup {
display: flex;
gap: 1ch;
flex-wrap: wrap;
margin: 0.5lh 0.5ch;
max-width: 80vw;
}
.reactions-popup .add-comment-reaction {
display: inline-block;
cursor: pointer;
border-radius: 0.7vw;
font-size: clamp(18px, 4vw, 22px);
text-align: center;
line-height: 1.3;
font-size: 1.2em;
width: 5vw;
}
.reactions-popup .add-comment-reaction:hover {
background-color: #b0c4de;
@ -24,20 +18,20 @@
.activities {
clear: both;
}
.activity {
display: flex;
}
.activities .activity {
margin: 0.1vh 0;
padding: 0.8vh 0;
display: flex;
font-size: 0.8em;
}
.activities .activity .member {
width: 4vw;
height: 4vw;
}
.activities .activity .activity-member {
font-weight: 700;
}
.activities .activity .activity-desc {
overflow-wrap: break-word;
word-wrap: break-word;
overflow: hidden;
flex: 1;
align-self: center;

View file

@ -275,7 +275,7 @@ Template.commentReactions.events({
cardComment.toggleReaction(codepoint);
}
},
'click .open-comment-reaction-popup': Popup.open('addReaction', {showHeader: false})
'click .open-comment-reaction-popup': Popup.open('addReaction'),
})
Template.addReactionPopup.events({
@ -306,11 +306,6 @@ Template.addReactionPopup.helpers({
'😊',
'🤔',
'😔'];
},
hasUserReacted(codepoint) {
const commentId = Template.instance().data.commentId;
const cardComment = ReactiveCache.getCardComment(commentId);
return cardComment.hasUserReacted(codepoint);
}
})

View file

@ -1,12 +1,12 @@
.new-comment {
position: relative;
display: flex;
align-items: center;
justify-content: stretch;
gap: 1ch;
margin: 0 0 20px 38px;
}
.new-comment .member {
opacity: 0.7;
position: absolute;
top: 1px;
left: -38px;
}
.new-comment.is-open .member {
opacity: 1;
@ -14,44 +14,34 @@
.new-comment.is-open .helper {
display: inline-block;
}
.new-comment, .comment {
.is-open textarea {
min-height: 100px;
color: #4d4d4d;
cursor: auto;
overflow-wrap: break-word;
}
textarea {
grid-area: editor;
background-color: #fff;
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
min-height: 3lh;
&:hover, &.is-open {
cursor: auto;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.33);
border: 0;
cursor: pointer;
}
}
.new-comment.is-open textarea {
min-height: 100px;
color: #4d4d4d;
cursor: auto;
overflow: hidden;
word-wrap: break-word;
}
.new-comment .too-long {
margin-top: 8px;
}
.js-new-comment-form, .js-edit-comment {
display: grid !important;
grid-template-areas:
"editor editor editor editor"
"main-controls main-controls link-controls editor-controls";
grid-auto-columns: 1fr;
grid-auto-rows: min-content;
align-items: center;
flex: 1;
gap: 0.3lh;
.new-comment textarea {
background-color: #fff;
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
height: 36px;
margin: 4px 4px 6px 0;
padding: 9px 11px;
width: 100%;
}
.new-comment textarea:hover,
.new-comment textarea:is-open {
background-color: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.33);
border: 0;
cursor: pointer;
}
.new-comment textarea:is-open {
cursor: auto;
}
.comment-item {
background-color: #fff;
@ -75,30 +65,31 @@
}
.comments {
clear: both;
display: flex;
flex-direction: column;
gap: 1lh;
padding-top: 1lh;
}
.comments .comment {
margin: 0.5px 0;
padding: 6px 0;
display: flex;
gap: 1ch;
}
.comments .comment .member {
width: 32px;
height: 32px;
}
.comments .comment .comment-member {
font-weight: 700;
}
.comments .comment .comment-desc {
overflow-wrap: break-word;
word-wrap: break-word;
overflow: hidden;
flex: 1;
align-self: center;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3lh;
margin-left: 3px;
overflow: hidden;
word-break: break-word;
}
.comments .comment .comment-desc .comment-text {
display: flex;
display: block;
border-radius: 3px;
background: #fff;
text-decoration: none;
@ -110,7 +101,6 @@
display: flex;
margin-top: 5px;
gap: 5px;
align-items: center;
}
.comments .comment .comment-desc .reactions .open-comment-reaction-popup {
display: flex;
@ -120,6 +110,7 @@
}
.comments .comment .comment-desc .reactions .open-comment-reaction-popup span {
display: inline-block;
font-size: clamp(14px, 2vw, 18px);
font-weight: 500;
line-height: 1;
margin-left: 4px;
@ -137,14 +128,10 @@
.comments .comment .comment-desc .reactions .reaction:hover {
background-color: #b0c4de;
}
.comments .comment .comment-desc .reactions .reaction .reaction-count {
font-size: 12px;
}
.comments .comment .comment-desc .comment-meta {
font-size: 0.8em;
color: #999;
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: 1ch;
align-items: center;
/* #FIXME maybe put date outside of comment body ?*/
margin-inline-start: -10vw;
}

View file

@ -64,5 +64,5 @@ template(name="commentReactions")
template(name="addReactionPopup")
.reactions-popup
each codepoint in codepoints
unless (hasUserReacted codepoint)
span.add-comment-reaction(data-codepoint="#{codepoint}") !{codepoint}
span.add-comment-reaction(data-codepoint="#{codepoint}") !{codepoint}

View file

@ -18,7 +18,7 @@
.board-conversion-modal {
background: white;
border-radius: 0.8ch;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
@ -47,7 +47,7 @@
.board-conversion-header h3 {
margin: 0 0 8px 0;
color: #333;
font-size: 20px;
font-weight: 500;
}
@ -59,7 +59,7 @@
.board-conversion-header p {
margin: 0;
color: #666;
font-size: 14px;
}
.board-conversion-content {
@ -74,7 +74,7 @@
width: 100%;
height: 8px;
background-color: #e0e0e0;
border-radius: 0.4ch;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
@ -82,7 +82,7 @@
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #2196F3, #21CBF3);
border-radius: 0.4ch;
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
@ -116,14 +116,14 @@
text-align: center;
font-weight: 600;
color: #2196F3;
font-size: 16px;
}
.conversion-status {
text-align: center;
margin-bottom: 16px;
color: #333;
font-size: 16px;
}
.conversion-status i {
@ -134,10 +134,10 @@
.conversion-time {
text-align: center;
color: #666;
font-size: 14px;
background-color: #f5f5f5;
padding: 8px 12px;
border-radius: 0.4ch;
border-radius: 4px;
margin-bottom: 16px;
}
@ -155,7 +155,7 @@
.conversion-info {
text-align: center;
color: #666;
font-size: 13px;
line-height: 1.4;
}
@ -179,6 +179,6 @@
}
.board-conversion-header h3 {
font-size: 18px;
}
}

View file

@ -6,21 +6,21 @@ template(name="boardConversionProgress")
i.fa.fa-cog
| {{_ 'converting-board'}}
p {{_ 'converting-board-description'}}
.board-conversion-content
.conversion-progress
.progress-bar
.progress-fill(style="width: {{conversionProgress}}%")
.progress-text {{conversionProgress}}%
.conversion-status
i.fa.fa-cog
| {{conversionStatus}}
.conversion-time(style="{{#unless conversionEstimatedTime}}display: none;{{/unless}}")
i.fa.fa-clock-o
| {{_ 'estimated-time-remaining'}}: {{conversionEstimatedTime}}
.board-conversion-footer
.conversion-info
i.fa.fa-info-circle

View file

@ -1,6 +1,6 @@
import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import {
import {
boardConverter,
isConverting,
conversionProgress,
@ -12,15 +12,15 @@ Template.boardConversionProgress.helpers({
isConverting() {
return isConverting.get();
},
conversionProgress() {
return conversionProgress.get();
},
conversionStatus() {
return conversionStatus.get();
},
conversionEstimatedTime() {
return conversionEstimatedTime.get();
}

View file

@ -1,7 +1,7 @@
template(name="archivedBoards")
h2
span(title="{{_ 'archived-boards'}}")
i.fa.fa-archive
i.fa.fa-archive
| {{_ 'archived-boards'}}
ul.archived-lists

View file

@ -1,25 +1,43 @@
.swim-flex {
display: flex;
flex: 1;
flex-direction: column;
align-items: stretch;
padding-bottom: 40vw;
}
.board-wrapper {
display: flex;
flex: 1;
overflow: auto;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow-x: hidden;
overflow-y: hidden;
width: 100%;
min-width: 100%;
}
/* When zoom is 50% or lower, ensure full width like content */
.board-wrapper[style*="transform: scale(0.5)"] {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.board-wrapper[style*="transform: scale(0.4)"] {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.board-wrapper[style*="transform: scale(0.3)"] {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.board-wrapper .board-canvas {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
transition: margin 0.1s;
overflow-y: auto;
overflow-x: hidden;
display: flex;
/* don't stretch vertically if not needed (e.g collapsed) */
align-self: start;
flex: 1;
width: 100%;
min-width: 100%;
}
/* Ensure horizontal scrollbar is visible for high zoom levels */
@ -79,12 +97,172 @@
position: relative;
}
/* Force vertical scrollbar to always be visible */
#content[style*="overflow-y: auto"] {
overflow-y: scroll !important;
#content[style*="overflow-x: auto"]::-webkit-scrollbar {
height: 12px;
width: 12px;
}
/* Force vertical scrollbar to always be visible */
#content[style*="overflow-y: auto"] {
overflow-y: scroll !important;
}
/* Mobile - make all text 2x bigger inside #content by default (icons stay same size) */
@media screen and (max-width: 800px),
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) {
#content {
font-size: 2em !important; /* 2x bigger base font size for content area */
}
/* Make all text elements 2x bigger */
#content h1, #content h2, #content h3, #content h4, #content h5, #content h6,
#content p, #content span, #content div, #content a, #content button,
#content .minicard, #content .list-header-name, #content .board-header-btn,
#content .card-title, #content .card-details, #content .card-description,
#content .swimlane-header, #content .list-title, #content .card-text,
#content .member, #content .member-name, #content .member-initials,
#content .checklist-item, #content .checklist-title, #content .comment,
#content .activity, #content .activity-text, #content .activity-time,
#content .board-title, #content .board-description, #content .list-name,
#content .card-text, #content .card-title, #content .card-description,
#content .swimlane-title, #content .swimlane-description,
#content .board-header-title, #content .board-header-description,
#content .card-detail-title, #content .card-detail-description,
#content .list-header-title, #content .list-header-description,
#content .swimlane-header-title, #content .swimlane-header-description,
#content .minicard-title, #content .minicard-description,
#content .card-comment, #content .card-comment-text,
#content .checklist-item-text, #content .checklist-item-title,
#content .activity-item, #content .activity-item-text,
#content .board-member, #content .board-member-name,
#content .team-member, #content .team-member-name,
#content .org-member, #content .org-member-name,
#content .template-member, #content .template-member-name,
#content .user-name, #content .user-email, #content .user-role,
#content .setting-title, #content .setting-description,
#content .popup-title, #content .popup-description,
#content .modal-title, #content .modal-description,
#content .notification-title, #content .notification-text,
#content .announcement-title, #content .announcement-text,
#content .offline-warning-title, #content .offline-warning-text,
#content .error-title, #content .error-text,
#content .success-title, #content .success-text,
#content .info-title, #content .info-text,
#content .warning-title, #content .warning-text {
font-size: 1em !important; /* Use inherited 2x scaling */
}
/* Keep icons the same size (don't scale them) */
#content .fa, #content .icon, #content i {
font-size: 1em !important; /* Keep original icon size */
}
/* Reset specific icon sizes to prevent double scaling */
#content .fa-home, #content .fa-bars, #content .fa-search,
#content .fa-bell, #content .fa-user, #content .fa-cog,
#content .fa-plus, #content .fa-minus, #content .fa-edit,
#content .fa-trash, #content .fa-save, #content .fa-cancel,
#content .fa-arrow-left, #content .fa-arrow-right,
#content .fa-arrow-up, #content .fa-arrow-down,
#content .fa-check, #content .fa-times, #content .fa-close,
#content .fa-star, #content .fa-heart, #content .fa-thumbs-up,
#content .fa-thumbs-down, #content .fa-comment, #content .fa-reply,
#content .fa-share, #content .fa-download, #content .fa-upload,
#content .fa-copy, #content .fa-paste, #content .fa-cut,
#content .fa-undo, #content .fa-redo, #content .fa-refresh,
#content .fa-sync, #content .fa-spinner, #content .fa-loading,
#content .fa-info, #content .fa-question, #content .fa-exclamation,
#content .fa-warning, #content .fa-error, #content .fa-success,
#content .fa-check-circle, #content .fa-times-circle,
#content .fa-exclamation-circle, #content .fa-question-circle,
#content .fa-info-circle, #content .fa-warning-circle,
#content .fa-error-circle, #content .fa-success-circle {
font-size: 1em !important; /* Keep original icon size */
}
}
/* Fallback for iPhone devices using JavaScript detection */
.iphone-device #content {
font-size: 2em !important; /* 2x bigger base font size for content area */
}
.iphone-device #content h1, .iphone-device #content h2, .iphone-device #content h3, .iphone-device #content h4, .iphone-device #content h5, .iphone-device #content h6,
.iphone-device #content p, .iphone-device #content span, .iphone-device #content div, .iphone-device #content a, .iphone-device #content button,
.iphone-device #content .minicard, .iphone-device #content .list-header-name, .iphone-device #content .board-header-btn,
.iphone-device #content .card-title, .iphone-device #content .card-details, .iphone-device #content .card-description,
.iphone-device #content .swimlane-header, .iphone-device #content .list-title, .iphone-device #content .card-text,
.iphone-device #content .member, .iphone-device #content .member-name, .iphone-device #content .member-initials,
.iphone-device #content .checklist-item, .iphone-device #content .checklist-title, .iphone-device #content .comment,
.iphone-device #content .activity, .iphone-device #content .activity-text, .iphone-device #content .activity-time,
.iphone-device #content .board-title, .iphone-device #content .board-description, .iphone-device #content .list-name,
.iphone-device #content .card-text, .iphone-device #content .card-title, .iphone-device #content .card-description,
.iphone-device #content .swimlane-title, .iphone-device #content .swimlane-description,
.iphone-device #content .board-header-title, .iphone-device #content .board-header-description,
.iphone-device #content .card-detail-title, .iphone-device #content .card-detail-description,
.iphone-device #content .list-header-title, .iphone-device #content .list-header-description,
.iphone-device #content .swimlane-header-title, .iphone-device #content .swimlane-header-description,
.iphone-device #content .minicard-title, .iphone-device #content .minicard-description,
.iphone-device #content .card-comment, .iphone-device #content .card-comment-text,
.iphone-device #content .checklist-item-text, .iphone-device #content .checklist-item-title,
.iphone-device #content .activity-item, .iphone-device #content .activity-item-text,
.iphone-device #content .board-member, .iphone-device #content .board-member-name,
.iphone-device #content .team-member, .iphone-device #content .team-member-name,
.iphone-device #content .org-member, .iphone-device #content .org-member-name,
.iphone-device #content .template-member, .iphone-device #content .template-member-name,
.iphone-device #content .user-name, .iphone-device #content .user-email, .iphone-device #content .user-role,
.iphone-device #content .setting-title, .iphone-device #content .setting-description,
.iphone-device #content .popup-title, .iphone-device #content .popup-description,
.iphone-device #content .modal-title, .iphone-device #content .modal-description,
.iphone-device #content .notification-title, .iphone-device #content .notification-text,
.iphone-device #content .announcement-title, .iphone-device #content .announcement-text,
.iphone-device #content .offline-warning-title, .iphone-device #content .offline-warning-text,
.iphone-device #content .error-title, .iphone-device #content .error-text,
.iphone-device #content .success-title, .iphone-device #content .success-text,
.iphone-device #content .info-title, .iphone-device #content .info-text,
.iphone-device #content .warning-title, .iphone-device #content .warning-text {
font-size: 1em !important; /* Use inherited 2x scaling */
}
/* Keep icons the same size for iPhone devices */
.iphone-device #content .fa, .iphone-device #content .icon, .iphone-device #content i {
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;
}
#content[style*="overflow-x: auto"]::-webkit-scrollbar:horizontal {
height: 12px;
}
/* Force both scrollbars to always be visible for high zoom levels */
#content[style*="overflow-x: auto"][style*="overflow-y: auto"] {
@ -96,6 +274,36 @@
#content[style*="overflow-y: auto"] {
scrollbar-gutter: stable;
}
.board-wrapper .board-canvas .board-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
top: -100px;
right: -400px;
background: #000;
opacity: 0.33;
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 {
@ -112,14 +320,73 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
.board-wrapper .board-canvas.is-dragging-active .minicard-wrapper.is-checked {
display: none;
}
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
.board-wrapper.mobile-view {
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: 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: block !important;
flex-direction: column;
margin: 0;
padding: 0;
overflow-x: hidden !important;
overflow-y: auto;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
}
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.board-wrapper {
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 {
/* this effectively prevents board
to shrink */
min-width: 100vw;
.board-wrapper .board-canvas {
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: block !important;
flex-direction: column;
margin: 0;
padding: 0;
overflow-x: hidden !important;
overflow-y: auto;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
}
}
.calendar-event-green {
@ -278,6 +545,7 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
justify-content: center;
align-items: center;
margin: 0;
font-size: 18px;
}
.modal-footer {
display: flex;
@ -290,6 +558,10 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 5px;
right: 5px;
font-size: 25px;
cursor: pointer;
}

View file

@ -1,9 +1,13 @@
template(name="board")
if isConverting.get
+boardConversionProgress
else if isBoardReady.get
if currentBoard
+boardBody
if onlyShowCurrentCard
+cardDetails(currentCard)
else
+boardBody
else
//-- XXX We need a better error message in case the board has been archived
+message(label="board-not-found")
@ -13,32 +17,32 @@ template(name="board")
template(name="boardBody")
if notDisplayThisBoard
| {{_ 'tableVisibilityMode-allowPrivateOnly'}}
| {{_ 'tableVisibilityMode-allowPrivateOnly'}}
else
//- Debug information (remove in production)
// Debug information (remove in production)
if debugBoardState
//- Debug information (remove in production)
.debug-info(style="position: fixed; top: 0; left: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
.board-canvas.js-swimlanes(
class="{{#if hasSwimlanes}}dragscroll{{/if}}"
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
class="{{#if draggingActive.get}}is-dragging-active{{/if}}"
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}"
class="{{#if isMiniScreen}}mobile-view{{/if}}")
if showOverlay.get
.board-overlay
if currentBoard.isTemplatesBoard
.swim-flex
each currentBoard.swimlanes
+swimlane(this)
else if isViewSwimlanes
if hasSwimlanes
each currentBoard.swimlanes
+swimlane(this)
else if isViewSwimlanes
.swim-flex
if hasSwimlanes
each currentBoard.swimlanes
+swimlane(this)
else
// Fallback: If no swimlanes exist, show lists instead of empty message
+listsGroup(currentBoard)
else
// Fallback: If no swimlanes exist, show lists instead of empty message
+listsGroup(currentBoard)
else if isViewLists
+listsGroup(currentBoard)
else if isViewCalendar
@ -52,6 +56,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")

View file

@ -105,6 +105,7 @@ BlazeComponent.extendComponent({
this.isBoardReady.set(true); // Show board even if conversion check failed
}
},
onlyShowCurrentCard() {
const isMiniScreen = Utils.isMiniScreen();
const currentCardId = Utils.getCurrentCardId(true);
@ -113,7 +114,7 @@ BlazeComponent.extendComponent({
openCards() {
// In desktop mode, return array of all open cards
const isMobile = Utils.isMiniScreen();
const isMobile = Utils.getMobileMode();
if (!isMobile) {
const openCardIds = Session.get('openCards') || [];
return openCardIds.map(id => ReactiveCache.getCard(id)).filter(card => card);
@ -122,7 +123,7 @@ BlazeComponent.extendComponent({
},
goHome() {
FlowRouter.go('home')
FlowRouter.go('home');
},
isConverting() {
@ -194,7 +195,7 @@ BlazeComponent.extendComponent({
}
},
onRendered() {
// Initialize user settings (mobile mode)
// Initialize user settings (zoom and mobile mode)
Utils.initializeUserSettings();
// Detect iPhone devices and add class for better CSS targeting
@ -390,24 +391,23 @@ BlazeComponent.extendComponent({
helper(evt, item) {
const helper = $(`<div class="swimlane"
style="flex-direction: column;
max-height: 30vh;
width: 100vw;
overflow: hidden; z-index: 100;"/>`);
height: ${swimlaneWhileSortingHeight}px;
width: $(boardComponent.width)px;
overflow: hidden;"/>`);
helper.append(item.clone());
// Also grab the list of lists of cards
const list = item.next();
helper.append(list.clone());
return helper;
},
items: '.swimlane-container',
items: '.swimlane:not(.placeholder)',
placeholder: 'swimlane placeholder',
distance: 7,
start(evt, ui) {
const listDom = ui.placeholder.next('.js-swimlane');
const parentOffset = ui.item.parent().offset();
height = ui.helper.height();
ui.placeholder[0].setAttribute('style', `height: ${height}px !important;`);
ui.placeholder.height(ui.helper.height());
EscapeActions.executeUpTo('popup-close');
listDom.addClass('moving-swimlane');
boardComponent.setIsDragging(true);
@ -415,19 +415,40 @@ BlazeComponent.extendComponent({
ui.placeholder.insertAfter(ui.placeholder.next());
boardComponent.origPlaceholderIndex = ui.placeholder.index();
// resize all swimlanes + headers to be a total of 150 px per row
// this could be achieved by setIsDragging(true) but we want immediate
// result
ui.item
.siblings('.js-swimlane')
.css('height', `${swimlaneWhileSortingHeight - 26}px`);
// set the new scroll height after the resize and insertion of
// the placeholder. We want the element under the cursor to stay
// at the same place on the screen
ui.item.parent().get(0).scrollTop =
ui.placeholder.get(0).offsetTop + parentOffset.top - evt.pageY;
},
beforeStop(evt, ui) {
const parentOffset = ui.item.parent().offset();
const siblings = ui.item.siblings('.js-swimlane');
siblings.css('height', '');
// compute the new scroll height after the resize and removal of
// the placeholder
const scrollTop =
ui.placeholder.get(0).offsetTop + parentOffset.top - evt.pageY;
// then reset the original view of the swimlane
siblings.removeClass('moving-swimlane');
// and apply the computed scrollheight
ui.item.parent().get(0).scrollTop = scrollTop;
},
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 prevSwimlaneDom = ui.item.prevAll('.swimlane-container').get(0);
const nextSwimlaneDom = ui.item.nextAll('.swimlane-container').get(0);
const prevSwimlaneDom = ui.item.prevAll('.js-swimlane').get(0);
const nextSwimlaneDom = ui.item.nextAll('.js-swimlane').get(0);
const sortIndex = calculateIndex(prevSwimlaneDom, nextSwimlaneDom, 1);
$swimlanesDom.sortable('cancel');
@ -443,7 +464,39 @@ BlazeComponent.extendComponent({
boardComponent.setIsDragging(false);
},
sort(evt, ui) {
Utils.scrollIfNeeded(evt);
// get the mouse position in the sortable
const parentOffset = ui.item.parent().offset();
const cursorY =
evt.pageY - parentOffset.top + ui.item.parent().scrollTop();
// compute the intended index of the placeholder (we need to skip the
// slots between the headers and the list of cards)
const newplaceholderIndex = Math.floor(
cursorY / swimlaneWhileSortingHeight,
);
let destPlaceholderIndex = (newplaceholderIndex + 1) * 2;
// if we are scrolling far away from the bottom of the list
if (destPlaceholderIndex >= ui.item.parent().get(0).childElementCount) {
destPlaceholderIndex = ui.item.parent().get(0).childElementCount - 1;
}
// update the placeholder position in the DOM tree
if (destPlaceholderIndex !== ui.placeholder.index()) {
if (destPlaceholderIndex < boardComponent.origPlaceholderIndex) {
ui.placeholder.insertBefore(
ui.placeholder
.siblings()
.slice(destPlaceholderIndex - 2, destPlaceholderIndex - 1),
);
} else {
ui.placeholder.insertAfter(
ui.placeholder
.siblings()
.slice(destPlaceholderIndex - 1, destPlaceholderIndex),
);
}
}
},
});
@ -452,10 +505,10 @@ BlazeComponent.extendComponent({
dragscroll.reset();
if ($swimlanesDom.data('uiSortable') || $swimlanesDom.data('sortable')) {
if (Utils.isMiniScreen()) {
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$swimlanesDom.sortable('option', 'handle', '.js-swimlane-header-handle');
} else {
$swimlanesDom.sortable('option', 'handle', '.swimlane-header-wrap');
$swimlanesDom.sortable('option', 'handle', '.swimlane-header');
}
// Disable drag-dropping if the current user is not a board member
@ -972,3 +1025,4 @@ BlazeComponent.extendComponent({
* Gantt View Component
* Displays cards as a Gantt chart with start/due dates
*/

File diff suppressed because it is too large Load diff

View file

@ -22,90 +22,918 @@
padding: 0.7vh 0.7vw;
}
.board-header {
display: grid;
flex: 1;
gap: 0.3lh;
}
body {
&.mobile-mode {
.board-header {
flex-wrap: wrap;
}
}
&:not(.mobile-mode) {
.header-board-menu {
flex: 1;
}
.board-header {
justify-content: space-between;
grid-auto-flow: column;
}
.board-header-btns-left {
flex: 1;
justify-content: center;
}
.board-header-btns-right {
flex-grow: 0;
justify-content: end;
}
& .board-header-btns-right,
& .board-header-btns-left,
& .header-board-menu {
align-self: center;
align-items: center;
display: flex;
gap: 1.5ch;
overflow-wrap: normal;
}
}
}
/* Make some space on intermediate layouts */
@media screen and (max-width: 1200px) {
.board-header-btns-right span {
display: none !important;
}
}
.header-board-menu, .board-header-btns {
/* Zoom and Mobile Mode Controls */
.board-header-btns.center {
display: flex;
align-self: center;
align-items: center;
justify-content: center;
gap: 1ch;
& p {
margin: 0;
flex: 1;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 0.5vw;
background: rgba(255, 255, 255, 0.9);
padding: 0.5vh 1vw;
border-radius: 0.5vw;
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
}
.zoom-controls .board-header-btn {
padding: 0.5vh 0.8vw !important;
border-radius: 0.3vw !important;
background: #fff !important;
border: 1px solid #000 !important;
transition: all 0.2s ease !important;
color: #000 !important;
height: auto !important;
line-height: normal !important;
margin: 0 !important;
float: none !important;
overflow: visible !important;
}
.zoom-controls .board-header-btn i {
color: #000 !important;
float: none !important;
display: inline !important;
line-height: normal !important;
margin: 0 !important;
}
.zoom-controls .board-header-btn:hover {
background: #000 !important;
border-color: #000 !important;
color: #fff !important;
}
.zoom-controls .board-header-btn:hover i {
color: #fff !important;
}
.zoom-controls .board-header-btn.is-active {
background: #0079bf;
color: white;
border-color: #005a8a;
}
.zoom-controls .board-header-btn.is-active i {
color: white;
}
.zoom-level {
font-weight: bold;
color: #333;
min-width: 3vw;
text-align: center;
font-size: clamp(12px, 2vw, 14px);
cursor: pointer;
padding: 0.3vh 0.5vw;
border-radius: 0.3vw;
transition: all 0.2s ease;
}
.zoom-level:hover {
background: #f0f0f0;
color: #000;
}
/* Mobile Mode Styles */
.mobile-mode .board-wrapper {
width: 100%;
height: 100%;
}
.mobile-mode .board-canvas {
height: 100%;
}
.mobile-mode .minicard {
font-size: clamp(16px, 4vw, 20px);
padding: 1.2vh 1.5vw 0.5vh;
min-height: 3vh;
}
.mobile-mode .list-header-name {
font-size: clamp(18px, 4.5vw, 24px);
}
.mobile-mode .board-header-btn {
padding: 1vh 1.5vw;
font-size: clamp(14px, 3.5vw, 18px);
}
.mobile-mode .zoom-controls {
padding: 1vh 1.5vw;
gap: 1vw;
}
.mobile-mode .zoom-controls .board-header-btn {
padding: 1vh 1.5vw !important;
font-size: clamp(14px, 3.5vw, 18px) !important;
background: #fff !important;
border: 1px solid #000 !important;
color: #000 !important;
height: auto !important;
line-height: normal !important;
margin: 0 !important;
float: none !important;
overflow: visible !important;
}
.mobile-mode .zoom-controls .board-header-btn i {
color: #000 !important;
float: none !important;
display: inline !important;
line-height: normal !important;
margin: 0 !important;
}
.mobile-mode .zoom-controls .board-header-btn:hover {
background: #000 !important;
border-color: #000 !important;
color: #fff !important;
}
.mobile-mode .zoom-controls .board-header-btn:hover i {
color: #fff !important;
}
.mobile-mode .zoom-level {
font-size: clamp(14px, 3.5vw, 18px);
min-width: 4vw;
}
/* Comprehensive Mobile Mode Styles - Works on all screen sizes */
.mobile-mode .board-wrapper {
width: 100% !important;
height: 100% !important;
transform: none !important;
transform-origin: initial !important;
max-width: 100% !important;
}
.mobile-mode .board-canvas {
height: 100% !important;
overflow-x: hidden !important;
overflow-y: auto !important;
width: 100% !important;
max-width: 100% !important;
}
.mobile-mode .swimlane {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
margin-bottom: 2rem !important;
display: block !important;
float: none !important;
}
.mobile-mode .swimlane-header {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
font-size: clamp(18px, 2.5vw, 32px) !important;
padding: 1rem !important;
margin-bottom: 1rem !important;
display: block !important;
}
.mobile-mode .list {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
display: block !important;
float: none !important;
margin-bottom: 2rem !important;
border-left: none !important;
border-bottom: 2px solid #ccc !important;
clear: both !important;
}
.mobile-mode .list-header {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding: 1rem !important;
font-size: clamp(18px, 2.5vw, 32px) !important;
display: grid !important;
grid-template-columns: 30px 1fr auto auto !important;
gap: 10px !important;
align-items: center !important;
position: relative !important;
}
.mobile-mode .list-header .list-header-name {
font-size: clamp(18px, 2.5vw, 32px) !important;
font-weight: bold !important;
grid-row: 1 !important;
grid-column: 2 !important;
align-self: end !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-header .cardCount {
font-size: clamp(14px, 2vw, 24px) !important;
grid-row: 2 !important;
grid-column: 2 !important;
align-self: start !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-header .list-header-menu-icon {
position: static !important;
right: auto !important;
top: auto !important;
transform: none !important;
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: clamp(24px, 3vw, 48px) !important;
text-align: center !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-header .list-header-handle {
position: static !important;
right: auto !important;
top: auto !important;
transform: none !important;
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: clamp(28px, 3.5vw, 56px) !important;
text-align: center !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-body {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding: 1rem !important;
display: block !important;
}
.mobile-mode .minicard {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
font-size: clamp(16px, 2vw, 24px) !important;
padding: 1.2vh 1.5vw 0.5vh !important;
min-height: 3vh !important;
margin-bottom: 0.5rem !important;
display: block !important;
float: none !important;
}
.mobile-mode .minicard .minicard-title {
font-size: clamp(16px, 2vw, 24px) !important;
font-weight: bold !important;
}
.mobile-mode .minicard .minicard-members {
font-size: clamp(12px, 1.5vw, 18px) !important;
}
.mobile-mode .minicard .minicard-lists {
font-size: clamp(12px, 1.5vw, 18px) !important;
}
/* Desktop Mode Styles */
.desktop-mode .board-wrapper {
width: auto !important;
height: auto !important;
}
.desktop-mode .swimlane {
width: auto !important;
min-width: auto !important;
}
.desktop-mode .list {
width: auto !important;
min-width: auto !important;
display: flex !important;
float: left !important;
margin-bottom: 0 !important;
border-left: 1px solid #ccc !important;
border-bottom: none !important;
}
.desktop-mode .list-header {
width: auto !important;
min-width: auto !important;
padding: 2.5vh 1.5vw 0.5vh !important;
font-size: clamp(14px, 3vw, 18px) !important;
display: block !important;
}
.desktop-mode .list-header .list-header-name {
font-size: clamp(14px, 3vw, 18px) !important;
display: inline !important;
grid-row: auto !important;
grid-column: auto !important;
align-self: auto !important;
}
.desktop-mode .list-header .cardCount {
font-size: 12px !important;
grid-row: auto !important;
grid-column: auto !important;
align-self: auto !important;
}
.desktop-mode .list-header .list-header-menu-icon {
position: absolute !important;
right: 60px !important;
top: 50% !important;
transform: translateY(-50%) !important;
grid-row: auto !important;
grid-column: auto !important;
padding: 14px !important;
font-size: 40px !important;
}
.desktop-mode .list-header .list-header-handle {
position: absolute !important;
right: 10px !important;
top: 50% !important;
transform: translateY(-50%) !important;
grid-row: auto !important;
grid-column: auto !important;
padding: 7px !important;
font-size: clamp(16px, 3vw, 20px) !important;
}
.desktop-mode .list-body {
width: auto !important;
min-width: auto !important;
padding: 5px 11px !important;
}
.desktop-mode .minicard {
width: auto !important;
min-width: auto !important;
font-size: clamp(12px, 2.5vw, 16px) !important;
padding: 0.5vh 0.8vw !important;
min-height: auto !important;
margin-bottom: 9px !important;
}
.desktop-mode .minicard .minicard-title {
font-size: clamp(12px, 2.5vw, 16px) !important;
}
.desktop-mode .minicard .minicard-members {
font-size: 10px !important;
}
.desktop-mode .minicard .minicard-lists {
font-size: 10px !important;
}
/* Additional Mobile Mode Styles for Other Elements - Works on all screen sizes */
.mobile-mode .swimlane-header .swimlane-title {
font-size: clamp(18px, 2.5vw, 32px) !important;
font-weight: bold !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .swimlane-header .swimlane-description {
font-size: clamp(14px, 2vw, 24px) !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header {
font-size: clamp(18px, 2.5vw, 32px) !important;
padding: 1rem !important;
width: 100% !important;
max-width: 100% !important;
}
.mobile-mode .board-header .board-header-title {
font-size: clamp(18px, 2.5vw, 32px) !important;
font-weight: bold !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header .board-header-description {
font-size: clamp(14px, 2vw, 24px) !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header .board-header-btn {
font-size: clamp(14px, 2vw, 24px) !important;
padding: 1vh 1.5vw !important;
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header .board-header-btn i {
font-size: clamp(14px, 2vw, 24px) !important;
display: inline !important;
visibility: visible !important;
opacity: 1 !important;
}
/* Force mobile mode visibility on all screen sizes */
.mobile-mode .list-header .fa-angle-right,
.mobile-mode .list-header .fa-arrows {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
position: static !important;
right: auto !important;
top: auto !important;
transform: none !important;
}
.mobile-mode .list-header .fa-angle-right {
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: clamp(24px, 3vw, 48px) !important;
text-align: center !important;
}
.mobile-mode .list-header .fa-arrows {
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: clamp(28px, 3.5vw, 56px) !important;
text-align: center !important;
}
/* Override any media queries that might hide elements in mobile mode */
.mobile-mode * {
max-width: none !important;
}
.mobile-mode .list,
.mobile-mode .swimlane,
.mobile-mode .board-wrapper,
.mobile-mode .board-canvas {
max-width: 100% !important;
width: 100% !important;
min-width: 100% !important;
}
/* Force mobile mode list styling on all screen sizes - override desktop CSS */
.mobile-mode .board-canvas {
display: block !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !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: 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: 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: 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: 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;
margin-top: 0 !important;
}
.mobile-mode .list:last-child {
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.mobile-mode .list.ui-sortable-helper {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
height: auto !important;
min-height: 60px !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;
}
.mobile-mode .list.placeholder {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
height: auto !important;
min-height: 60px !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;
}
/* Override any existing responsive CSS that might interfere with mobile mode */
.mobile-mode .board-canvas .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;
overflow: visible !important;
}
.mobile-mode .board-canvas .swimlane .lists .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;
}
/* Force mobile mode to override any media query styles */
@media screen and (min-width: 801px) {
.mobile-mode .board-canvas {
display: block !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !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 .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 .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;
}
}
.board-header-btns-right > a {
flex-wrap: no-wrap;
/* Hide desktop-only elements in mobile mode (like mobile media queries do) */
.mobile-mode .board-header-btn i.fa + span {
display: none !important;
}
body.mobile-mode {
header-board-menu h1 {
font-size: 2em;
}
.board-header-btn {
/* avoid wrapping if possible, at the cost of little icons */
font-size: 0.5em;
/* no much choice because the way FA icons are inserted */
padding-top: 0.1lh;
min-height: 0.8lh;
}
.board-header-btns-right {
display: grid;
grid-auto-flow: column;
grid-template-columns: repeat(auto-fit, 1fr);
flex: 1;
gap: 1ch;
justify-content: start;
align-items: center;
}
.mobile-mode .board-header-btn span {
display: none !important;
}
.board-header-btns-left {
display: flex;
flex: 1;
gap: 2ch;
padding: 0 0.5ch;
.mobile-mode .board-header-btn .fa + span {
display: none !important;
}
.mobile-mode .board-header-btn .fa + .board-header-btn-text {
display: none !important;
}
.mobile-mode .board-header-btn .fa + .board-header-btn-label {
display: none !important;
}
/* Show only icons in mobile mode */
.mobile-mode .board-header-btn {
width: auto !important;
min-width: auto !important;
padding: 8px !important;
text-align: center !important;
}
.mobile-mode .board-header-btn i {
display: inline-block !important;
margin: 0 !important;
}
/* Hide desktop-specific elements that shouldn't show in mobile mode */
.mobile-mode .desktop-only,
.mobile-mode .board-header .desktop-only {
display: none !important;
}
.mobile-mode .board-header .board-header-btn.desktop-only {
display: none !important;
}
/* Hide desktop-specific board header buttons in mobile mode */
.mobile-mode .board-header-btns.left {
display: none !important;
}
.mobile-mode .board-header-btns.center {
display: none !important;
}
/* Show only the right section buttons in mobile mode, but hide text labels */
.mobile-mode .board-header-btns.right {
display: block !important;
}
.mobile-mode .board-header-btns.right .board-header-btn span {
display: none !important;
}
.mobile-mode .board-header-btns.right .board-header-btn .fa + span {
display: none !important;
}
.mobile-mode .board-header-btns.right .board-header-btn .fa + .board-header-btn-text {
display: none !important;
}
.mobile-mode .board-header-btns.right .board-header-btn .fa + .board-header-btn-label {
display: none !important;
}
/* Hide specific desktop-only buttons that shouldn't show in mobile mode */
.mobile-mode .board-header-btn.js-star-board span,
.mobile-mode .board-header-btn.js-change-visibility span,
.mobile-mode .board-header-btn.js-watch-board span,
.mobile-mode .board-header-btn.js-sort-cards span {
display: none !important;
}
/* Show only icons for mobile mode buttons */
.mobile-mode .board-header-btns.right .board-header-btn {
width: auto !important;
min-width: auto !important;
padding: 8px !important;
text-align: center !important;
margin: 0 2px !important;
}
.mobile-mode .board-header-btns.right .board-header-btn i {
display: inline-block !important;
margin: 0 !important;
}
/* Ensure mobile mode looks like small screen mobile view */
.mobile-mode .board-header {
height: 40px !important;
}
.mobile-mode .board-header .board-header-btns {
margin-top: 0px !important;
}
.mobile-mode .board-header .board-header-btn {
height: 32px !important;
line-height: 32px !important;
font-size: 15px !important;
}
.mobile-mode .board-header .board-header-btn i.fa {
line-height: 32px !important;
}
/* Copy mobile media query styles to mobile mode for consistent appearance */
.mobile-mode .board-header {
height: 40px !important;
padding: 0 !important;
margin: 0 !important;
}
.mobile-mode .board-header .board-header-btns {
margin-top: 0px !important;
height: 40px !important;
display: flex !important;
align-items: center !important;
justify-content: flex-end !important;
}
.mobile-mode .board-header .board-header-btn {
height: 32px !important;
line-height: 32px !important;
font-size: 15px !important;
margin: 0 2px !important;
padding: 4px 8px !important;
border-radius: 4px !important;
background: rgba(255, 255, 255, 0.1) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: #fff !important;
text-decoration: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 32px !important;
width: auto !important;
}
.mobile-mode .board-header .board-header-btn:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
.mobile-mode .board-header .board-header-btn i.fa {
line-height: 32px !important;
font-size: 15px !important;
margin: 0 !important;
padding: 0 !important;
}
.mobile-mode .board-header .board-header-btn i.fa + span {
display: none !important;
}
.mobile-mode .board-header .board-header-btn span {
display: none !important;
}
/* Hide the board title in mobile mode to match mobile view */
.mobile-mode .header-board-menu {
display: none !important;
}
/* Ensure the board header takes full width in mobile mode */
.mobile-mode .board-header {
width: 100% !important;
max-width: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding: 0 10px !important;
}
/* Additional Desktop Mode Styles for Other Elements */
.desktop-mode .swimlane-header .swimlane-title {
font-size: clamp(14px, 3vw, 18px) !important;
}
.desktop-mode .swimlane-header .swimlane-description {
font-size: 12px !important;
}
.desktop-mode .board-header {
font-size: clamp(14px, 3vw, 18px) !important;
padding: 2.5vh 1.5vw 0.5vh !important;
}
.desktop-mode .board-header .board-header-title {
font-size: clamp(14px, 3vw, 18px) !important;
}
.desktop-mode .board-header .board-header-description {
font-size: 12px !important;
}
.desktop-mode .board-header .board-header-btn {
font-size: clamp(12px, 2.5vw, 16px) !important;
padding: 0.5vh 0.8vw !important;
}
.desktop-mode .board-header .board-header-btn i {
font-size: clamp(12px, 2.5vw, 16px) !important;
}

View file

@ -1,67 +1,26 @@
template(name="boardHeaderBar")
.board-header
.header-board-menu
with currentBoard
if $eq title 'Templates'
| {{_ 'templates'}}
else
+viewer
= title
if currentBoard
if currentUser
with currentBoard
if currentUser.isBoardAdmin
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
unless isMiniScreen
.board-header-btns-left
if currentBoard
if currentUser
with currentBoard
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ currentBoard.permission}}
h1.header-board-menu
with currentBoard
if $eq title 'Templates'
| {{_ 'templates'}}
else
+viewer
= title
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
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'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span.board-star-counter {{currentBoard.stars}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
span {{_ 'log-in'}}
.board-header-btns-right
.separator
.board-header-btns.left
unless isMiniScreen
if currentBoard
if isMiniScreen
if currentUser
with currentBoard
if currentUser
with currentBoard
if currentUser.isBoardAdmin
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ currentBoard.permission}}
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
@ -70,43 +29,89 @@ template(name="boardHeaderBar")
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
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'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span.board-star-counter {{currentBoard.stars}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
span {{_ 'log-in'}}
.board-header-btns.center
.board-header-btns.right
if currentBoard
if isMiniScreen
if currentUser
with currentBoard
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
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'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
if isSandstorm
if currentUser
a.js-open-archived-board
i.fa.fa-archive
if isSandstorm
if currentUser
a.board-header-btn.js-open-archived-board
i.fa.fa-archive
//if showSort
// a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
// i.fa(class="{{directionClass}}")
// span {{_ 'sort'}}{{_ listSortShortDesc}}
//if showSort
//
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
//
i.fa(class="{{directionClass}}")
//
span {{_ 'sort'}}{{_ listSortShortDesc}}
a.board-header-btn.js-open-filter-view(
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
i.fa.fa-filter
span {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
if Filter.isActive
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
a.board-header-btn.js-open-filter-view(
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
i.fa.fa-filter
span {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
if Filter.isActive
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
i.fa.fa-search
span {{_ 'search'}}
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
i.fa.fa-search
span {{_ 'search'}}
unless currentBoard.isTemplatesBoard
a.board-header-btn.js-toggle-board-view
@ -138,9 +143,9 @@ template(name="boardHeaderBar")
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
.separator
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
.separator
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
template(name="boardVisibilityList")
ul.pop-over-list
@ -170,29 +175,26 @@ template(name="boardChangeWatchPopup")
li
with "watching"
a.js-select-watch
span
i.fa.fa-eye
| {{_ 'watching'}}
if watchCheck
i.fa.fa-check
i.fa.fa-eye
| {{_ 'watching'}}
if watchCheck
i.fa.fa-check
span.sub-name {{_ 'watching-info'}}
li
with "tracking"
a.js-select-watch
span
i.fa.fa-bell
| {{_ 'tracking'}}
if watchCheck
i.fa.fa-check
i.fa.fa-bell
| {{_ 'tracking'}}
if watchCheck
i.fa.fa-check
span.sub-name {{_ 'tracking-info'}}
li
with "muted"
a.js-select-watch
span
i.fa.fa-bell-slash
| {{_ 'muted'}}
if watchCheck
i.fa.fa-check
i.fa.fa-bell-slash
| {{_ 'muted'}}
if watchCheck
i.fa.fa-check
span.sub-name {{_ 'muted-info'}}
template(name="boardChangeViewPopup")
@ -248,13 +250,12 @@ template(name="createBoard")
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
.create-element-foooter
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
template(name="createBoardPopup")
form
@ -278,13 +279,12 @@ template(name="createBoardPopup")
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
.create-element-foooter
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
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")
@ -308,28 +308,39 @@ template(name="createTemplateContainerPopup")
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
.create-element-foooter
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
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'}}
// hr
// ul.pop-over-list
// each value in allowedSortValues
// li
// a.js-sort-by(name="{{value.name}}")
// if $eq sortby value.name
// | {{#if $eq Direction "fa-arrow-up"}}⬆️{{else}}⬇️{{/if}}
// | {{_ value.label }}{{_ value.shortLabel}}
// if $eq sortby value.name
// i.fa.fa-check
//
h2
//
| {{_ 'list-sort-by'}}
//
hr
//
ul.pop-over-list
//
each value in allowedSortValues
//
li
//
a.js-sort-by(name="{{value.name}}")
//
if $eq sortby value.name
//
| {{#if $eq Direction "fa-arrow-up"}}⬆️{{else}}⬇️{{/if}}
//
| {{_ value.label }}{{_ value.shortLabel}}
//
if $eq sortby value.name
//
i.fa.fa-check
template(name="boardChangeTitlePopup")
form
label
@ -366,3 +377,4 @@ template(name="cardsSortPopup")
a.js-sort-created-asc
i.fa.fa-arrow-up
| {{_ 'created-at-oldest-first'}}

View file

@ -33,6 +33,9 @@ BlazeComponent.extendComponent({
const currentBoard = Utils.getCurrentBoard();
return currentBoard && currentBoard.getWatchLevel(Meteor.userId());
},
isStarred() {
const boardId = Session.get('currentBoard');
const user = ReactiveCache.getCurrentUser();

File diff suppressed because it is too large Load diff

View file

@ -67,71 +67,81 @@ template(name="boardList")
// Right boards grid
.boards-right-grid
.boards-path-header
.path-bottom
.path-left
span.path-icon.emoji-icon {{currentMenuPath.icon}}
span.path-text {{currentMenuPath.text}}
.path-right
unless isMiniScreen
+headerMultiSelection
if canModifyBoards
a.board-header-btn.js-multiselection-activate(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
.path-left
span.path-icon.emoji-icon {{currentMenuPath.icon}}
span.path-text {{currentMenuPath.text}}
if BoardMultiSelection.isActive
span.multiselection-hint
span.emoji-icon
i.fa.fa-thumb-tack
| {{_ 'multi-selection-active'}}
.path-right
if canModifyBoards
if hasBoardsSelected
button.js-archive-selected-boards.board-header-btn
span.emoji-icon
i.fa.fa-check-square-o
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
i.fa.fa-archive
span {{_ 'archive-board'}}
button.js-duplicate-selected-boards.board-header-btn
span.emoji-icon
i.fa.fa-clipboard
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}}")
span.emoji-icon
i.fa.fa-check-square-o
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
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'}}")
span.emoji-icon
i.fa.fa-plus
| {{_ 'add-template-container'}}
| &nbsp;{{_ 'add-template-container'}}
else
a.board-list-item.label(title="{{_ 'add-board'}}")
span.emoji-icon
i.fa.fa-plus
| {{_ 'add-board'}}
| &nbsp;{{_ '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
.board-card-header
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'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
.board-card-body
span.details
span.board-list-item-name= title
| {{#if isStarred}}⭐{{else}}☆{{/if}}
p.board-list-item-desc {{_ 'just-invited'}}
button.js-accept-invite.primary {{_ 'accept'}}
button.js-decline-invite {{_ 'decline'}}
.board-card-footer
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isActive }}active{{/if}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
else
if $eq type "template-container"
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
.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}}")
.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'}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
span.details
span.board-list-item-name(title="{{_ 'template-container'}}")
+viewer
= title
//- #FIXME: is this obsolete ?
//- p.board-list-item-desc
//- +viewer
//- = description
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}}"
@ -144,20 +154,19 @@ template(name="boardList")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
else
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
.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'}}")
span.emoji-icon
i.fa.fa-arrows
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
//- p.board-list-item-desc
//- +viewer
//- = description
unless currentSetting.hideBoardMemberList
if allowsBoardMemberList
.minicard-members
@ -166,24 +175,40 @@ template(name="boardList")
+userAvatar(userId=member noRemove=true)
unless currentSetting.hideCardCounterList
if allowsCardCounterList
.minicard-lists
.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}}")
span.emoji-icon
i.fa.fa-clock-o
a.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
a.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
template(name="boardListHeaderBar")
h1 {{_ title }}
//.board-header-btns.right
//
a.board-header-btn.js-open-archived-board
//
i.fa.fa-archive
//
span {{_ 'archives'}}
//
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")
@ -212,16 +237,3 @@ template(name="workspaceTree")
a.js-add-subworkspace(data-id="{{id}}" title="{{_ 'allboards.add-subworkspace'}}") +
if children
+workspaceTree(nodes=children selectedWorkspaceId=selectedWorkspaceId)
template(name="headerMultiSelection")
if BoardMultiSelection.isActive
if canModifyBoards
if hasBoardsSelected
button.negate.js-archive-selected-boards.board-header-btn
span.emoji-icon
i.fa.fa-archive
span {{_ 'archive-board'}}
button.negate.js-duplicate-selected-boards.board-header-btn
span.emoji-icon
i.fa.fa-clipboard
span {{_ 'duplicate-board'}}

View file

@ -108,7 +108,10 @@ BlazeComponent.extendComponent({
const newTree = EJSON.clone(tree);
// Remove the dragged space
const { tree: treeAfterRemoval, removed } = removeSpace(newTree, draggedSpaceId);
const { tree: treeAfterRemoval, removed } = removeSpace(
newTree,
draggedSpaceId,
);
if (removed) {
// Insert after target
@ -124,39 +127,46 @@ BlazeComponent.extendComponent({
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)';
// #FIXME OLD SORTABLE CODE - WILL BE DISABLED
//
// const itemsSelector = '.js-board';
const $boards = this.$('.js-boards');
$boards.sortable({
connectWith: '.js-boards',
tolerance: 'pointer',
appendTo: '.board-list',
helper: 'clone',
distance: 7,
items: itemsSelector,
placeholder: 'board-wrapper placeholder',
start(evt, ui) {
ui.helper.css('z-index', 1000);
ui.placeholder.height(ui.helper.height());
EscapeActions.executeUpTo('popup-close');
},
async stop(evt, ui) {
const prevBoardDom = ui.item.prev('.js-board').get(0);
const nextBoardDom = ui.item.next('.js-board').get(0);
const sortIndex = Utils.calculateIndex(prevBoardDom, nextBoardDom, 1);
// const $boards = this.$('.js-boards');
// $boards.sortable({
// connectWith: '.js-boards',
// tolerance: 'pointer',
// appendTo: '.board-list',
// helper: 'clone',
// distance: 7,
// items: itemsSelector,
// placeholder: 'board-wrapper placeholder',
// start(evt, ui) {
// ui.helper.css('z-index', 1000);
// ui.placeholder.height(ui.helper.height());
// EscapeActions.executeUpTo('popup-close');
// },
// async stop(evt, ui) {
// const prevBoardDom = ui.item.prev('.js-board').get(0);
// 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);
$boards.sortable('cancel');
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser && typeof currentUser.setBoardSortIndex === 'function') {
await currentUser.setBoardSortIndex(board._id, sortIndex.base);
}
},
});
// const boardDomElement = ui.item.get(0);
// const board = Blaze.getData(boardDomElement);
// $boards.sortable('cancel');
// const currentUser = ReactiveCache.getCurrentUser();
// if (currentUser && typeof currentUser.setBoardSortIndex === 'function') {
// await currentUser.setBoardSortIndex(board._id, sortIndex.base);
// }
// },
// });
this.autorun(() => {
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$boards.sortable({
handle: '.board-handle',
});
}
});
*/
},
userHasTeams() {
if (ReactiveCache.getCurrentUser()?.teams?.length > 0) return true;
@ -347,7 +357,7 @@ BlazeComponent.extendComponent({
const lists = ReactiveCache.getLists({ 'boardId': boardId, 'archived': false },{sort: ['sort','asc']});
const ret = lists.map(list => {
let cardCount = ReactiveCache.getCards({ 'boardId': boardId, 'listId': list._id }).length;
return `${list.title}: ${cardCountcardCount}`;
return `${list.title}: ${cardCount}`;
});
return ret;
*/
@ -525,7 +535,6 @@ BlazeComponent.extendComponent({
'click .js-multiselection-reset'(evt) {
evt.preventDefault();
BoardMultiSelection.disable();
Popup.close();
},
'click .js-toggle-board-multi-selection'(evt) {
evt.preventDefault();
@ -699,7 +708,6 @@ BlazeComponent.extendComponent({
icon: newIcon || '📁',
});
Meteor.call('setWorkspacesTree', updatedTree, (err) => {
if (err) console.error(err);
});
@ -800,7 +808,6 @@ BlazeComponent.extendComponent({
// 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
@ -823,7 +830,6 @@ BlazeComponent.extendComponent({
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') {
@ -838,11 +844,9 @@ BlazeComponent.extendComponent({
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;
@ -904,7 +908,6 @@ BlazeComponent.extendComponent({
};
const allBoards = ReactiveCache.getBoards(query, {});
if (type === 'starred') {
return allBoards.filter(
(b) => currentUser && currentUser.hasStarred(b._id),

View file

@ -23,7 +23,7 @@
.original-positions-content {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.4ch;
border-radius: 4px;
padding: 15px;
}
@ -65,7 +65,7 @@
.original-position-item {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.4ch;
border-radius: 4px;
margin-bottom: 10px;
padding: 12px;
transition: all 0.2s ease;
@ -100,7 +100,7 @@
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
@ -112,7 +112,7 @@
.entity-id {
color: #6c757d;
font-size: 11px;
font-family: monospace;
}
@ -123,12 +123,12 @@
.original-position-description {
color: #495057;
margin-bottom: 6px;
font-size: 13px;
}
.original-title {
color: #6c757d;
font-size: 12px;
margin-bottom: 6px;
padding: 4px 6px;
background-color: #e9ecef;
@ -141,7 +141,7 @@
.original-position-date {
color: #6c757d;
font-size: 11px;
}
.no-original-positions {
@ -152,7 +152,7 @@
}
.no-original-positions i {
font-size: 24px;
margin-bottom: 10px;
display: block;
color: #adb5bd;

View file

@ -5,7 +5,7 @@
<i class="fa fa-history"></i>
{{#if isShowingOriginalPositions}}Hide{{else}}Show{{/if}} Original Positions
</button>
{{#if isShowingOriginalPositions}}
<button class="btn btn-sm btn-outline-primary" onclick="{{refreshHistory}}">
<i class="fa fa-refresh"></i> Refresh
@ -22,22 +22,22 @@
{{else}}
<div class="original-positions-filters">
<div class="btn-group btn-group-sm" role="group">
<button type="button"
<button type="button"
class="btn {{#if isFilterType 'all'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
onclick="{{setFilterType 'all'}}">
All
</button>
<button type="button"
<button type="button"
class="btn {{#if isFilterType 'swimlane'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
onclick="{{setFilterType 'swimlane'}}">
<i class="fa fa-bars"></i> Swimlanes
</button>
<button type="button"
<button type="button"
class="btn {{#if isFilterType 'list'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
onclick="{{setFilterType 'list'}}">
<i class="fa fa-columns"></i> Lists
</button>
<button type="button"
<button type="button"
class="btn {{#if isFilterType 'card'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
onclick="{{setFilterType 'card'}}">
<i class="fa fa-sticky-note"></i> Cards

View file

@ -26,7 +26,7 @@ class OriginalPositionsViewComponent extends BlazeComponent {
if (!boardId) return;
this.isLoading.set(true);
Meteor.call('positionHistory.getBoardHistory', boardId, (error, result) => {
this.isLoading.set(false);
if (error) {
@ -57,11 +57,11 @@ class OriginalPositionsViewComponent extends BlazeComponent {
getFilteredHistory() {
const history = this.getBoardHistory();
const filterType = this.filterType.get();
if (filterType === 'all') {
return history;
}
return history.filter(item => item.entityType === filterType);
}
@ -93,7 +93,7 @@ class OriginalPositionsViewComponent extends BlazeComponent {
getEntityOriginalPositionDescription(entity) {
const position = entity.originalPosition || {};
let description = `Position: ${position.sort || 0}`;
if (entity.entityType === 'list' && entity.originalSwimlaneId) {
description += ` in swimlane ${entity.originalSwimlaneId}`;
} else if (entity.entityType === 'card') {
@ -104,7 +104,7 @@ class OriginalPositionsViewComponent extends BlazeComponent {
description += ` in list ${entity.originalListId}`;
}
}
return description;
}

View file

@ -6,80 +6,75 @@
font-weight: bold;
}
.attachment-gallery {
display: grid;
grid-auto-flow: row;
display: flex;
flex-direction: column;
}
.attachment-item {
display: grid;
grid-template-columns: 10ch auto;
display: flex;
flex-direction: row;
align-items: center;
grid-template-rows: repeat(auto-fit, minmax(1.5lh, auto));
justify-content: stretch;
gap: 2ch;
padding: 2ch;
border-radius: 0.6ch;
margin-top: 16px;
}
.attachment-item:hover {
background: #e0e0e0;
}
.attachment-details-container {
display: flex;
flex: 1;
}
.attachment-thumbnail-container {
display: flex;
flex: 1;
position: relative;
display: block;
width: 150px;
min-width: 150px;
max-height: 150px;
padding-right: 16px;
}
.attachment-thumbnail {
/* more deterministic outcome */
aspect-ratio: 1/1;
object-fit: cover;
max-width: 100%;
max-width: 150px;
max-height: 150px;
min-height: 2em;
cursor: pointer;
border-radius: 0.4ch;
}
.attachment-thumbnail-text {
flex: 1;
text-align: center;
border-radius: 2px;
min-height: 2em;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 5px;
}
.attachment-details-container {
display: block;
flex-grow: 1;
}
.attachment-details {
display: flex;
flex: 1;
gap: 0.5ch;
align-items: center;
justify-content: space-between;
margin-right: 25px; /* Make sure the icons are not to far to the right */
}
.attachment-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5ch;
}
body.mobile-mode .attachment-actions {
flex-direction: column;
gap: 0;
.attachment-actions a {
margin-left: 16px;
}
.attachment-actions a:first-child {
margin-left: 0;
}
.add-attachment {
border: 1px dashed #555;
border-radius: .5ch;
cursor: pointer;
aspect-ratio: 1/1;
height: 1.5lh;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #555;
border-radius: 5px;
padding: 10px;
cursor: pointer;
margin-top: 16px;
}
.icon {
font-size: 1.5em;
cursor: pointer;
margin-left: 10px;
}
.icon:hover {
color: #666;
@ -100,25 +95,26 @@ body.mobile-mode .attachment-actions {
height: 100%;
}
#viewer-top-bar {
display: grid;
grid-template-columns: 1fr auto;
justify-content: center;
justify-items: center;
font-size: 2rem;
padding: 0.3lh 0.5ch;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
padding: 16px;
}
#attachment-name {
color: white;
text-overflow: ellipsis;
overflow: hidden;
font-size: 1.5em;
max-width: calc(
100% - 50px
); /* Make sure the name does not overlap the close button */
}
#viewer-close {
color: white;
cursor: pointer;
font-size: 4em;
position: absolute;
right: 50px;
top: 16px;
font-size: 2em;
}
/* Upload progress indicators for drag-and-drop uploads */
@ -126,24 +122,30 @@ body.mobile-mode .attachment-actions {
.card-details-upload-progress {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.4ch;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-size: 14px;
}
.upload-progress-header {
display: flex;
align-items: center;
margin-bottom: 8px;
font-weight: bold;
color: #495057;
}
.upload-progress-header i {
margin-right: 8px;
color: #007bff;
}
.upload-progress-item {
display: flex;
flex-direction: column;
margin-bottom: 8px;
padding: 8px;
background: white;
border-radius: 3px;
border: 1px solid #dee2e6;
@ -156,17 +158,22 @@ body.mobile-mode .attachment-actions {
.upload-progress-filename {
font-weight: 500;
margin-bottom: 4px;
color: #495057;
word-break: break-all;
}
.upload-progress-bar {
width: 100%;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.upload-progress-fill {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
transition: width 0.3s ease;
border-radius: 3px;
@ -180,6 +187,7 @@ body.mobile-mode .attachment-actions {
.upload-progress-success {
display: flex;
align-items: center;
font-size: 12px;
font-weight: 500;
}
@ -191,6 +199,47 @@ body.mobile-mode .attachment-actions {
color: #28a745;
}
.upload-progress-error i,
.upload-progress-success i {
margin-right: 4px;
}
/* Minicard specific styles */
.minicard-upload-progress {
margin: 4px 0;
padding: 8px;
font-size: 12px;
}
.minicard-upload-progress .upload-progress-item {
padding: 6px;
margin-bottom: 6px;
}
.minicard-upload-progress .upload-progress-filename {
font-size: 11px;
}
/* Card details specific styles */
.card-details-upload-progress {
margin: 12px 0;
padding: 16px;
}
.card-details-upload-progress .upload-progress-header {
font-size: 16px;
margin-bottom: 12px;
}
.card-details-upload-progress .upload-progress-item {
padding: 12px;
margin-bottom: 10px;
}
.card-details-upload-progress .upload-progress-filename {
font-size: 14px;
}
/* Drag over state for minicards */
.minicard.is-dragging-over {
border: 2px dashed #007bff !important;
@ -207,6 +256,7 @@ body.mobile-mode .attachment-actions {
color: white;
cursor: pointer;
align-self: center;
margin: 0 20px;
}
#prev-attachment {
font-size: 4em;
@ -272,6 +322,7 @@ body.mobile-mode .attachment-actions {
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
}
#prev-attachment {
left: 0;
@ -305,10 +356,19 @@ body.mobile-mode .attachment-actions {
margin-top: 20%;
width: 100%;
}
.attachment-thumbnail-container {
width: 100px;
min-width: 100px;
}
.attachment-thumbnail {
max-width: 100px;
}
.attachment-details {
flex-direction: column;
margin-right: 0px;
}
.attachment-actions {
flex-direction: row;
margin-top: 10px;
}
}

View file

@ -49,11 +49,15 @@ template(name="attachmentViewer")
i.fa.fa-caret-right#next-attachment
template(name="attachmentGallery")
if canModifyCard
a.add-attachment.js-add-attachment
i.fa.fa-plus
.attachment-gallery
if canModifyCard
a.attachment-item.add-attachment.js-add-attachment
i.fa.fa-plus
each attachments
.attachment-item(class="{{#if isAttachmentMigrating _id}}migrating{{/if}}")
.attachment-thumbnail-container.open-preview(data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}")
if link

View file

@ -1,26 +1,26 @@
import { TAPi18n } from '/imports/i18n';
import { DatePicker } from '/client/lib/datepicker';
import { ReactiveCache } from '/imports/reactiveCache';
import {
formatDateTime,
formatDate,
import {
formatDateTime,
formatDate,
formatDateByUserPreference,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
} from '/imports/lib/dateUtils';
import Cards from '/models/cards';
import { CustomFieldStringTemplate } from '/client/lib/customFields'

View file

@ -1,5 +1,6 @@
.card-date {
display: block;
border-radius: 4px;
padding: 1px 3px;
background-color: #dbdbdb;
}
@ -105,10 +106,6 @@
background-color: #e6c200;
}
.date a:has(time) {
text-decoration: none;
}
.card-date.end-date {
background-color: #ffb3b3; /* Light red for end */
color: #000; /* Black text for end */
@ -142,6 +139,6 @@
}
.customfield-date {
display: block;
border-radius: 0.4ch;
border-radius: 4px;
padding: 1px 3px;
}

View file

@ -1,12 +1,16 @@
.new-description {
flex: 1;
position: relative;
margin: 0 0 20px 0;
}
.new-description textarea {
min-height: 1lh;
.new-description.is-open .helper {
display: inline-block;
}
.new-description.is-open textarea {
min-height: 100px;
color: #4d4d4d;
cursor: auto;
overflow: hidden;
overflow-wrap: break-word;
word-wrap: break-word;
}
.new-description .too-long {
margin-top: 8px;
@ -15,6 +19,9 @@
background-color: #fff;
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
height: 36px;
margin: 4px 4px 6px 0;
padding: 9px 11px;
width: 100%;
}
.new-description textarea:hover,
@ -32,12 +39,16 @@
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
color: #8c8c8c;
height: 36px;
margin: 4px 4px 6px 0;
width: 92%;
}
.description-item:hover {
background: #e0e0e0;
}
.description-item.add-description {
display: flex;
margin: 5px;
}
.description-item.add-description a {
display: block;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,6 @@ import { DialogWithBoardSwimlaneList } from '/client/lib/dialogWithBoardSwimlane
import { DialogWithBoardSwimlaneListCard } from '/client/lib/dialogWithBoardSwimlaneListCard';
import { handleFileUpload } from './attachments';
import uploadProgressManager from '../../lib/uploadProgressManager';
import PopupComponent from '../main/popup';
const subManager = new SubsManager();
const { calculateIndexData } = Utils;
@ -61,8 +60,19 @@ BlazeComponent.extendComponent({
onCreated() {
this.currentBoard = Utils.getCurrentBoard();
this.isLoaded = new ReactiveVar(false);
this.dep = new Tracker.Dependency();
if (this.parentComponent() && this.parentComponent().parentComponent()) {
const boardBody = this.parentComponent().parentComponent();
//in Miniview parent is Board, not BoardBody.
if (boardBody !== null) {
// Only show overlay in mobile mode, not in desktop mode
const isMobile = Utils.getMobileMode();
if (isMobile) {
boardBody.showOverlay.set(true);
}
boardBody.mouseHasEnterCardDetails = false;
}
}
this.calculateNextPeak();
Meteor.subscribe('unsaved-edits');
@ -75,18 +85,6 @@ BlazeComponent.extendComponent({
// });
},
onRendered() {
const boardOverlay = document.getElementsByClassName('board-overlay')?.[0];
this.boardBody = BlazeComponent.getComponentForElement(boardOverlay);
if (this.boardBody) {
this.boardBody.mouseHasEnterCardDetails = false;
}
const isMobile = Utils.getMobileMode();
if (isMobile && Session.get('currentCard')) {
//this.boardBody?.showOverlay.set(true);
}
},
isWatching() {
const card = this.currentData();
if (!card || typeof card.findWatcher !== 'function') return false;
@ -97,8 +95,8 @@ BlazeComponent.extendComponent({
return ReactiveCache.getCurrentUser().hasCustomFieldsGrid();
},
cardMaximized() {
this.dep.depend();
return !Utils.getPopupCardId() && ReactiveCache.getCurrentUser().hasCardMaximized();
},
@ -177,11 +175,6 @@ BlazeComponent.extendComponent({
},
onRendered() {
// #FIXME hackish; if accepted tweak static funcs
if (this.cardMaximized()) {
PopupComponent.maximize({target: this.firstNode()});
}
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
// Send Webhook but not create Activities records ---
const card = this.currentData();
@ -216,11 +209,11 @@ BlazeComponent.extendComponent({
}
const $checklistsDom = this.$('.card-checklist-items');
const sortableSelector = Utils.isMiniScreen() ? '.checklist-handle' : '.checklist-title';
$checklistsDom.sortable({
tolerance: 'pointer',
helper: 'clone',
handle: sortableSelector,
handle: '.checklist-title',
items: '.js-checklist',
placeholder: 'checklist placeholder',
distance: 7,
@ -289,8 +282,6 @@ BlazeComponent.extendComponent({
return ReactiveCache.getCurrentUser()?.isBoardMember();
}
// Disable sorting if the current user is not a board member
this.autorun(() => {
const disabled = !userIsMember();
@ -298,7 +289,10 @@ BlazeComponent.extendComponent({
$checklistsDom.data('uiSortable') ||
$checklistsDom.data('sortable')
) {
$checklistsDom.sortable('option', 'handle', sortableSelector);
$checklistsDom.sortable('option', 'disabled', disabled);
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$checklistsDom.sortable({ handle: '.checklist-handle' });
}
}
if ($subtasksDom.data('uiSortable') || $subtasksDom.data('sortable')) {
$subtasksDom.sortable('option', 'disabled', disabled);
@ -307,7 +301,11 @@ BlazeComponent.extendComponent({
},
onDestroyed() {
this.boardBody?.showOverlay.set(false);
if (this.parentComponent() === null) return;
const parentComponent = this.parentComponent().parentComponent();
//on mobile view parent is Board, not board body.
if (parentComponent === null) return;
parentComponent.showOverlay.set(false);
},
events() {
@ -334,11 +332,59 @@ BlazeComponent.extendComponent({
},
'mousedown .js-card-drag-handle'(event) {
event.preventDefault();
PopupComponent.toFront(event);
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-card-send-to-back'(event) {
'mousedown .js-card-title-drag-handle'(event) {
// Allow dragging from title for ReadOnly users
// Don't interfere with text selection
if (event.target.tagName === 'A' || $(event.target).closest('a').length > 0) {
return; // Don't drag if clicking on links
}
event.preventDefault();
PopupComponent.toBack(event);
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
@ -346,21 +392,26 @@ BlazeComponent.extendComponent({
const boardId = (card && card.boardId) || Utils.getCurrentBoard()._id;
const cardId = card && card._id;
if (boardId && cardId) {
const openCards = Session.get('openCards') || [];
const filtered = openCards.filter(id => id !== cardId);
// If this was the current card, clear it
if (openCards.length === filtered.length) {
Session.set('currentCard', null);
}
else {
Session.set('currentCard', filtered[0]);
}
Session.set('openCards', filtered);
if (boardId) {
// 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);
// Navigate back to board without card: must be done at the time of writing
// otherwise the route for the card is disabled until another
// card is opened
// 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
const board = ReactiveCache.getBoard(boardId);
if (board) {
FlowRouter.go('board', {
@ -383,6 +434,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);
},
async 'submit .js-card-description'(event) {
event.preventDefault();
const description = this.currentComponent().getValue();
@ -446,7 +525,7 @@ BlazeComponent.extendComponent({
'click .js-add-members': Popup.open('cardMembers'),
'click .js-assignee': Popup.open('cardAssignee'),
'click .js-add-assignees': Popup.open('cardAssignees'),
'click .js-add-labels'(event) {Popup.open('cardLabels')(event, { dataContextIfCurrentDataIsUndefined: this.currentData() })},
'click .js-add-labels': Popup.open('cardLabels'),
'click .js-received-date': Popup.open('editCardReceivedDate'),
'click .js-start-date': Popup.open('editCardStartDate'),
'click .js-due-date': Popup.open('editCardDueDate'),
@ -455,10 +534,12 @@ BlazeComponent.extendComponent({
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
'click .js-custom-fields': Popup.open('cardCustomFields'),
'mouseenter .js-card-details'() {
if (this.boardBody) {
this.boardBody.showOverlay.set(true);
this.boardBody.mouseHasEnterCardDetails = true;
}
if (this.parentComponent() === null) return;
const parentComponent = this.parentComponent().parentComponent();
//on mobile view parent is Board, not BoardBody.
if (parentComponent === null) return;
parentComponent.showOverlay.set(true);
parentComponent.mouseHasEnterCardDetails = true;
},
'mousedown .js-card-details'() {
Session.set('cardDetailsIsDragging', false);
@ -479,13 +560,13 @@ BlazeComponent.extendComponent({
'click #toggleCustomFieldsGridButton'() {
Meteor.call('toggleCustomFieldsGrid');
},
'click .js-maximize-card-details'(e) {
PopupComponent.maximize(e);
'click .js-maximize-card-details'() {
Meteor.call('toggleCardMaximized');
autosize($('.card-details'));
},
'click .js-minimize-card-details'(e) {
PopupComponent.minimize(e);
'click .js-minimize-card-details'() {
Meteor.call('toggleCardMaximized');
autosize($('.card-details'));
},
'click .js-vote'(e) {
const forIt = $(e.target).hasClass('js-vote-positive');
@ -656,6 +737,16 @@ Template.cardDetails.helpers({
return uploadProgressManager.getUploadCountForCard(this._id);
}
});
Template.cardDetailsPopup.onDestroyed(() => {
Session.delete('popupCardId');
Session.delete('popupCardBoardId');
});
Template.cardDetailsPopup.helpers({
popupCard() {
const ret = Utils.getPopupCard();
return ret;
},
});
BlazeComponent.extendComponent({
template() {
@ -792,7 +883,9 @@ Template.cardDetailsActionsPopup.events({
'click .js-toggle-watch-card'() {
const currentCard = this;
const level = currentCard.findWatcher(Meteor.userId()) ? null : 'watching';
Meteor.call('watch', 'card', currentCard._id, level)
Meteor.call('watch', 'card', currentCard._id, level, (err, ret) => {
if (!err && ret) Popup.close();
});
},
'click .js-toggle-show-list-on-minicard'() {
const currentCard = this;
@ -803,6 +896,9 @@ Template.cardDetailsActionsPopup.events({
});
BlazeComponent.extendComponent({
onRendered() {
autosize(this.$('textarea.js-edit-card-title'));
},
events() {
return [
{
@ -883,6 +979,10 @@ const filterMembers = (filterTerm) => {
return members;
}
Template.editCardRequesterForm.onRendered(function () {
autosize(this.$('.js-edit-card-requester'));
});
Template.editCardRequesterForm.events({
'keydown .js-edit-card-requester'(event) {
// If enter key was pressed, submit the data
@ -892,6 +992,10 @@ Template.editCardRequesterForm.events({
},
});
Template.editCardAssignerForm.onRendered(function () {
autosize(this.$('.js-edit-card-assigner'));
});
Template.editCardAssignerForm.events({
'keydown .js-edit-card-assigner'(event) {
// If enter key was pressed, submit the data
@ -1801,6 +1905,9 @@ EscapeActions.register(
() => {
return !Session.equals('currentCard', null);
},
{
noClickEscapeOn: '.js-card-details,.board-sidebar,#header',
},
);
Template.cardAssigneesPopup.onCreated(function () {
@ -1878,16 +1985,3 @@ Template.cardAssigneePopup.events({
},
'click .js-edit-profile': Popup.open('editProfile'),
});
Template.cardDetailsPopup.helpers({
popupArgs() {
return {
name: "cardDetails",
showHeader: false,
closeDOMs: ["click .js-close-card-details"],
followDOM: ".card-details",
handleDOM: ".card-header-middle",
closeVar: "currentCard"
}
},
});

View file

@ -1,6 +1,6 @@
.card-time {
display: block;
border-radius: 0.4ch;
border-radius: 4px;
padding: 1px 3px;
color: #fff;
background-color: #dbdbdb;

View file

@ -4,7 +4,7 @@
textarea.js-add-checklist-item,
textarea.js-edit-checklist-item {
overflow: hidden;
overflow-wrap: break-word;
word-wrap: break-word;
resize: none;
height: 34px;
}
@ -13,7 +13,7 @@ textarea.js-edit-checklist-item {
.js-convert-checklist-item-to-card {
color: #8c8c8c;
text-decoration: underline;
overflow-wrap: break-word;
word-wrap: break-word;
float: right;
padding-top: 6px;
}
@ -25,7 +25,6 @@ textarea.js-edit-checklist-item {
.checklists-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.checklist-progress-bar-container {
display: flex;
@ -36,7 +35,7 @@ textarea.js-edit-checklist-item {
margin-right: 10px;
}
.checklist-progress-bar-container .checklist-progress-bar {
flex: 1;
width: 80%;
height: 10px;
background-color: #e0e0e0;
border-radius: 16px;
@ -48,29 +47,19 @@ textarea.js-edit-checklist-item {
border-radius: 16px;
height: 100%;
}
.checklist-controls {
display: flex;
gap: 0.25lh;
}
.checklist-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
}
.checklist-title .checkbox {
float: left;
width: 30px;
height: 30px;
font-size: 18px;
line-height: 30px;
}
.checklist-title p, .title {
font-size: 1em;
line-height: 1;
margin: 0;
.checklist-title .title {
font-size: 18px;
line-height: 25px;
}
.checklist-title .checklist-stat {
margin: 0 0.5em;
@ -90,31 +79,29 @@ textarea.js-edit-checklist-item {
bottom: -600px;
right: 0;
}
.checklist {
padding: 0.5lh;
margin: 0.5lh 0;
background-color: #f7f7f7;
background: #f7f7f7;
}
.checklist.placeholder {
background: #ccc;
border-radius: 2px;
}
.checklist.ui-sortable-helper {
box-shadow: -2px 2px 8px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
transform: rotate(4deg);
cursor: grabbing;
}
.checklist-item {
margin: 0 0 0 0.1em;
line-height: 18px;
font-size: 1.1em;
margin-top: 3px;
display: flex;
gap: 0.25lh;
background: #f7f7f7;
opacity: 1;
transition: height 0ms 400ms, opacity 400ms 0ms;
height: auto;
overflow: hidden;
align-items: center;
min-height: 1.5lh;
padding: 0 1ch;
}
.checklist-item.is-checked.invisible {
opacity: 0;
@ -127,21 +114,26 @@ textarea.js-edit-checklist-item {
background: #ccc;
border-radius: 2px;
}
.checklist-item.ui-sortable-helper {
box-shadow: -2px 2px 8px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
transform: rotate(4deg);
cursor: grabbing;
}
.checklist-item:hover {
background-color: #ebebeb;
}
.checklist-item .check-box-container {
padding-right: 10px;
}
.checklist-item .check-box {
margin: 0.1em 0 0 0;
}
.checklist-item .check-box.is-checked {
border-bottom: 0.2ch solid #3cb500;
border-right: 0.2ch solid #3cb500;
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
}
.checklist-item .item-title {
display: flex;
justify-content: start;
flex: 1;
cursor: grab;
}
.checklist-item .item-title.is-checked {
color: #8c8c8c;
@ -149,18 +141,27 @@ textarea.js-edit-checklist-item {
text-decoration: line-through;
}
.checklist-item .item-title .viewer p {
display: flex;
overflow-wrap: break-word;
margin-bottom: 2px;
display: block;
word-wrap: break-word;
max-width: 420px;
}
.checklist-item span.fa.checklistitem-handle {
padding-top: 2px;
padding-right: 10px;
}
.js-delete-checklist-item,
.js-convert-checklist-item-to-card {
margin: 0 0 0.5em 1.33em;
padding: 12px 0 0 0;
}
.add-checklist-item {
margin: 0.2em 0 0.5em 1.33em;
}
.add-checklist-item.js-open-inlined-form,
.add-checklist.js-open-inlined-form {
display: inline-block;
display: block;
width: 50%;
}
.add-checklist-item.js-open-inlined-form:hover,
.add-checklist.js-open-inlined-form:hover {
@ -168,13 +169,25 @@ textarea.js-edit-checklist-item {
color: #222;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.add-checklist-top {
/* more space to checklists title */
padding-left: 20px;
/* + is easier clickable */
padding-right: 20px;
}
.add-checklist-top.js-open-inlined-form:hover {
background: #dbdbdb;
color: #222;
box-shadow: 0 1px 2px rgba(0,0,0,.2);
}
.card-details-item-title {
/* max width for adding checklist at top */
width: 100%;
}
.checklist-details-menu {
float: right;
padding: 6px 10px 6px 10px;
}
.edit-controls label.toggle-label {
margin-left: 2px;
}

View file

@ -4,7 +4,8 @@ template(name="checklists")
i.fa.fa-check
| {{_ 'checklists'}}
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist" cardId = cardId position="top")
+inlinedForm(autoclose=false classNames="js-add-checklist" cardId = cardId
position="top")
+addChecklistItemForm
else
a.add-checklist-top.js-open-inlined-form(title="{{_ 'add-checklist'}}")
@ -36,31 +37,26 @@ template(name="checklistDetail")
+editChecklistItemForm(checklist = checklist)
else
.checklist-title
h4.title
if canModifyCard
a.js-open-inlined-form.is-editable(title="{{_ 'moveChecklistPopup-title'}}")
+viewer
= checklist.title
else
span
if canModifyCard
a.fa.fa-navicon.checklist-details-menu.js-open-checklist-details-menu(title="{{_ 'checklistActionsPopup-title'}}")
if canModifyCard
h4.title.js-open-inlined-form.is-editable
if isTouchScreenOrShowDesktopDragHandles
span.fa.checklist-handle(class="fa-arrows" title="{{_ 'dragChecklist'}}")
+viewer
= checklist.title
.checklist-controls
if canModifyCard
a.fa.fa-navicon.checklist-details-menu.js-open-checklist-details-menu(title="{{_ 'checklistActionsPopup-title'}}")
if isMiniScreen
span.fa.checklist-handle(class="fa-arrows" title="{{_ 'dragChecklist'}}")
+viewer
else
h4.title
+viewer
= checklist.title
//- jumps where checking the first item is not comfortable;
//- so try to show it anytime. also, it helps to separate the checklists.
.checklist-progress-bar-container
.checklist-progress-text {{finishedPercent}}%
.checklist-progress-bar
if $gt finishedPercent 0
if $gt finishedPercent 0
.checklist-progress-bar-container
.checklist-progress-text {{finishedPercent}}%
.checklist-progress-bar
.checklist-progress(style="width:{{finishedPercent}}%")
else
.checklist-progress(style="visibility:hidden")
+checklistItems(checklist = checklist card = card)
template(name="checklistDeletePopup")
@ -69,7 +65,7 @@ template(name="checklistDeletePopup")
template(name="addChecklistItemForm")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip.copied-tooltip-hidden {{_ 'copied'}}
span.copied-tooltip {{_ 'copied'}}
textarea.js-add-checklist-item(rows='1' autofocus)
.edit-controls.clearfix
button.primary.confirm.js-submit-add-checklist-item-form(type="submit") {{_ 'save'}}
@ -78,12 +74,16 @@ template(name="addChecklistItemForm")
.material-toggle-switch(title="{{_ 'newlineBecomesNewChecklistItem'}}")
input.toggle-switch(type="checkbox" id="toggleNewlineBecomesNewChecklistItem")
label.toggle-label(for="toggleNewlineBecomesNewChecklistItem")
span.toggle-switch-desc
| {{_ 'newLineNewItem'}}
if $eq position 'top'
.material-toggle-switch(title="{{_ 'newlineBecomesNewChecklistItemOriginOrder'}}")
input.toggle-switch(type="checkbox" id="toggleNewlineBecomesNewChecklistItemOriginOrder")
label.toggle-label(for="toggleNewlineBecomesNewChecklistItemOriginOrder")
| {{_ 'originOrder'}}
template(name="editChecklistItemForm")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip.copied-tooltip-hidden {{_ 'copied'}}
span.copied-tooltip {{_ 'copied'}}
textarea.js-edit-checklist-item(rows='1' autofocus dir="auto")
if $eq type 'item'
= item.title
@ -100,6 +100,13 @@ template(name="editChecklistItemForm")
| {{_ 'convertChecklistItemToCardPopup-title'}}
template(name="checklistItems")
if checklist.items.length
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist = checklist position="top")
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=true position="top")
else
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
i.fa.fa-plus
.checklist-items.js-checklist-items
each item in checklist.items
+inlinedForm(classNames="js-edit-checklist-item" item = item checklist = checklist)
@ -112,15 +119,14 @@ template(name="checklistItems")
else
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
i.fa.fa-plus
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist = checklist)
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=true position="top")
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")
.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}}")
if isMiniScreen
if isTouchScreenOrShowDesktopDragHandles
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
@ -136,16 +142,16 @@ template(name="checklistActionsPopup")
li
a.js-delete-checklist.delete-checklist
i.fa.fa-trash
| {{_ "delete"}}
| {{_ "delete"}} ...
a.js-move-checklist.move-checklist
i.fa.fa-arrow-right
| {{_ "moveChecklist"}}
| {{_ "moveChecklist"}} ...
a.js-copy-checklist.copy-checklist
i.fa.fa-copy
| {{_ "copyChecklist"}}
| {{_ "copyChecklist"}} ...
a.js-hide-checked-checklist-items
i.fa.fa-eye-slash
| {{_ "hideCheckedChecklistItems"}}
| {{_ "hideCheckedChecklistItems"}} ...
.material-toggle-switch(title="{{_ 'hide-checked-items'}}")
if checklist.hideCheckedChecklistItems
input.toggle-switch(type="checkbox" id="toggleHideCheckedChecklistItems_{{checklist._id}}" checked="checked")
@ -153,7 +159,7 @@ template(name="checklistActionsPopup")
input.toggle-switch(type="checkbox" id="toggleHideCheckedChecklistItems_{{checklist._id}}")
label.toggle-label(for="toggleHideCheckedChecklistItems_{{checklist._id}}")
a.js-hide-all-checklist-items
| 🚫
i.fa.fa-ban
| {{_ "hideAllChecklistItems"}} ...
.material-toggle-switch(title="{{_ 'hideAllChecklistItems'}}")
if checklist.hideAllChecklistItems

View file

@ -7,7 +7,7 @@ import { DialogWithBoardSwimlaneListCard } from '/client/lib/dialogWithBoardSwim
const subManager = new SubsManager();
const { calculateIndexData, capitalize } = Utils;
function initSorting(items, handleSelector) {
function initSorting(items) {
items.sortable({
tolerance: 'pointer',
helper: 'clone',
@ -16,7 +16,6 @@ function initSorting(items, handleSelector) {
appendTo: 'parent',
distance: 7,
placeholder: 'checklist-item placeholder',
handle: handleSelector,
scroll: true,
start(evt, ui) {
ui.placeholder.height(ui.helper.height());
@ -49,9 +48,8 @@ function initSorting(items, handleSelector) {
BlazeComponent.extendComponent({
onRendered() {
const self = this;
this.handleSelector = Utils.isMiniScreen() ? 'span.fa.checklistitem-handle' : '.item-title';
self.itemsDom = this.$('.js-checklist-items');
initSorting(self.itemsDom, this.handleSelector);
initSorting(self.itemsDom);
self.itemsDom.mousedown(function (evt) {
evt.stopPropagation();
});
@ -65,9 +63,11 @@ BlazeComponent.extendComponent({
const $itemsDom = $(self.itemsDom);
if ($itemsDom.data('uiSortable') || $itemsDom.data('sortable')) {
$(self.itemsDom).sortable('option', 'disabled', !userIsMember());
$(self.itemsDom).sortable({
handle: this.handleSelector,
});
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$(self.itemsDom).sortable({
handle: 'span.fa.checklistitem-handle',
});
}
}
});
},
@ -372,9 +372,9 @@ BlazeComponent.extendComponent({
const ret = ReactiveCache.getCurrentUser().getMoveChecklistDialogOptions();
return ret;
}
setDone(cardId, options) {
async setDone(cardId, options) {
ReactiveCache.getCurrentUser().setMoveChecklistDialogOption(this.currentBoardId, options);
this.data().checklist.move(cardId);
await this.data().checklist.move(cardId);
}
}).register('moveChecklistPopup');
@ -384,8 +384,8 @@ BlazeComponent.extendComponent({
const ret = ReactiveCache.getCurrentUser().getCopyChecklistDialogOptions();
return ret;
}
setDone(cardId, options) {
async setDone(cardId, options) {
ReactiveCache.getCurrentUser().setCopyChecklistDialogOption(this.currentBoardId, options);
this.data().checklist.copy(cardId);
await this.data().checklist.copy(cardId);
}
}).register('copyChecklistPopup');

View file

@ -1,20 +1,20 @@
.card-label {
border-radius: 0.4ch;
border: 1px solid #000;
border-radius: 4px;
color: #fff;
display: inline-block;
font-weight: 700;
font-size: 0.9em;
display: flex;
/* prefer not using padding/margin but let outer grids
position/size labels (see e.g. minicards), otherwise we get
inconsistencies */
align-self: stretch;
justify-content: center;
align-items: center;
text-align: center;
padding: 0 0.5ch;
height: var(--label-height);
min-width: 8ch;
font-size: 13px;
margin-right: 4px;
margin-bottom: 5px;
padding: 3px 8px;
max-width: 210px;
min-width: 8px;
word-wrap: break-word;
min-height: 18px;
vertical-align: middle;
white-space: initial;
overflow: initial;
}
.card-label:hover {
color: #fff;
@ -34,7 +34,6 @@
}
.card-label p {
margin: 0px;
--overflow-lines: 1;
}
.palette-colors {
display: flex;
@ -139,22 +138,37 @@
.card-label-indigo {
background-color: #4b0082;
}
.edit-label .card-label,
.create-label .card-label {
float: left;
height: 25px;
margin: 0px 3% 7px 0px;
width: 10.5%;
max-width: 10.5%;
cursor: pointer;
}
.edit-labels input[type="text"] {
margin: 4px 0 6px 38px;
width: 243px;
}
.edit-labels .card-label {
height: 30px;
left: 0;
padding: 1px 5px;
position: absolute;
top: 0;
width: 24px;
}
.edit-labels .labels-static .card-label {
line-height: 30px;
margin-bottom: 4px;
position: relative;
top: auto;
left: 0;
width: 260px;
}
.edit-labels-pop-over {
display: grid;
/* so that inner elements, align nicely */
grid-template-columns: 1fr;
gap: 0.1lh;
>li {
display: flex;
flex-direction: row-reverse;
gap: 1ch;
align-items: center;
}
.card-label-selectable {
flex: 1;
display: flex;
gap: 1ch;
}
margin-bottom: 8px;
}
.edit-labels-pop-over .card-label .viewer p {
margin: 0;
@ -162,6 +176,34 @@
.edit-labels-pop-over .shortcut {
display: inline-block;
}
.card-label-selectable {
border-radius: 3px;
cursor: pointer;
margin: 0;
margin-bottom: 3px;
width: 190px;
min-height: 18px;
padding: 8px;
position: relative;
transition: margin-right 0.1s;
}
.card-label-selectable .card-label-selectable-icon {
position: absolute;
top: 8px;
right: -20px;
}
.card-label-selectable.active:hover,
.card-label-selectable.active,
.card-label-selectable.active.selected:hover,
.card-label-selectable.active.selected {
padding-right: 32px;
}
.card-label-selectable.active:hover .card-label-selectable-icon,
.card-label-selectable.active .card-label-selectable-icon,
.card-label-selectable.active.selected:hover .card-label-selectable-icon,
.card-label-selectable.active.selected .card-label-selectable-icon {
right: 6px;
}
.card-label-selectable.selected,
.card-label-selectable:hover {
opacity: 0.8;
@ -170,6 +212,24 @@
.active .card-label-selectable:hover {
margin-right: 0;
}
.active .card-label-selectable .card-label-selectable-icon {
right: 8px;
}
.card-label-edit-button {
border-radius: 3px;
float: right;
padding: 8px;
}
.card-label-edit-button:hover {
background: #dbdbdb;
}
}
ul.edit-labels-pop-over span.label-handle {
padding-right: 10px;
display: inline-block;
width: 1.2em;
text-align: center;
color: #999;
}
ul.edit-labels-pop-over span.label-handle + .card-label {
max-width: 180px;
}

View file

@ -1,32 +1,10 @@
.minicard-body {
display: flex;
flex-direction: column;
padding: 0 1ch 0.2lh 1ch;
gap: 0.2lh;
}
.minicard-wrapper {
cursor: pointer;
position: relative;
display: flex;
align-items: center;
height: min-content;
margin-bottom: 1.2vh;
}
.minicard-header {
display: flex;
align-items: center;
padding: 0 1ch;
gap: 1ch;
}
.minicard > hr {
margin: 0;
}
.minicard-add-form {
width: auto;
}
.minicard-wrapper.placeholder {
background: #ccc;
border-radius: 1.2vw;
@ -50,25 +28,32 @@
.minicard-wrapper .multi-selection-checkbox + .minicard {
margin-left: 1vw;
}
.minicard {
display: grid;
grid-auto-flow: row;
grid-template-rows: min-content 1fr auto auto;
gap: 0.4lh;
background-color: #fff;
box-shadow: 0 0.2vh 0.3vh rgba(0,0,0,0.15);
border-radius: 0.3vw;
color: #4d4d4d;
overflow: hidden;
transition: transform 0.2s, border-radius 0.2s;
flex: 1;
@media only screen {
.minicard {
padding: 0.8vh 1vw 0.3vh;
position: relative;
flex: 1;
flex-wrap: wrap;
background-color: #fff;
min-height: 2.5vh;
box-shadow: 0 0.2vh 0.3vh rgba(0,0,0,0.15);
border-radius: 0.3vw;
color: #4d4d4d;
overflow: hidden;
transition: transform 0.2s, border-radius 0.2s;
}
}
.minicard-actions-right {
justify-content: end;
display: flex;
align-items: end;
gap: .5lh;
.minicard-details-menu-with-handle {
float: right;
padding-left: 0.7vw;
font-size: clamp(14px, 3vw, 18px);
padding: 0;
z-index: 1;
}
.minicard-details-menu {
float: right;
font-size: clamp(14px, 3vw, 18px);
padding-left: 0.7vw;
}
@media print {
.minicard-details-menu,
@ -91,6 +76,7 @@
transform: translateX(1.5vw);
border-bottom-right-radius: 0;
border-top-right-radius: 0;
z-index: 25;
box-shadow: -0.3vw 0.2vh 0.3vh rgba(0,0,0,0.2);
}
.minicard:hover:not(.minicard-composer),
@ -110,30 +96,20 @@
margin: 0.8vh -1vw 0.8vh -1vw;
border-radius: top 0.3vw;
}
.minicard {
.minicard-labels, .dates {
display: grid;
grid-auto-rows: min-content;
justify-content: stretch;
font-size: 0.8em;
grid-auto-rows: minmax(1.3lh, auto);
}
.minicard-labels {
grid-template-columns: repeat(auto-fill, minmax(12ch, auto));
gap: 0.2lh 0.5ch;
}
.minicard-labels-no-text {
grid-template-columns: repeat(auto-fill, 4ch);
grid-template-rows: 4ch;
font-size: 0.4em;
.minicard-label {
border-radius: 1ch;
}
}
.dates {
height: min-content;
grid-template-columns: repeat(auto-fit, minmax(15ch, auto));
}
.minicard .minicard-labels {
float: none;
margin-right: 6vw;
}
.minicard .minicard-labels .minicard-label {
width: clamp(12px, 1.5vw, 16px);
height: clamp(12px, 1.5vw, 16px);
border-radius: 0.3vw;
margin-right: 0.4vw;
margin-bottom: 0.4vh;
}
.minicard .minicard-labels-no-text {
display: flex;
flex-wrap: wrap;
}
.minicard .minicard-custom-fields {
display: block;
@ -145,22 +121,26 @@
.minicard .minicard-custom-field-item {
flex-grow: 1;
display: block;
overflow-wrap: break-word;
word-wrap: break-word;
max-width: 13vw;
margin-right: 0.5vw;
}
.minicard .minicard-custom-field-item-fullwidth {
flex-grow: 1;
display: block;
overflow-wrap: break-word;
word-wrap: break-word;
max-width: 100%;
margin-right: 0.5vw;
}
.minicard .handle {
width: clamp(20px, 2.5vw, 28px);
height: clamp(20px, 2.5vw, 28px);
position: absolute;
right: 0vw;
top: 4vh;
display: none;
z-index: 1;
}
@media only screen {
.minicard .handle {
display: block;
@ -174,34 +154,58 @@
text-align: center;
}
.minicard .minicard-title {
display: flex;
max-width: 100%;
flex: 1;
cursor: grab;
.viewer {
--overflow-lines: 2;
}
margin-right: 1.5vw;
}
.minicard .minicard-title .card-number {
color: #b3b3b3;
display: inline-block;
margin-right: 0.7vw;
}
.minicard .date {
display: flex;
&>a {
display: flex;
justify-content: center;
flex: 1;
align-items: center;
text-align: center;
align-self: stretch;
@media only screen {
.minicard .minicard-title p:last-child {
margin-bottom: 0;
}
.minicard .minicard-title .viewer {
display: block;
word-wrap: break-word;
}
}
.minicard .dates {
display: flex;
flex-direction: row;
flex-wrap: wrap;
position: relative;
z-index: 5;
margin-right: 6vw;
clear: both;
}
.minicard .date {
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: #d3d3d3; /* Grey for received - a bit darker than base card-date */
background-color: #dbdbdb; /* Grey for received - same as base card-date */
}
.minicard .card-date.received-date:hover,
@ -307,134 +311,102 @@
background-color: #1976d2 !important;
}
.minicard .minicard-badges-and-creator {
display: flex;
flex-direction: row-reverse;
justify-content: end;
gap: 0 0.5ch;;
}
.minicard-people-grid {
display: grid;
grid-template-columns: 1fr auto;
grid-auto-rows: auto;
}
.minicard-people-wrapper {
display: flex;
justify-content: end;
gap: 0.1lh;
}
.minicard .badges {
display: flex;
align-items: center;
gap: 1.5ch;
float: left;
margin-top: 1vh;
color: #808080;
/* this avoid padding-ish at the bottom of the card */
font-size: 0.8rem;
}
.minicard .badges:empty {
display: none;
}
.minicard .badges .badge {
float: left;
margin-right: 1.5vw;
margin-bottom: 0.4vh;
font-size: 0.9em;
}
.minicard .badges .badge.is-finished {
background: #3cb500;
padding: 0.3lh 0.8ch;
padding: 0 0.4vw;
border-radius: 0.4vw;
&, .fa {
color: #fff;
}
color: #fff;
}
.minicard .badges .badge:last-of-type {
margin-right: 0;
}
.minicard .badges .badge .badge-icon,
.minicard .badges .badge .badge-text {
vertical-align: middle;
}
.minicard .badges .badge .badge-icon.badge-comment,
.minicard .badges .badge .badge-text.badge-comment {
margin-bottom: 0.1rem;
}
.minicard .badges .badge .badge-text {
font-size: 0.9em;
padding-left: 0.3vw;
line-height: 1.2;
}
.minicard .badges .badge .check-list-text {
padding-left: 0px;
line-height: 1.1;
}
.minicard .minicard-members,
.minicard .minicard-assignees,
.minicard .minicard-creator {
float: right;
margin-left: 0.7vw;
margin-bottom: 0.5vh;
}
.minicard .minicard-members .member,
.minicard .minicard-assignees .member,
.minicard .minicard-creator .member {
display: flex;
float: right;
border-radius: 50%;
font-size: 0.8em;
margin-bottom: 0.2lh;
height: clamp(24px, 3.5vw, 32px);
width: clamp(24px, 3.5vw, 32px);
margin-bottom: 0.5vh;
}
.minicard .minicard-assignees .member {
border: 2px solid rgb(180, 87, 87);
.minicard .minicard-members .assignee,
.minicard .minicard-assignees .assignee,
.minicard .minicard-creator .assignee {
float: right;
border-radius: 50%;
height: clamp(24px, 3.5vw, 32px);
width: clamp(24px, 3.5vw, 32px);
}
.minicard .minicard-creator .member {
border: 2px solid #7fd67f;
}
.minicard .minicard-members .member {
border: 2px solid #5a5ac6;
.minicard .minicard-members + .badges,
.minicard .minicard-assignees + .badges,
.minicard .minicard-creator + .badges {
margin-top: 0.7vh;
}
.minicard .minicard-assignees {
display: flex;
border-bottom: 1px solid #f00;
}
.minicard .minicard-creator {
border-bottom: 1px solid #008000;
}
.minicard .minicard-members:empty,
.minicard .minicard-assignees:empty {
display: none;
}
.minicard .minicard-description {
padding: 0.8vh 0 0 1vw;
color: #000;
background-color: #eee;
padding: 0.5lh 1ch;
--overflow-lines: 2;
.viewer {
font-size: 0.9em;
ul {
padding-bottom: 0;
}
}
width: 100%;
margin-bottom: 0.3vh;
margin-left: -0.5vw;
border-radius: 0.4vw;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.minicard .minicard-description p {
margin: 0;
}
.minicard-composer {
display: flex;
flex-direction: column;
.minicard-composer-icons {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2lh;
}
.minicard-bottom {
display: flex;
justify-content: end;
align-items: center;
gap: 1ch;
.minicard-composer-icons {
display: flex;
flex: 1;
flex-wrap: wrap;
flex-direction: row-reverse;
}
.add-controls {
display: flex;
align-self: start;
}
textarea {
display: flex;
flex: 1;
}
}
}
.minicard.minicard-composer {
flex-wrap: wrap;
flex: 1;
align-self: stretch;
gap: 0.3lh;
padding: 0.5lh 1ch;
position: relative;
margin-bottom: 1.3vh;
}
.minicard.minicard-composer textarea.minicard-composer-textarea,
.minicard.minicard-composer textarea.minicard-composer-textarea:focus {
resize: none;
@ -443,11 +415,11 @@
box-shadow: none;
height: auto;
margin: 0;
padding: 1ch;
min-height: 5lh;
padding: 0;
max-height: 22vh;
min-height: 5vh;
margin-bottom: 2.5vh;
overflow-y: auto;
flex: 1;
width: 100%;
}
.parent-prefix {
color: #b3b3b3;
@ -762,12 +734,30 @@
/* List name display on minicard */
.minicard-list-name {
font-size: inherit;
font-size: 0.75em;
color: #8c8c8c;
margin-top: 0.2vh;
display: flex;
padding: 0 0.5ch;
align-items: center;
gap: 0.3vw;
}
/* Checklist display on minicard */
.minicard-checklist {
width: 100%;
margin-top: 0.5vh;
margin-bottom: 0.5vh;
padding: 0.3vh 0.5vw;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 0.3vw;
border: 1px solid #e0e0e0;
}
.minicard-checklist .checklist-header {
display: flex;
gap: 0.5ch;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3vh;
}
.minicard-checklist .checklist-title {

View file

@ -1,236 +1,227 @@
template(name="minicard")
if isSelected
+cardDetailsPopup(this)
.minicard.nodragscroll(
class="{{#if isLinkedCard}}linked-card{{/if}}"
class="{{#if isLinkedBoard}}linked-board{{/if}}"
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
if canMoveCard
if isTouchScreenOrShowDesktopDragHandles
.handle
i.fa.fa-arrows
if canModifyCard
a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
.dates
if allowsReceivedDate
if getReceived
.date.viewer
+minicardReceivedDate
if allowsStartDate
if getStart
.date.viewer
+minicardStartDate
if allowsDueDate
if getDue
.date.viewer
+minicardDueDate
if allowsEndDate
if getEnd
.date.viewer
+minicardEndDate
if getReceived
.date
+minicardReceivedDate
if getStart
.date
+minicardStartDate
if getDue
.date
+minicardDueDate
if getEnd
+minicardEndDate
if getSpentTime
.date.viewer
.date
+cardSpentTime
.minicard-header
.minicard-title
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix
| {{ parentString ' > ' }}
if $eq 'prefix-with-parent' currentBoard.presentParentTask
.parent-prefix
| {{ parentCardName }}
if isLinkedBoard
a.js-linked-link
span.linked-icon
i.fa.fa-folder
else if isLinkedCard
a.js-linked-link
span.linked-icon
i.fa.fa-id-card
if getArchived
span.linked-icon.linked-archived
i.fa.fa-archive
+viewer
if allowsCardNumber
span.card-number
| ##{getCardNumber}&thinsp;
= getTitle
div.minicard-actions-right
if canModifyCard
a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
if isMiniScreen
if canMoveCard
.handle
i.fa.fa-arrows
hr
.minicard-body
if cover
if allowsCoverAttachmentOnMinicard
.minicard-cover(style="background-image: url('{{cover.link 'original'}}?dummyReloadAfterSessionEstablished={{sess}}');")
if cover
if currentBoard.allowsCoverAttachmentOnMinicard
.minicard-cover(style="background-image: url('{{cover.link 'original'}}?dummyReloadAfterSessionEstablished={{sess}}');")
//- Upload progress indicator for drag-and-drop uploads
if hasActiveUploads
.minicard-upload-progress
.upload-progress-header
i.fa.fa-upload
span {{_ 'uploading-files'}} ({{uploadCount}})
each uploads
.upload-progress-item(class="{{#if $eq status 'error'}}upload-error{{/if}}")
.upload-progress-filename {{file.name}}
.upload-progress-bar
.upload-progress-fill(style="width: {{progress}}%")
if $eq status 'error'
.upload-progress-error
i.fa.fa-warning
span {{_ 'upload-failed'}}
else if $eq status 'completed'
.upload-progress-success
i.fa.fa-check
span {{_ 'upload-completed'}}
// Upload progress indicator for drag-and-drop uploads
if hasActiveUploads
.minicard-upload-progress
.upload-progress-header
i.fa.fa-upload
span {{_ 'uploading-files'}} ({{uploadCount}})
each uploads
.upload-progress-item(class="{{#if $eq status 'error'}}upload-error{{/if}}")
.upload-progress-filename {{file.name}}
.upload-progress-bar
.upload-progress-fill(style="width: {{progress}}%")
if $eq status 'error'
.upload-progress-error
i.fa.fa-warning
span {{_ 'upload-failed'}}
else if $eq status 'completed'
.upload-progress-success
i.fa.fa-check
span {{_ 'upload-completed'}}
if labels
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
each labels
unless hiddenMinicardLabelText
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
+viewer
= name
if hiddenMinicardLabelText
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-title
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix
| {{ parentString ' > ' }}
if $eq 'prefix-with-parent' currentBoard.presentParentTask
.parent-prefix
| {{ parentCardName }}
if isLinkedBoard
a.js-linked-link
span.linked-icon
i.fa.fa-folder
else if isLinkedCard
a.js-linked-link
span.linked-icon
i.fa.fa-id-card
if getArchived
span.linked-icon.linked-archived
i.fa.fa-archive
+viewer
if currentBoard.allowsCardNumber
span.card-number
| ##{getCardNumber}
= getTitle
if labels
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
each labels
unless hiddenMinicardLabelText
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
+viewer
= name
if hiddenMinicardLabelText
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-custom-fields
each customFieldsWD
if definition.showOnCard
if trueValue
.minicard-custom-field
// If there is custom field label, show label at left,
// and value at right
if definition.showLabelOnMiniCard
.minicard-custom-field-item
.minicard-custom-fields
each customFieldsWD
if definition.showOnCard
if trueValue
.minicard-custom-field
// If there is custom field label, show label at left,
// and value at right
if definition.showLabelOnMiniCard
.minicard-custom-field-item
+viewer
= definition.name
.minicard-custom-field-item
if $eq definition.type "currency"
+viewer
= definition.name
.minicard-custom-field-item
if $eq definition.type "currency"
+viewer
= formattedCurrencyCustomFieldValue(definition)
else if $eq definition.type "date"
.date
+minicardCustomFieldDate
else if $eq definition.type "checkbox"
.materialCheckBox(class="{{#if value }}is-checked{{/if}}")
else if $eq definition.type "stringtemplate"
+viewer
= formattedStringtemplateCustomFieldValue(definition)
else
+viewer
= trueValue
else
// If there is no custom field label,
// show value full width
.minicard-custom-field-item-fullwidth
if $eq definition.type "currency"
+viewer
= formattedCurrencyCustomFieldValue(definition)
else if $eq definition.type "date"
.date
+minicardCustomFieldDate
else if $eq definition.type "checkbox"
.materialCheckBox(class="{{#if value }}is-checked{{/if}}")
else if $eq definition.type "stringtemplate"
+viewer
= formattedStringtemplateCustomFieldValue(definition)
else
+viewer
= trueValue
.minicard-people-grid
if allowsAssignee
if getAssignees
.minicard-people-wrapper
.minicard-assignees.js-minicard-assignees
each getAssignees
+userAvatar(userId=this)
= formattedCurrencyCustomFieldValue(definition)
else if $eq definition.type "date"
.date
+minicardCustomFieldDate
else if $eq definition.type "checkbox"
.materialCheckBox(class="{{#if value }}is-checked{{/if}}")
else if $eq definition.type "stringtemplate"
+viewer
= formattedStringtemplateCustomFieldValue(definition)
else
+viewer
= trueValue
else
// If there is no custom field label,
// show value full width
.minicard-custom-field-item-fullwidth
if $eq definition.type "currency"
+viewer
= formattedCurrencyCustomFieldValue(definition)
else if $eq definition.type "date"
.date
+minicardCustomFieldDate
else if $eq definition.type "checkbox"
.materialCheckBox(class="{{#if value }}is-checked{{/if}}")
else if $eq definition.type "stringtemplate"
+viewer
= formattedStringtemplateCustomFieldValue(definition)
else
+viewer
= trueValue
if allowsMembers
if getMembers
.minicard-people-wrapper
.minicard-members.js-minicard-members
each getMembers
+userAvatar(userId=this)
if showAssignee
if getAssignees
.minicard-assignees.js-minicard-assignees
each getAssignees
+userAvatar(userId=this)
.minicard-badges-and-creator
if allowsCreatorOnMinicard
.minicard-creator
+userAvatar(userId=this.userId noRemove=true)
if showMembers
if getMembers
.minicard-members.js-minicard-members
each getMembers
+userAvatar(userId=this)
.badges
if canModifyCard
if allowsComments
if comments.length
.badge(title="{{_ 'card-comments-title' comments.length }}")
span.badge-icon.badge-comment.badge-text
i.fa.fa-comment-o
= ' '
= comments.length
//span.badge-comment.badge-text
//| {{_ 'comment'}}
if getDescription
unless allowsDescriptionTextOnMinicard
.badge.badge-state-image-only(title=getDescription)
span.badge-icon
i.fa.fa-file-text-o
if getVoteQuestion
.badge.badge-state-image-only(title=getVoteQuestion)
span.badge-icon(class="{{#if voteState}}text-green{{/if}}")
i.fa.fa-thumbs-up
span.badge-text {{ voteCountPositive }}
span.badge-icon(class="{{#if $eq voteState false}}text-red{{/if}}")
i.fa.fa-thumbs-down
span.badge-text {{ voteCountNegative }}
if getPokerQuestion
.badge.badge-state-image-only(title=getPokerQuestion)
span.badge-icon(class="{{#if pokerState}}text-green{{/if}}")
i.fa.fa-check-square
if expiredPoker
span.badge-text {{ getPokerEstimation }}
if attachments.length
if allowsBadgeAttachmentOnMinicard
.badge
span.badge-icon
i.fa.fa-paperclip
span.badge-text= attachments.length
if checklists.length
if allowsChecklists
.badge(class="{{#if checklistFinished}}is-finished{{/if}}")
span.badge-icon
i.fa.fa-check
span.badge-text.check-list-text {{checklistFinishedCount}}/{{checklistItemCount}}
if allSubtasks.count
if allowsSubtasks
.badge
span.badge-icon
i.fa.fa-globe
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
if allowsCardSortingByNumber
if allowsCardSortingByNumberOnMinicard
.badge
span.badge-icon
i.fa.fa-sort-numeric-asc
span.badge-text.check-list-sort {{ sort }}
if shouldShowListOnMinicard
.minicard-list-name
span
i.fa.fa-list
span
| {{ listName }}
if $eq 'subtext-with-full-path' presentParentTask
.parent-subtext
| {{ parentString ' > ' }}
if $eq 'subtext-with-parent' presentParentTask
.parent-subtext
| {{ parentCardName }}
if allowsDescriptionTextOnMinicard
if showCreatorOnMinicard
.minicard-creator
+userAvatar(userId=this.userId noRemove=true)
.badges
if canModifyCard
if comments.length
.badge(title="{{_ 'card-comments-title' comments.length }}")
span.badge-icon.badge-comment.badge-text
i.fa.fa-comment-o
= ' '
= comments.length
//span.badge-comment.badge-text
//|
{{_ 'comment'}}
if getDescription
unless currentBoard.allowsDescriptionTextOnMinicard
.badge.badge-state-image-only(title=getDescription)
span.badge-icon
i.fa.fa-file-text-o
if getVoteQuestion
.badge.badge-state-image-only(title=getVoteQuestion)
span.badge-icon(class="{{#if voteState}}text-green{{/if}}")
i.fa.fa-thumbs-up
span.badge-text {{ voteCountPositive }}
span.badge-icon(class="{{#if $eq voteState false}}text-red{{/if}}")
i.fa.fa-thumbs-down
span.badge-text {{ voteCountNegative }}
if getPokerQuestion
.badge.badge-state-image-only(title=getPokerQuestion)
span.badge-icon(class="{{#if pokerState}}text-green{{/if}}")
i.fa.fa-check-square
if expiredPoker
span.badge-text {{ getPokerEstimation }}
if attachments.length
if currentBoard.allowsBadgeAttachmentOnMinicard
.badge
span.badge-icon
i.fa.fa-paperclip
span.badge-text= attachments.length
if allSubtasks.count
.badge
span.badge-icon
i.fa.fa-globe
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
i.fa.fa-sort-numeric-asc
span.badge-text.check-list-sort {{ sort }}
if shouldShowChecklistAtMinicard
each shouldShowChecklistAtMinicard
+minicardChecklist(checklist=. card=..)
if currentBoard.allowsDescriptionTextOnMinicard
if getDescription
.minicard-description
+viewer
| {{ getDescription }}
if shouldShowListOnMinicard
.minicard-list-name
i.fa.fa-list
| {{ listName }}
if $eq 'subtext-with-full-path' currentBoard.presentParentTask
.parent-subtext
| {{ parentString ' > ' }}
if $eq 'subtext-with-parent' currentBoard.presentParentTask
.parent-subtext
| {{ parentCardName }}
template(name="editCardSortOrderPopup")
input.js-edit-card-sort-popup(type='text' value=sort dir="auto")
input.js-edit-card-sort-popup(type='text' autofocus value=sort dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-card-sort-popup(type="submit") {{_ 'save'}}
button.primary.confirm.js-submit-edit-card-sort-popup(type="submit") {{_ 'save'}}
template(name="minicardChecklist")
.minicard-checklist
.checklist-header
.checklist-title= checklist.title
if canModifyCard
a.checklist-menu.js-open-checklist-menu(title="{{_ 'checklistActionsPopup-title'}}")
i.fa.fa-bars
each visibleItems
+checklistItemDetail(item = . checklist = checklist card = card)

View file

@ -13,24 +13,6 @@ BlazeComponent.extendComponent({
return 'minicard';
},
onRendered() {
// cannot be done with CSS because newlines
// rendered by the JADE engine count as non empty
// and some "empty" divs are nested
// this is not very robust and could probably be
// done with a helper, but it could be in fact worse
// because we would need to to if (allowsX() && X() && ...)
const body = $(this.find('.minicard-body'));
if (!body) {return}
let emptyChildren;
do {
emptyChildren = body.find('*').filter((_, e) => !e.classList.contains('fa') && $(e).html().trim().length === 0).remove();
} while (emptyChildren.length > 0)
if (body.html().trim().length === 0) {
body.parent().find('hr:has(+ .minicard-body)').remove();
}
},
formattedCurrencyCustomFieldValue(definition) {
const customField = this.data()
.customFieldsWD()
@ -57,14 +39,46 @@ BlazeComponent.extendComponent({
return ret;
},
showCreatorOnMinicard() {
// cache "board" to reduce the mini-mongodb access
const board = this.data().board();
let ret = false;
if (board) {
ret = board.allowsCreatorOnMinicard ?? false;
}
return ret;
},
isWatching() {
const card = this.currentData();
return card.findWatcher(Meteor.userId());
},
isSelected() {
const card = this.currentData();
return Session.get('currentCard') === card._id;
showMembers() {
// cache "board" to reduce the mini-mongodb access
const board = this.data().board();
let ret = false;
if (board) {
ret =
board.allowsMembers === null ||
board.allowsMembers === undefined ||
board.allowsMembers
;
}
return ret;
},
showAssignee() {
// cache "board" to reduce the mini-mongodb access
const board = this.data().board();
let ret = false;
if (board) {
ret =
board.allowsAssignee === null ||
board.allowsAssignee === undefined ||
board.allowsAssignee
;
}
return ret;
},
/** opens the card label popup only if clicked onto a label
@ -73,8 +87,6 @@ BlazeComponent.extendComponent({
*/
cardLabelsPopup(event) {
if (this.find('.js-card-label:hover')) {
event.preventDefault();
event.stopPropagation();
Popup.open("cardLabels")(event, {dataContextIfCurrentDataIsUndefined: this.currentData()});
}
},
@ -242,8 +254,33 @@ Template.minicard.helpers({
},
shouldShowListOnMinicard() {
return Utils.allowsShowLists();
// Show list name if either:
// 1. Board-wide setting is enabled, OR
// 2. This specific card has the setting enabled
const currentBoard = this.board();
if (!currentBoard) return false;
return currentBoard.allowsShowListsOnMinicard || this.showListOnMinicard;
},
shouldShowChecklistAtMinicard() {
// Return checklists that should be shown on minicard
const currentBoard = this.board();
if (!currentBoard) return [];
const checklists = this.checklists();
const visibleChecklists = [];
checklists.forEach(checklist => {
// Show checklist if either:
// 1. Board-wide setting is enabled, OR
// 2. This specific checklist has the setting enabled
if (currentBoard.allowsChecklistAtMinicard || checklist.showChecklistAtMinicard) {
visibleChecklists.push(checklist);
}
});
return visibleChecklists;
}
});
BlazeComponent.extendComponent({

View file

@ -18,8 +18,7 @@
font-weight: bold;
}
.result-card-context-list {
display: flex;
gap: 0.2ch;
margin-bottom: 0.7rem;
}
.result-card-block-wrapper {
display: inline-block;

View file

@ -14,10 +14,17 @@ BlazeComponent.extendComponent({
onReady() {
Session.set('popupCardId', cardId);
Session.set('popupCardBoardId', boardId);
this_.cardDetailsPopup(evt);
},
});
},
cardDetailsPopup(event) {
if (!Popup.isOpen()) {
Popup.open("cardDetails")(event);
}
},
events() {
return [
{

View file

@ -4,14 +4,19 @@
textarea.js-add-subtask-item,
textarea.js-edit-subtask-item {
overflow: hidden;
overflow-wrap: break-word;
word-wrap: break-word;
resize: none;
height: 34px;
}
.delete-text,
.subtask-title .js-delete-subtask,
.subtask-title .js-view-subtask,
.js-delete-subtask-item {
color: #8c8c8c;
text-decoration: underline;
word-wrap: break-word;
float: right;
padding-top: 6px;
}
.delete-text:hover,
.subtask-title .js-delete-subtask:hover,
@ -23,11 +28,11 @@ textarea.js-edit-subtask-item {
float: left;
width: 30px;
height: 30px;
font-size: 18px;
line-height: 30px;
}
.subtask-title .title {
font-size: 18px;
line-height: 25px;
}
.subtask-title .subtasks-stat {
@ -128,7 +133,7 @@ textarea.js-edit-subtask-item {
margin: 0.1em 0 0 0;
}
.subtasks-item .check-box.is-checked {
border-bottom: 0.2ch solid #3cb500;
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
}
/* Unicode checkbox icons styling */
@ -160,4 +165,16 @@ body.grey-icons-enabled .subtasks-item .check-box-unicode {
}
.subtasks-item .item-title .viewer p {
margin-bottom: 2px;
}
}
.js-delete-subtask-item {
margin: 0 0 0.5em 1.33em;
padding: 12px 0 0 0;
}
.add-subtask-item {
margin: 0.2em 0 0.5em 1.33em;
display: inline-block;
}
.subtask-details-menu {
float: right;
padding: 6px 10px 6px 10px;
}

View file

@ -4,20 +4,12 @@ template(name="subtasks")
| {{_ 'subtasks'}}
if currentUser.isBoardAdmin
if toggleDeleteDialog.get
.board-overlay#card-details-overlay
+subtaskDeleteDialog(subtask = subtaskToDelete)
if currentCard.subtasks
.card-subtasks-items
each subtask in currentCard.subtasks
.subtask-container
+subtaskDetail(subtask = subtask)
if canModifyCard
a.subtask-details-menu.js-open-subtask-details-menu(title="{{_ 'subtaskActionsPopup-title'}}")
| ☰
if currentUser.isBoardAdmin
a.js-delete-subtask-item
| ❌
.card-subtasks-items
each subtask in currentCard.subtasks
+subtaskDetail(subtask = subtask)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-subtask" cardId = cardId)
@ -32,6 +24,9 @@ template(name="subtaskDetail")
+editSubtaskItemForm(subtask = subtask)
else
.subtask-title
span
if canModifyCard
a.subtask-details-menu.js-open-subtask-details-menu(title="{{_ 'subtaskActionsPopup-title'}}")
if canModifyCard
h2.title.js-open-inlined-form.is-editable
+viewer
@ -42,13 +37,13 @@ template(name="subtaskDetail")
= subtask.title
template(name="addSubtaskItemForm")
textarea.js-add-subtask-item(rows='1' dir="auto")
textarea.js-add-subtask-item(rows='1' autofocus dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-add-subtask-item-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form
template(name="editSubtaskItemForm")
textarea.js-edit-subtask-item(rows='1' dir="auto")
textarea.js-edit-subtask-item(rows='1' autofocus dir="auto")
if $eq type 'item'
= item.title
else
@ -57,6 +52,9 @@ template(name="editSubtaskItemForm")
button.primary.confirm.js-submit-edit-subtask-item-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form
span(title=createdAt) {{ moment createdAt }}
if canModifyCard
if currentUser.isBoardAdmin
a.js-delete-subtask-item {{_ "delete"}}...
template(name="subtasksItems")
.subtasks-items.js-subtasks-items
@ -102,3 +100,4 @@ template(name="subtaskActionsPopup")
a.js-delete-subtask.delete-subtask
i.fa.fa-trash
| {{_ "delete"}} ...

View file

@ -2,8 +2,8 @@
.original-position-info {
margin: 5px 0;
padding: 8px;
border-radius: 0.4ch;
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
}
@ -57,7 +57,7 @@
.original-title {
color: #6c757d;
font-size: 11px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid #e9ecef;
@ -78,7 +78,7 @@
/* Responsive adjustments */
@media (max-width: 768px) {
.original-position-info {
font-size: 11px;
padding: 6px;
}

View file

@ -15,7 +15,7 @@
<span class="original-position-text">✅ In original position</span>
</div>
{{/if}}
{{#if getOriginalTitle}}
<div class="original-title">
<strong>Original title:</strong> {{getOriginalTitle}}

View file

@ -13,7 +13,7 @@ class OriginalPositionComponent extends BlazeComponent {
this.originalPosition = new ReactiveVar(null);
this.isLoading = new ReactiveVar(false);
this.hasMoved = new ReactiveVar(false);
this.autorun(() => {
const data = this.data();
if (data && data.entityId && data.entityType) {
@ -24,9 +24,9 @@ class OriginalPositionComponent extends BlazeComponent {
loadOriginalPosition(entityId, entityType) {
this.isLoading.set(true);
const methodName = `positionHistory.get${entityType.charAt(0).toUpperCase() + entityType.slice(1)}OriginalPosition`;
Meteor.call(methodName, entityId, (error, result) => {
this.isLoading.set(false);
if (error) {
@ -34,7 +34,7 @@ class OriginalPositionComponent extends BlazeComponent {
this.originalPosition.set(null);
} else {
this.originalPosition.set(result);
// Check if the entity has moved
const movedMethodName = `positionHistory.has${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Moved`;
Meteor.call(movedMethodName, entityId, (movedError, movedResult) => {
@ -61,11 +61,11 @@ class OriginalPositionComponent extends BlazeComponent {
getOriginalPositionDescription() {
const position = this.getOriginalPosition();
if (!position) return 'No original position data';
if (position.originalPosition) {
const entityType = this.data().entityType;
let description = `Original position: ${position.originalPosition.sort || 0}`;
if (entityType === 'list' && position.originalSwimlaneId) {
description += ` in swimlane ${position.originalSwimlaneId}`;
} else if (entityType === 'card') {
@ -76,10 +76,10 @@ class OriginalPositionComponent extends BlazeComponent {
description += ` in list ${position.originalListId}`;
}
}
return description;
}
return 'No original position data';
}

View file

@ -1,18 +1,22 @@
.datepicker-container {
form {
display: flex;
gap: 0.3lh;
padding: 0.3lh 1ch;
}
.fields {
display: flex;
justify-content: stretch;
gap: 1ch;
.left, .right {
display: flex;
flex: 1;
flex-direction: column;
align-items: stretch;
}
}
}
.datepicker-container .fields .left {
width: 56%;
}
.datepicker-container .fields .right {
width: 38%;
}
.datepicker-container .datepicker {
width: 100%;
}
.datepicker-container .datepicker table {
width: 100%;
border: none;
border-spacing: 0;
border-collapse: collapse;
}
.datepicker-container .datepicker table thead {
background: none;
}
.datepicker-container .datepicker table td,
.datepicker-container .datepicker table th {
box-sizing: border-box;
}

View file

@ -1,16 +1,3 @@
select, button, input {
font-size: 1rem !important;
}
form {
/* 🛑 remove me if it causes a significant issue.
this can be overidden and otherwise allow forms to
scale with their parent. */
display: flex;
flex-direction: column;
flex: 1;
}
select,
textarea,
input:not([type=file]),
@ -20,8 +7,9 @@ button {
border: 1px solid #ccc;
border-radius: 0.4vw;
display: block;
padding: 0.3lh 1ch;
max-width: clamp(30vw, 100%, 800px);
margin-bottom: 1.5vh;
min-height: 4.5vh;
padding: 1vh 1vw;
}
select.full,
textarea.full,
@ -54,6 +42,18 @@ input[type="text"],
input[type="password"],
input[type="email"] {
transition: background 85ms ease-in, border-color 85ms ease-in;
width: min(250px, 30vw);
}
input[type="text"].inline-input,
input[type="password"].inline-input,
input[type="email"].inline-input {
background: none;
border: 0;
margin: 0;
padding: 0.3vh;
min-height: 0;
height: 2.5vh;
width: min(200px, 25vw);
}
input[type="text"].full-line,
input[type="password"].full-line,
@ -102,6 +102,11 @@ textarea:disabled {
-webkit-user-select: none;
user-select: none;
}
select {
max-height: 40vh;
width: min(256px, 32vw);
margin-bottom: 1vh;
}
select.inline {
width: 100%;
}
@ -109,11 +114,14 @@ option[disabled] {
color: #222;
}
textarea {
height: 20vh;
transition: background 85ms ease-in, border-color 85ms ease-in;
resize: vertical;
width: auto;
font-size: 0.9em;
min-height: 3lh;
width: 100%;
}
textarea.editor {
resize: none;
padding-bottom: 3vh;
}
.button {
border-radius: 3px;
@ -129,16 +137,9 @@ button {
display: inline-block;
font-weight: 700;
line-height: 1.3;
/* in flex layouts, padding often disturbs computations. rather rarely have
two lines, so setting relative-unit min-height works better */
min-height: 1.8lh;
padding: 0 2ch;
padding: 1vh 2.5vw;
text-align: center;
color: #fff;
z-index: 1;
:not(.password-toggle-btn) {
margin-top: 0.1lh;
}
}
input[type="submit"] .wide,
button .wide {
@ -240,9 +241,9 @@ input[type="hidden"] {
}
.radio-div,
.check-div {
display: flex;
align-items: center;
gap: 0.2lh;
display: block;
margin: 0 0 0.5vh 2.5vw;
min-height: 2.5vh;
position: relative;
}
.radio-div input,
@ -259,10 +260,9 @@ input[type="hidden"] {
font-weight: 400;
}
label {
display: flex;
flex-direction: column;
flex: 1;
display: block;
font-weight: 700;
margin-bottom: 0.5vh;
}
label.form-error {
color: #d32f2f;
@ -274,32 +274,11 @@ textarea::-moz-placeholder {
color: #333 !important;
}
.edit-controls,
.add-controls,
.links-controls {
.add-controls {
display: flex;
align-items: center;
gap: 1ch;
button {
display: flex;
justify-content: center;
align-items: center;
}
}
.edit-controls {
grid-area: main-controls;
}
.add-controls {
grid-area: main-controls;
}
.links-controls {
grid-area: links-controls
}
.links-controls span.quiet {
margin: auto;
margin-top: 0px;
margin-bottom: 1.5vh;
}
@media print {
.add-controls {
@ -310,7 +289,14 @@ textarea::-moz-placeholder {
.add-controls button[type=submit],
.edit-controls input[type=button],
.add-controls input[type=button] {
margin: 0;
float: left;
height: 4.5vh;
margin-bottom: 0px;
}
.edit-controls .fa-times-thin,
.add-controls .fa-times-thin {
font-size: clamp(20px, 4vw, 26px);
margin: 0.5vh 1.5vw;
}
[type="checkbox"]:not(:checked),
[type="checkbox"]:checked {
@ -320,18 +306,6 @@ textarea::-moz-placeholder {
display: none;
}
.materialCheckBox {
position: relative;
width: 0.5lh;
height: 0.5lh;
z-index: 0;
border: 0.2ch solid #5a5a5a;
border-radius: 1px;
transition: 0.2s;
margin: 0;
margin-left: 0px;
cursor: pointer;
}
.materialCheckBox:is(.active) {
position: relative;
width: 13px;
height: 13px;
@ -343,33 +317,19 @@ textarea::-moz-placeholder {
cursor: pointer;
}
.materialCheckBox.is-checked {
top: 0.3lh;
left: 0.25lh;
width: 0.25lh;
height: 0.5lh;
margin-right: 0.6lh;
border-top: 0 solid transparent;
border-left: 0 solid transparent;
border-bottom: 0.3ch solid #3cb500;
border-right: 0.3ch solid #3cb500;
top: -4px;
left: -3px;
width: 7px;
height: 15px;
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: rotate(50deg);
backface-visibility: hidden;
transform-origin: 0.5lh 0;
}
form .form-buttons {
display: flex;
flex: 1;
align-self: stretch;
justify-content: stretch;
gap: 0.5ch;
&>button {
display: flex;
flex: 1;
justify-content: center;
}
transform-origin: 100% 100%;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .materialCheckBox.is-checked {
@ -402,7 +362,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
border-radius: 3px;
color: #fff;
display: none;
font-size: 12px;
font-weight: 700;
height: 17px;
line-height: 17px;
@ -459,7 +419,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
.button-link.setting .label {
color: #222;
display: block;
font-size: 12px;
line-height: 14px;
margin-bottom: 0;
}
@ -468,7 +428,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
}
.button-link.setting .value {
display: block;
font-size: 18px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
@ -612,7 +572,7 @@ button.loud-text-button:hover {
padding: 11px;
position: relative;
text-decoration: none;
font-size: 16px;
line-height: 20px;
}
.big-link .text {
@ -655,7 +615,7 @@ button.loud-text-button:hover {
width: 40px;
}
.big-link.avatar-changer .member .member-initials {
font-size: 16px;
height: 40px;
line-height: 40px;
max-height: 40px;
@ -695,7 +655,7 @@ button.loud-text-button:hover {
left: 0;
width: 100%;
z-index: 2;
font-size: 23px;
}
.uploader .realfile input[type="file"] {
cursor: pointer;
@ -706,7 +666,7 @@ button.loud-text-button:hover {
padding: 0;
width: 100%;
z-index: 2;
font-size: 23px;
}
.uploader:hover .fakefile {
background: #318ec4;
@ -745,13 +705,13 @@ button.loud-text-button:hover {
color: #fff;
}
.material-toggle-switch {
padding: 0.2rlh 1ch;
align-self: center;
display: flex;
}
.toggle-label {
height: 0.6rlh;
width: 1.3rlh;
position: relative;
display: block;
height: 20px;
width: 44px;
background-color: #a6a6a6;
border-radius: 100px;
cursor: pointer;
@ -759,13 +719,11 @@ button.loud-text-button:hover {
}
.toggle-label:after {
position: absolute;
/* ensure vertical centering */
margin: auto;
top: 0;
bottom: 0;
left: -0.2rlh;
width: .8rlh;
height: .8rlh;
left: -2px;
top: -3px;
display: block;
width: 26px;
height: 26px;
border-radius: 100px;
background-color: #fff;
box-shadow: 0px 3px 3px rgba(0,0,0,0.05);
@ -779,7 +737,7 @@ button.loud-text-button:hover {
background-color: #6fbeb5;
}
.toggle-switch:checked ~ .toggle-label:after {
left: 1.5ch;
left: 20px;
background-color: #179588;
}
.toggle-switch:checked:disabled ~ .toggle-label {

View file

@ -52,7 +52,7 @@
min-width: 800px;
border: 2px solid #666;
font-family: sans-serif;
font-size: 13px;
background-color: #fff;
}
@ -81,7 +81,7 @@
padding: 2px 1px; /* half */
text-align: center;
background-color: #f5f5f5;
font-size: 11px;
min-width: 15px; /* half of 30px */
font-weight: bold;
height: auto;
@ -112,7 +112,7 @@
vertical-align: middle;
line-height: 28px;
background-color: #ffffff;
font-size: 18px;
font-weight: bold;
}
@ -162,7 +162,7 @@
.gantt-container tbody td.ganttview-block {
background-color: #4CAF50 !important;
color: #fff !important;
font-size: 18px !important;
font-weight: bold !important;
padding: 2px !important;
border-radius: 2px;
@ -171,7 +171,7 @@
/* Responsive adjustments */
@media (max-width: 768px) {
.gantt-container table {
font-size: 11px;
}
.gantt-container thead td {
@ -187,7 +187,7 @@
.gantt-container tbody td:first-child {
width: 100px;
font-size: 12px;
}
}

View file

@ -347,7 +347,7 @@ BlazeComponent.extendComponent({
const results = UserSearchIndex.search(query, { limit: 20 }).fetch();
this.searchResults.set(results);
this.searching.set(false);
if (results.length === 0) {
this.noResults.set(true);
}
@ -358,11 +358,11 @@ BlazeComponent.extendComponent({
{
'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);

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
template(name='list')
.list.js-list(id="js-list-{{_id}}"
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
unless collapsed
+listBody
.list-resize-handle.js-list-resize-handle.nodragscroll
.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}}")
+listHeader
if isCurrentList
+listBody

View file

@ -4,8 +4,6 @@ require('/client/lib/jquery-ui.js')
const { calculateIndex } = Utils;
export const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
BlazeComponent.extendComponent({
// Proxy
openForm(options) {
@ -14,7 +12,6 @@ BlazeComponent.extendComponent({
onCreated() {
this.newCardFormIsVisible = new ReactiveVar(true);
this.collapse = new ReactiveVar(Utils.getListCollapseState(this.data()));
},
// The jquery UI sortable library is the best solution I've found so far. I
@ -25,32 +22,178 @@ BlazeComponent.extendComponent({
// callback, we basically solve all issues related to reactive updates. A
// comment below provides further details.
onRendered() {
this.list = this.firstNode();
this.resizeHandle = this.find('.js-list-resize-handle');
const boardComponent = this.parentComponent().parentComponent();
// Initialize list resize functionality immediately
this.initializeListResize();
const ensureCollapseState = (collapsed) => {
if (this.collapse.get() === collapsed) return;
if (this.autoWidth() || collapsed) {
$(this.resizeHandle).hide();
} else {
$(this.resizeHandle).show();
}
this.collapse.set(collapsed);
this.initializeListResize();
}
const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
const $cards = this.$('.js-minicards');
$cards.sortable({
connectWith: '.js-minicards:not(.js-list-full)',
tolerance: 'pointer',
appendTo: '.board-canvas',
helper(evt, item) {
const helper = item.clone();
if (MultiSelection.isActive()) {
const andNOthers = $cards.find('.js-minicard.is-checked').length - 1;
if (andNOthers > 0) {
helper.append(
$(
Blaze.toHTML(
HTML.DIV(
{ class: 'and-n-other' },
TAPi18n.__('and-n-other-card', { count: andNOthers }),
),
),
),
);
}
}
return helper;
},
distance: 7,
items: itemsSelector,
placeholder: 'minicard-wrapper placeholder',
scrollSpeed: 10,
start(evt, ui) {
ui.helper.css('z-index', 1000);
ui.placeholder.height(ui.helper.height());
EscapeActions.executeUpTo('popup-close');
boardComponent.setIsDragging(true);
},
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 prevCardDom = ui.item.prev('.js-minicard').get(0);
const nextCardDom = ui.item.next('.js-minicard').get(0);
const nCards = MultiSelection.isActive() ? MultiSelection.count() : 1;
const sortIndex = calculateIndex(prevCardDom, nextCardDom, nCards);
const listId = Blaze.getData(ui.item.parents('.list').get(0))._id;
const currentBoard = Utils.getCurrentBoard();
const defaultSwimlaneId = currentBoard.getDefaultSwimline()._id;
let targetSwimlaneId = null;
// only set a new swimelane ID if the swimlanes view is active
if (
Utils.boardView() === 'board-view-swimlanes' ||
currentBoard.isTemplatesBoard()
)
targetSwimlaneId = Blaze.getData(ui.item.parents('.swimlane').get(0))
._id;
// 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.
$cards.sortable('cancel');
if (MultiSelection.isActive()) {
ReactiveCache.getCards(MultiSelection.getMongoSelector(), { sort: ['sort'] }).forEach((card, i) => {
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(
currentBoard._id,
newSwimlaneId,
listId,
sortIndex.base + i * sortIndex.increment,
);
});
} else {
const cardDomElement = ui.item.get(0);
const card = Blaze.getData(cardDomElement);
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(currentBoard._id, newSwimlaneId, listId, sortIndex.base);
}
boardComponent.setIsDragging(false);
},
sort(event, ui) {
const $boardCanvas = $('.board-canvas');
const boardCanvas = $boardCanvas[0];
if (event.pageX < 10) { // scroll to the left
boardCanvas.scrollLeft -= 15;
ui.helper[0].offsetLeft -= 15;
}
if (
event.pageX > boardCanvas.offsetWidth - 10 &&
boardCanvas.scrollLeft < $boardCanvas.data('scrollLeftMax') // don't scroll more than possible
) { // scroll to the right
boardCanvas.scrollLeft += 15;
}
if (
event.pageY > boardCanvas.offsetHeight - 10 &&
event.pageY + boardCanvas.scrollTop < $boardCanvas.data('scrollTopMax') // don't scroll more than possible
) { // scroll to the bottom
boardCanvas.scrollTop += 15;
}
if (event.pageY < 10) { // scroll to the top
boardCanvas.scrollTop -= 15;
}
},
activate(event, ui) {
const $boardCanvas = $('.board-canvas');
const boardCanvas = $boardCanvas[0];
// scrollTopMax and scrollLeftMax only available at Firefox (https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTopMax)
// https://www.it-swarm.com.de/de/javascript/so-erhalten-sie-den-maximalen-dokument-scrolltop-wert/1069126844/
$boardCanvas.data('scrollTopMax', boardCanvas.scrollHeight - boardCanvas.clientTop);
// https://stackoverflow.com/questions/5138373/how-do-i-get-the-max-value-of-scrollleft/5704386#5704386
$boardCanvas.data('scrollLeftMax', boardCanvas.scrollWidth - boardCanvas.clientWidth);
},
});
// Reactively update collapse appearance and resize handle visibility when auto-width or collapse changes
this.autorun(() => {
ensureCollapseState(Utils.getListCollapseState(this.data()));
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',
// Disable drag-dropping when user is not member
!Utils.canModifyBoard(),
// Not disable drag-dropping while in multi-selection mode
// MultiSelection.isActive() || !Utils.canModifyBoard(),
);
}
});
// We want to re-run this function any time a card is added.
this.autorun(() => {
const currentBoardId = Tracker.nonreactive(() => {
return Session.get('currentBoard');
});
Tracker.afterFlush(() => {
$cards.find(itemsSelector).droppable({
hoverClass: 'draggable-hover-card',
accept: '.js-member,.js-label',
drop(event, ui) {
const cardId = Blaze.getData(this)._id;
const card = ReactiveCache.getCard(cardId);
if (ui.draggable.hasClass('js-member')) {
const memberId = Blaze.getData(ui.draggable.get(0)).userId;
card.assignMember(memberId);
} else {
const labelId = Blaze.getData(ui.draggable.get(0))._id;
card.addLabel(labelId);
}
},
});
});
});
},
collapsed() {
return this.collapse.get();
},
listWidth() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
@ -79,7 +222,7 @@ BlazeComponent.extendComponent({
listConstraint() {
const user = ReactiveCache.getCurrentUser();
const list = Template.currentData();
if (!list) return 0;
if (!list) return 550; // Return default constraint if list is not available
if (user) {
// For logged-in users, get from user profile
@ -97,7 +240,7 @@ BlazeComponent.extendComponent({
} catch (e) {
console.warn('Error reading list constraint from localStorage:', e);
}
return 0;
return 550; // Return default constraint if not found
}
},
@ -113,14 +256,18 @@ BlazeComponent.extendComponent({
initializeListResize() {
// Check if we're still in a valid template context
if (!this.data()) {
if (!Template.currentData()) {
console.warn('No current template data available for list resize initialization');
return;
}
const list = Template.currentData();
const $list = this.$('.js-list');
const $resizeHandle = this.$('.js-list-resize-handle');
// Check if elements exist
if (!this.list || !this.resizeHandle) {
console.info('List or resize handle not found, retrying in 100ms');
if (!$list.length || !$resizeHandle.length) {
console.warn('List or resize handle not found, retrying in 100ms');
Meteor.setTimeout(() => {
if (!this.isDestroyed) {
this.initializeListResize();
@ -129,117 +276,95 @@ BlazeComponent.extendComponent({
return;
}
let isResizing = false;
let previousLimit = false;
// seems reasonable; better let user shrink too much that too little
const minWidth = 280;
// stored width
const width = this.listWidth();
// min-width is initially min-content; a good start
let maxWidth = this.listConstraint() || parseInt(this.list.style.getProperty('--list-min-width', `${(minWidth)}px`), 10) || width + 100;
if (!width || width > maxWidth) {
width = (maxWidth + minWidth) / 2;
}
this.list.style.setProperty('--list-min-width', `${Math.round(minWidth)}px`);
// actual size before fitting (usually max-content equivalent)
this.list.style.setProperty('--list-max-width', `${Math.round(maxWidth)}px`);
// avoid jump effect and ensure width stays consistent
this.list.style.setProperty('--list-width', `${Math.round(width)}px`);
const component = this;
// wait for click to add other events
const startResize = (e) => {
// gain access to modern attributes e.g. isPrimary
e = e.originalEvent;
if (isResizing || Utils.shouldIgnorePointer(e)) {
return;
// Reactively show/hide resize handle based on collapse and auto-width state
this.autorun(() => {
const isAutoWidth = this.autoWidth();
const isCollapsed = Utils.getListCollapseState(list);
if (isCollapsed || isAutoWidth) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
}
});
let isResizing = false;
let startX = 0;
let startWidth = 0;
let minWidth = 270; // Minimum width matching system default
let listConstraint = this.listConstraint(); // Store constraint value for use in event handlers
const component = this; // Store reference to component for use in event handlers
const startResize = (e) => {
isResizing = true;
startX = e.pageX || e.originalEvent.touches[0].pageX;
startWidth = $list.outerWidth();
// Add visual feedback
$list.addClass('list-resizing');
$('body').addClass('list-resizing-active');
// Prevent text selection during resize
$('body').css('user-select', 'none');
e.preventDefault();
e.stopPropagation();
$(document).on('pointermove', doResize);
// e.g. debugger can cancel event without pointerup being fired
$(document).on('pointercancel', stopResize);
$(document).on('pointerup', stopResize);
// --list-width can be either a stored size or "auto"; get actual computed size
component.currentWidth = component.list.offsetWidth;
component.list.classList.add('list-resizing');
document.body.classList.add('list-resizing-active');
isResizing = true;
};
const doResize = (e) => {
e = e.originalEvent;
if (!isResizing) {
return;
}
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
const deltaX = currentX - startX;
const newWidth = Math.max(minWidth, startWidth + deltaX);
// Apply the new width immediately for real-time feedback
$list[0].style.setProperty('--list-width', `${newWidth}px`);
$list[0].style.setProperty('width', `${newWidth}px`);
$list[0].style.setProperty('min-width', `${newWidth}px`);
$list[0].style.setProperty('max-width', `${newWidth}px`);
$list[0].style.setProperty('flex', 'none');
$list[0].style.setProperty('flex-basis', 'auto');
$list[0].style.setProperty('flex-grow', '0');
$list[0].style.setProperty('flex-shrink', '0');
e.preventDefault();
e.stopPropagation();
if (!isResizing || !e.isPrimary) {
return;
}
if (!previousLimit && component.collapsed()) {
previousLimit = true;
component.list.classList.add('cannot-resize');
return;
}
// relative to document, always >0 because pointer sticks to the right of list
const deltaX = e.clientX - component.list.getBoundingClientRect().right;
const candidateWidth = component.currentWidth + deltaX;
component.currentWidth = Math.max(minWidth, Math.min(maxWidth, candidateWidth));
const reachingMax = (maxWidth - component.currentWidth - 20) <= 0
const reachingMin = (component.currentWidth - 20 - minWidth) <= 0
// visual indicator to avoid trying too hard; try not to apply each tick
if (!previousLimit && (reachingMax && deltaX > 0 || reachingMin && deltaX < 0)) {
component.list.classList.add('cannot-resize');
previousLimit = true;
} else if (previousLimit && !reachingMax && !reachingMin) {
component.list.classList.remove('cannot-resize');
previousLimit = false;
}
// Apply the new width immediately for real-time feedback
component.list.style.setProperty('--list-width', `${component.currentWidth}px`);
};
const stopResize = (e) => {
e = e.originalEvent;
if (!isResizing) return;
e.preventDefault();
e.stopPropagation();
if (!isResizing || !e.isPrimary) {
return;
}
// hopefully be gentler on cpu
$(document).off('pointermove', doResize);
$(document).off('pointercancel', stopResize);
$(document).off('pointerup', stopResize);
isResizing = false;
if (previousLimit) {
component.list.classList.remove('cannot-resize');
}
// Calculate final width
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
const deltaX = currentX - startX;
const finalWidth = Math.max(minWidth, startWidth + deltaX);
const finalWidth = parseInt(component.list.style.getPropertyValue('--list-width'), 10);
// Ensure the final width is applied
$list[0].style.setProperty('--list-width', `${finalWidth}px`);
$list[0].style.setProperty('width', `${finalWidth}px`);
$list[0].style.setProperty('min-width', `${finalWidth}px`);
$list[0].style.setProperty('max-width', `${finalWidth}px`);
$list[0].style.setProperty('flex', 'none');
$list[0].style.setProperty('flex-basis', 'auto');
$list[0].style.setProperty('flex-grow', '0');
$list[0].style.setProperty('flex-shrink', '0');
// Remove visual feedback but keep the height
component.list.classList.remove('list-resizing');
document.body.classList.remove('list-resizing-active');
// Remove visual feedback but keep the width
$list.removeClass('list-resizing');
$('body').removeClass('list-resizing-active');
$('body').css('user-select', '');
if (component.collapse.get()) {
return;
}
// Keep the CSS custom property for persistent width
// The CSS custom property will remain on the element to maintain the width
// Save the new width using the existing system
const list = component.data();
const boardId = list.boardId;
const listId = list._id;
@ -250,7 +375,7 @@ BlazeComponent.extendComponent({
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
// For logged-in users, use server method
Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, maxWidth, (error, result) => {
Meteor.call('applyListWidthToStorage', boardId, listId, finalWidth, listConstraint, (error, result) => {
if (error) {
console.error('Error saving list width:', error);
} else {
@ -293,8 +418,32 @@ BlazeComponent.extendComponent({
e.preventDefault();
};
// handle both pointer and touch
$(this.resizeHandle).on("pointerdown", startResize);
// Mouse events
$resizeHandle.on('mousedown', startResize);
$(document).on('mousemove', doResize);
$(document).on('mouseup', stopResize);
// Touch events for mobile
$resizeHandle.on('touchstart', startResize, { passive: false });
$(document).on('touchmove', doResize, { passive: false });
$(document).on('touchend', stopResize, { passive: false });
// Prevent dragscroll interference
$resizeHandle.on('mousedown', (e) => {
e.stopPropagation();
});
// Reactively update resize handle visibility when auto-width or collapse changes
component.autorun(() => {
const collapsed = Utils.getListCollapseState(list);
if (component.autoWidth() || collapsed) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
}
});
// Clean up on component destruction
component.onDestroyed(() => {
@ -306,6 +455,12 @@ BlazeComponent.extendComponent({
},
}).register('list');
Template.list.helpers({
collapsed() {
return Utils.getListCollapseState(this);
},
});
Template.miniList.events({
'click .js-select-list'() {
const listId = this._id;
@ -313,10 +468,15 @@ Template.miniList.events({
},
});
Template.miniList.helpers({
isCurrentList() {
const currentList = Utils.getCurrentList();
const list = Template.currentData();
return currentList && currentList._id == list._id;
},
});
// Enable drag-reorder for collapsed lists from .js-collapsed-list-drag area
this.$('.js-collapsed-list-drag').draggable({
axis: 'x',
helper: 'clone',
revert: 'invalid',
start(evt, ui) {
boardComponent.setIsDragging(true);
},
stop(evt, ui) {
boardComponent.setIsDragging(false);
}
});

View file

@ -4,18 +4,17 @@ template(name="listBody")
.minicards.clearfix.js-minicards(class="{{#if reachedWipLimit}}js-list-full{{/if}}")
+inlinedForm(autoclose=false position="top")
+addCardForm(listId=_id position="top")
if customFieldSum.lenght
ul.sidebar-list
each customFieldsSum
li
ul.sidebar-list
each customFieldsSum
li
+viewer
= name
if $eq customFieldsSum.type "number"
+viewer
= name
if $eq customFieldsSum.type "number"
+viewer
= value
if $eq customFieldsSum.type "currency"
+viewer
= formattedCurrencyCustomFieldValue(value)
= value
if $eq customFieldsSum.type "currency"
+viewer
= formattedCurrencyCustomFieldValue(value)
each (cardsWithLimit (idOrNull ../../_id))
a.minicard-wrapper.js-minicard(href=originRelativeUrl
class="{{#if cardIsSelected}}is-selected{{/if}}"
@ -26,15 +25,15 @@ template(name="listBody")
+minicard(this)
if (showSpinner (idOrNull ../../_id))
+spinnerList
if canSeeAddCard
a.minicard-wrapper.minicard-add-form
+inlinedForm(autoclose=false position="bottom")
+addCardForm(listId=_id position="bottom")
else
.add-card-wrapper
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
i.fa.fa-plus
+inlinedForm(autoclose=false position="bottom")
+addCardForm(listId=_id position="bottom")
else
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
i.fa.fa-plus
| {{_ 'add-card'}}
+inlinedForm(autoclose=false position="bottom")
+addCardForm(listId=_id position="bottom")
template(name="spinnerList")
.sk-spinner.sk-spinner-list(
@ -44,30 +43,33 @@ template(name="spinnerList")
template(name="addCardForm")
.minicard.minicard-composer.js-composer
if getLabels
.minicard-labels
each getLabels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
textarea.minicard-composer-textarea.js-card-title(autofocus dir="auto")
.minicard-bottom
.minicard-composer-icons
if getLabels
each getLabels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
if members.get
each members.get
+userAvatar(userId=this)
.add-controls.clearfix
a.js-close-inlined-form
i.fa.fa-times-thin
if members.get
.minicard-members.js-minicard-composer-members
each members.get
+userAvatar(userId=this)
button.primary.confirm(type="submit") {{_ 'add'}}
.links-controls.clearfix
.add-controls.clearfix
button.primary.confirm(type="submit") {{_ 'add'}}
a.js-close-inlined-form
i.fa.fa-times-thin
.add-controls.clearfix
unless currentBoard.isTemplatesBoard
unless currentBoard.isTemplateBoard
span.quiet
| {{_ 'or'}}
a.js-link {{_ 'link'}}
span.quiet
| &nbsp;
| /
a.js-search {{_ 'search'}}
span.quiet
| &nbsp;
| /
a.js-card-template {{_ 'template'}}
template(name="autocompleteLabelLine")
@ -75,73 +77,70 @@ template(name="autocompleteLabelLine")
span(class="{{#if hasNoName}}quiet{{/if}}")= labelName
template(name="linkCardPopup")
label {{_ 'boards'}}:
.link-board-wrapper
.link-board-dropdown
label {{_ 'boards'}}:
select.js-select-boards
option(value="")
each boards
option(value="{{_id}}") {{isTitleDefault title}}
input.primary.confirm.js-link-board(type="button" value="{{_ 'link'}}")
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
option(value="") {{_ 'custom-field-dropdown-none'}}
each swimlanes
option(value="{{_id}}") {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
option(value="") {{_ 'custom-field-dropdown-none'}}
each lists
option(value="{{_id}}") {{isTitleDefault title}}
label {{_ 'cards'}}:
select.js-select-cards
option(value="") {{_ 'custom-field-dropdown-none'}}
each cards
option(value="{{getRealId}}") {{getTitle}}
.edit-controls.clearfix
input.primary.confirm.js-done(type="button" value="{{_ 'link'}}")
template(name="searchElementPopup")
form
label
| {{_ 'title'}}
input.js-element-title(type="text" placeholder="{{_ 'title'}}" autofocus required dir="auto")
unless isTemplateSearch
label {{_ 'boards'}}:
.link-board-wrapper
select.js-select-boards
option(value="")
each boards
option(value="{{_id}}") {{isTitleDefault title}}
input.primary.confirm.js-link-board(type="button" value="{{_ 'link'}}")
.link-board-dropdown
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
option(value="") {{_ 'custom-field-dropdown-none'}}
each swimlanes
option(value="{{_id}}") {{isTitleDefault title}}
.link-board-dropdown
label {{_ 'lists'}}:
select.js-select-lists
option(value="") {{_ 'custom-field-dropdown-none'}}
each lists
option(value="{{_id}}") {{isTitleDefault title}}
.link-board-dropdown
label {{_ 'cards'}}:
select.js-select-cards
option(value="") {{_ 'custom-field-dropdown-none'}}
each cards
option(value="{{getRealId}}") {{getTitle}}
.edit-controls.clearfix
input.primary.confirm.js-done(type="button" value="{{_ 'link'}}")
template(name="searchElementPopup")
.link-board-wrapper
form
label
| {{_ 'title'}}
input.js-element-title(type="text" placeholder="{{_ 'title'}}" autofocus required dir="auto")
unless isTemplateSearch
label {{_ 'boards'}}:
select.js-select-boards
option(value="")
each (boards)
option(value="{{_id}}") {{title}}
form.js-search-term-form
label
| {{_ 'template'}}
input(type="text" name="searchTerm" placeholder="{{_ 'search-example'}}" autofocus dir="auto")
.list-body.search-card-results
.minicards.clearfix.js-minicards
if isBoardTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+miniboard(this)
if isListTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+minilist(this)
if isSwimlaneTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+miniswimlane(this)
if isCardTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+minicard(this)
unless isTemplateSearch
each (results)
a.minicard-wrapper.js-minicard
+minicard(this)
option(value="{{_id}}") {{title}}
form.js-search-term-form
label
| {{_ 'template'}}
input(type="text" name="searchTerm" placeholder="{{_ 'search-example'}}" autofocus dir="auto")
.list-body.search-card-results
.minicards.clearfix.js-minicards
if isBoardTemplateSearch
each results
a.minicard-wrapper.js-minicard
+miniboard(this)
if isListTemplateSearch
each results
a.minicard-wrapper.js-minicard
+minilist(this)
if isSwimlaneTemplateSearch
each results
a.minicard-wrapper.js-minicard
+miniswimlane(this)
if isCardTemplateSearch
each results
a.minicard-wrapper.js-minicard
+minicard(this)
unless isTemplateSearch
each results
a.minicard-wrapper.js-minicard
+minicard(this)

View file

@ -3,168 +3,16 @@ import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { Spinner } from '/client/lib/spinner';
import getSlug from 'limax';
import { itemsSelector } from './list';
const subManager = new SubsManager();
const InfiniteScrollIter = 10;
function sortableCards(boardComponent, $cards) {
return {
connectWith: '.js-minicards:not(.js-list-full)',
tolerance: 'pointer',
appendTo: '.board-canvas',
helper(evt, item) {
const helper = item.clone();
const cardHeight = item.height();
const cardWidth = item.width();
helper[0].setAttribute('style', `height: ${cardHeight}px !important; width: ${cardWidth}px !important;`);
if (MultiSelection.isActive()) {
const andNOthers = $cards.find('.js-minicard.is-checked').length - 1;
if (andNOthers > 0) {
helper.append(
$(
Blaze.toHTML(
HTML.DIV(
{ class: 'and-n-other' },
TAPi18n.__('and-n-other-card', { count: andNOthers }),
),
),
),
);
}
}
return helper;
},
distance: 7,
items: itemsSelector,
placeholder: 'minicard-wrapper placeholder',
/* cursor must be tied to smaller objects, position approximately from the button
(can be computed if visually confusing) */
cursorAt: { right: 20, top: 30 },
start(evt, ui) {
const cardHeight = ui.helper.height();
ui.placeholder[0].setAttribute('style', `height: ${cardHeight}px !important;`);
EscapeActions.executeUpTo('popup-close');
boardComponent.setIsDragging(true);
},
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 prevCardDom = ui.item.prev('.js-minicard').get(0);
const nextCardDom = ui.item.next('.js-minicard').get(0);
const nCards = MultiSelection.isActive() ? MultiSelection.count() : 1;
const sortIndex = Utils.calculateIndex(prevCardDom, nextCardDom, nCards);
const listId = Blaze.getData(ui.item.parents('.list-body').get(0))._id;
const currentBoard = Utils.getCurrentBoard();
const defaultSwimlaneId = currentBoard.getDefaultSwimline()._id;
let targetSwimlaneId = null;
// only set a new swimelane ID if the swimlanes view is active
if (
Utils.boardView() === 'board-view-swimlanes' ||
currentBoard.isTemplatesBoard()
)
targetSwimlaneId = Blaze.getData(ui.item.parents('.swimlane').get(0))
._id;
// 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.
$cards.sortable('cancel');
if (MultiSelection.isActive()) {
ReactiveCache.getCards(MultiSelection.getMongoSelector(), { sort: ['sort'] }).forEach((card, i) => {
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(
currentBoard._id,
newSwimlaneId,
listId,
sortIndex.base + i * sortIndex.increment,
);
});
} else {
const cardDomElement = ui.item.get(0);
const card = Blaze.getData(cardDomElement);
const newSwimlaneId = targetSwimlaneId
? targetSwimlaneId
: card.swimlaneId || defaultSwimlaneId;
card.move(currentBoard._id, newSwimlaneId, listId, sortIndex.base);
}
boardComponent.setIsDragging(false);
},
sort(event, ui) {
Utils.scrollIfNeeded(event);
},
};
};
BlazeComponent.extendComponent({
onCreated() {
// for infinite scrolling
this.cardlimit = new ReactiveVar(InfiniteScrollIter);
},
onRendered() {
// Prefer handling drag/sort in listBody rather than list as
// it is shared between mobile and desktop view
const boardComponent = BlazeComponent.getComponentForElement(document.getElementsByClassName('board-canvas')[0]);
const $cards = this.$('.js-minicards');
$cards.sortable(sortableCards(boardComponent, $cards));
this.autorun(() => {
if ($cards.data('uiSortable') || $cards.data('sortable')) {
// Use handle button on mobile, classic move otherwise
if (Utils.isMiniScreen()) {
$cards.sortable('option', 'handle', '.handle');
} else {
$cards.sortable('option', 'handle', '.minicard');
}
$cards.sortable(
'option',
'disabled',
// Disable drag-dropping when user is not member
!Utils.canModifyBoard(),
// Not disable drag-dropping while in multi-selection mode
// MultiSelection.isActive() || !Utils.canModifyBoard(),
);
}
});
// We want to re-run this function any time a card is added.
this.autorun(() => {
const currentBoardId = Tracker.nonreactive(() => {
return Session.get('currentBoard');
});
Tracker.afterFlush(() => {
$cards.find(itemsSelector).droppable({
hoverClass: 'draggable-hover-card',
accept: '.js-member,.js-label',
drop(event, ui) {
const cardId = Blaze.getData(this)._id;
const card = ReactiveCache.getCard(cardId);
if (ui.draggable.hasClass('js-member')) {
const memberId = Blaze.getData(ui.draggable.get(0)).userId;
card.assignMember(memberId);
} else {
const labelId = Blaze.getData(ui.draggable.get(0))._id;
card.addLabel(labelId);
}
},
});
});
});
},
mixins() {
return [];
},
@ -234,10 +82,9 @@ BlazeComponent.extendComponent({
evt.preventDefault();
const firstCardDom = this.find('.js-minicard:first');
const lastCardDom = this.find('.js-minicard:last');
// more robust to start from the form
const textarea = $(evt.currentTarget).closest('.inlined-form').find('textarea');
const textarea = $(evt.currentTarget).find('textarea');
const position = this.currentData().position;
const title = $(textarea).val().trim();
const title = textarea.val().trim();
let sortIndex;
if (position === 'top') {
@ -321,6 +168,7 @@ BlazeComponent.extendComponent({
// We keep the form opened, empty it, and scroll to it.
textarea.val('').focus();
autosize.update(textarea);
if (position === 'bottom') {
this.scrollToBottom();
}
@ -346,19 +194,21 @@ BlazeComponent.extendComponent({
clickOnMiniCard(evt) {
if (MultiSelection.isActive() || evt.shiftKey) {
evt.stopImmediatePropagation();
evt.preventDefault();
const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
MultiSelection[methodName](this.currentData()._id);
// If the card is already selected, we want to de-select it.
// XXX We should probably modify the minicard href attribute instead of
// overwriting the event in case the card is already selected.
} else if (Utils.isMiniScreen()) {
evt.preventDefault();
Session.set('popupCardId', this.currentData()._id);
this.cardDetailsPopup(evt);
} else if (Session.equals('currentCard', this.currentData()._id)) {
// We need to wait a little because router gets called first,
// we probably need a level of indirection
// #FIXME remove if it works with commits we rebased on,
// which change the route declaration order
Meteor.setTimeout(() => {
Session.set('currentCard', null)
}, 50);
evt.stopImmediatePropagation();
evt.preventDefault();
Utils.goBoardId(Session.get('currentBoard'));
} else {
// Allow normal href navigation, but if it's the same card URL,
@ -433,6 +283,12 @@ BlazeComponent.extendComponent({
return user && user.isVerticalScrollbars();
},
cardDetailsPopup(event) {
if (!Popup.isOpen()) {
Popup.open("cardDetails")(event);
}
},
events() {
return [
{
@ -440,8 +296,6 @@ BlazeComponent.extendComponent({
'click .js-toggle-multi-selection': this.toggleMultiSelection,
'click .open-minicard-composer': this.scrollToBottom,
submit: this.addCard,
// #FIXME remove in final MR if it works
'click .confirm': this.addCard
},
];
},
@ -547,17 +401,6 @@ BlazeComponent.extendComponent({
'click .js-link': Popup.open('linkCard'),
'click .js-search': Popup.open('searchElement'),
'click .js-card-template': Popup.open('searchElement'),
submit: this.addCard,
'click .minicard-label': (event) => {
const clickedData = BlazeComponent.getComponentForElement(event.target).currentData?.()
this.labels.set(this.labels.get().filter(e => e !== clickedData?._id));
},
'click .member': (event) => {
const clickedData = BlazeComponent.getComponentForElement(event.target).currentData?.()
this.members.set(this.members.get().filter(e => e !== clickedData?.userId));
e.preventDefault();
e.stopPropagation();
},
},
];
},
@ -566,6 +409,8 @@ BlazeComponent.extendComponent({
const editor = this;
const $textarea = this.$('textarea');
autosize($textarea);
$textarea.escapeableTextComplete(
[
// User mentions
@ -576,9 +421,7 @@ BlazeComponent.extendComponent({
callback(
$.map(currentBoard.activeMembers(), member => {
const user = ReactiveCache.getUser(member.userId);
return user.username.indexOf(term) === 0 &&
// don't show already selected members
!editor.members.get().find((e) => e === member.userId) ? user : null;
return user.username.indexOf(term) === 0 ? user : null;
}),
);
},
@ -602,12 +445,8 @@ BlazeComponent.extendComponent({
const currentBoard = Utils.getCurrentBoard();
callback(
$.map(currentBoard.labels, label => {
if (
label.name == undefined ||
// don't show already selected labels
editor.getLabels().find((e) => e._id === label._id)
) {
return null;
if (label.name == undefined) {
label.name = "";
}
if (
label.name.indexOf(term) > -1 ||
@ -664,10 +503,10 @@ BlazeComponent.extendComponent({
subManager.subscribe('board', this.boardId, false);
this.board = ReactiveCache.getBoard(this.boardId);
// List where to insert card
this.list = $(PopupComponent.stack[0].openerElement).closest('.js-list');
this.list = $(Popup._getTopStack().openerElement).closest('.js-list');
this.listId = Blaze.getData(this.list[0])._id;
// Swimlane where to insert card
const swimlane = $(PopupComponent.stack[0].openerElement).closest(
const swimlane = $(Popup._getTopStack().openerElement).closest(
'.js-swimlane',
);
this.swimlaneId = '';
@ -720,8 +559,7 @@ BlazeComponent.extendComponent({
}
const lists = ReactiveCache.getLists(
{
boardId: this.selectedBoardId.get(),
swimlaneId: this.selectedSwimlaneId?.get?.()
boardId: this.selectedBoardId.get()
},
{
sort: { sort: 1 },
@ -865,16 +703,16 @@ BlazeComponent.extendComponent({
},
onCreated() {
this.isCardTemplateSearch = $(PopupComponent.stack[0].openerElement).hasClass(
this.isCardTemplateSearch = $(Popup._getTopStack().openerElement).hasClass(
'js-card-template',
);
this.isListTemplateSearch = $(PopupComponent.stack[0].openerElement).hasClass(
this.isListTemplateSearch = $(Popup._getTopStack().openerElement).hasClass(
'js-list-template',
);
this.isSwimlaneTemplateSearch = $(
PopupComponent.stack[0].openerElement,
Popup._getTopStack().openerElement,
).hasClass('js-open-add-swimlane-menu');
this.isBoardTemplateSearch = $(PopupComponent.stack[0].openerElement).hasClass(
this.isBoardTemplateSearch = $(Popup._getTopStack().openerElement).hasClass(
'js-add-board',
);
this.isTemplateSearch =
@ -893,16 +731,20 @@ BlazeComponent.extendComponent({
} else {
this.board = Utils.getCurrentBoard();
}
this.boardId = this.board?._id;
if (!this.board) {
Popup.back();
return;
}
this.boardId = this.board._id;
// Subscribe to this board
subManager.subscribe('board', this.boardId, false);
this.selectedBoardId = new ReactiveVar(this.boardId);
this.list = $(Popup._getTopStack().openerElement).closest('.js-list');
if (!this.isBoardTemplateSearch) {
this.list = $(PopupComponent.stack[0].openerElement).closest('.js-list');
this.swimlaneId = '';
// Swimlane where to insert card
const swimlane = $(PopupComponent.stack[0].openerElement).parents(
const swimlane = $(Popup._getTopStack().openerElement).parents(
'.js-swimlane',
);
if (Utils.boardView() === 'board-view-swimlanes')
@ -941,7 +783,11 @@ BlazeComponent.extendComponent({
} else if (this.isSwimlaneTemplateSearch) {
return board.searchSwimlanes(this.term.get());
} else if (this.isBoardTemplateSearch) {
return board.searchBoards(this.term.get());
const boards = board.searchBoards(this.term.get());
boards.forEach(board => {
subManager.subscribe('board', board.linkedId, false);
});
return boards;
} else {
return [];
}

View file

@ -9,68 +9,66 @@ template(name="listHeader")
if currentList
a.list-header-left-icon.js-unselect-list
i.fa.fa-caret-left
else
//- start by this on mobile to have cohesion with other views
a.list-header-menu-icon.js-select-list
else
if collapsed
if showCardsCountForList cards.length
br
span.cardCount {{cardsCount}}
if isMiniScreen
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
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
else
a.list-collapse-indicator.js-collapse(title="{{_ 'collapse'}}")
if collapsed
i.fa.fa-caret-right
.list-header-name-container
else
i.fa.fa-caret-down
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}}")
class="{{#unless collapsed}}{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}{{#unless currentUser.isWorker}}js-open-inlined-form is-editable{{/unless}}{{/unless}}{{/if}}{{/unless}}")
+viewer
= title
if wipLimit.enabled
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
else
div.list-header-name-container
unless isMiniScreen
a.list-collapse-indicator.js-collapse(title="{{_ 'collapse'}}")
if collapsed
i.fa.fa-caret-right
else
i.fa.fa-caret-down
div(class="{{#if collapsed}}list-rotated{{/if}}").list-header-wrap
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
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
|&nbsp;(
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cards.length}}
|/#{wipLimit.value})
unless collapsed
if showCardsCountForList cards.length
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cards.length}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
div.list-header-menu
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
if isMiniScreen
if currentList
if isWatching
i.list-header-watch-icon.i.fa.fa-eye
i.list-header-watch-icon i.fa.fa-eye
div.list-header-menu
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
if canSeeAddCard
a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}")
i.fa.fa-plus
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
else
a.list-header-menu-icon.js-select-list
i.fa.fa-caret-right
unless currentUser.isWorker
if isMiniScreen
if isTouchScreenOrShowDesktopDragHandles
a.list-header-handle.handle.js-list-handle
i.fa.fa-arrows
else if currentUser.isBoardMember
@ -79,13 +77,25 @@ template(name="listHeader")
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
if isMiniScreen
if isTouchScreenOrShowDesktopDragHandles
a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}")
i.fa.fa-arrows
unless isMiniScreen
if collapsed
if showCardsCountForList cards.length
span.cardCount {{cardsCount}}
unless collapsed
div.list-header-menu
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
//if isBoardAdmin
//
a.fa.js-list-star.list-header-plus-top(class="fa-star{{#unless starred}}-o{{/unless}}")
if isTouchScreenOrShowDesktopDragHandles
a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}")
i.fa.fa-arrows
if canSeeAddCard
a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}")
i.fa.fa-plus
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
template(name="editListTitleForm")
.list-composer
@ -185,8 +195,10 @@ template(name="listMorePopup")
| {{_ 'added'}}
span.date(title=list.createdAt) {{ moment createdAt 'LLL' }}
//unless currentUser.isWorker
// if currentUser.isBoardAdmin
// a.js-delete {{_ 'delete'}}
//
if currentUser.isBoardAdmin
//
a.js-delete {{_ 'delete'}}
template(name="listDeletePopup")
p {{_ "list-delete-pop"}}
@ -215,14 +227,14 @@ template(name="wipLimitErrorPopup")
.wip-limit-invalid
p {{_ 'wipLimitErrorPopup-dialog-pt1'}}
p {{_ 'wipLimitErrorPopup-dialog-pt2'}}
button.negate.js-back-view(type="submit") {{_ 'cancel'}}
button.full.js-back-view(type="submit") {{_ 'cancel'}}
template(name="setListWidthPopup")
#js-list-width-edit
label {{_ 'set-list-width-value'}}
p
input.list-width-value(type="number" value="{{ listWidthValue }}" min="100")
input.list-constraint-value(type="number" value="{{ listConstraintValue }}" min="100")
input.list-width-value(type="number" value="{{ listWidthValue }}" min="270")
input.list-constraint-value(type="number" value="{{ listConstraintValue }}" min="270")
input.list-width-apply(type="submit" value="{{_ 'apply'}}")
input.list-width-error
br
@ -233,8 +245,8 @@ template(name="setListWidthPopup")
template(name="listWidthErrorPopup")
.list-width-invalid
p {{_ 'list-width-error-message'}} '&gt;=100'
button.negate.js-back-view(type="submit") {{_ 'cancel'}}
p {{_ 'list-width-error-message'}} '&gt;=270'
button.full.js-back-view(type="submit") {{_ 'cancel'}}
template(name="setListColorPopup")
form.edit-label

View file

@ -9,15 +9,6 @@ Meteor.startup(() => {
});
BlazeComponent.extendComponent({
onRendered() {
/* #FIXME I have no idea why this exact same
event won't fire when in event maps */
$(this.find('.js-collapse')).on('click', (e) => {
e.preventDefault();
this.collapsed(!this.collapsed());
});
},
canSeeAddCard() {
const list = Template.currentData();
return (
@ -43,7 +34,7 @@ BlazeComponent.extendComponent({
}
},
collapsed(check = undefined) {
const list = this.data();
const list = Template.currentData();
const status = Utils.getListCollapseState(list);
if (check === undefined) {
// just check
@ -119,11 +110,7 @@ BlazeComponent.extendComponent({
return TAPi18n.__('cards-count');
}
},
currentList() {
const currentList = Utils.getCurrentList();
const list = Template.currentData();
return currentList && currentList._id == list._id;
},
events() {
return [
{
@ -131,6 +118,10 @@ BlazeComponent.extendComponent({
event.preventDefault();
this.starred(!this.starred());
},
'click .js-collapse'(event) {
event.preventDefault();
this.collapsed(!this.collapsed());
},
'click .js-open-list-menu': Popup.open('listAction'),
'click .js-add-card.list-header-plus-top'(event) {
const listDom = $(event.target).parents(
@ -515,7 +506,7 @@ BlazeComponent.extendComponent({
let sortIndex = 0;
const boardId = Utils.getCurrentBoardId();
const swimlaneId = this.currentSwimlane?._id;
let swimlaneId = this.currentSwimlane?._id;
const positionInput = this.find('.list-position-input');
@ -525,6 +516,9 @@ BlazeComponent.extendComponent({
if (selectedList) {
sortIndex = selectedList.sort + 1;
// Use the swimlane ID from the selected list to ensure the new list
// is added to the same swimlane as the selected list
swimlaneId = selectedList.swimlaneId;
} else {
// No specific position, add at end of swimlane
if (swimlaneId) {
@ -563,3 +557,4 @@ BlazeComponent.extendComponent({
];
},
}).register('addListPopup');

View file

@ -1,6 +1,6 @@
.my-cards-board-wrapper {
border-radius: 0 0 0.5vw 0.5vw;
min-width: min(100%, 400px, 52vw);
min-width: min(400px, 52vw);
margin-bottom: 2.5vh;
margin-right: auto;
margin-left: auto;
@ -33,6 +33,13 @@
text-align: center;
margin-bottom: 0.9vh;
}
.my-cards-list-wrapper {
margin: 1.3vh 1.3vw;
border-radius: 0.7vw;
display: inline-grid;
min-width: min(250px, 32vw);
max-width: min(350px, 45vw);
}
.my-cards-card-wrapper {
margin-top: 0;
margin-bottom: 1.3vh;
@ -74,7 +81,7 @@
}
.accessibility-page h2 {
font-size: 24px;
margin-bottom: 20px;
color: #4d4d4d;
}

View file

@ -92,14 +92,14 @@ BlazeComponent.extendComponent({
class DueCardsComponent extends BlazeComponent {
onCreated() {
super.onCreated();
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';
@ -107,7 +107,7 @@ class DueCardsComponent extends BlazeComponent {
this.subscriptionHandle.stop();
}
this.subscriptionHandle = Meteor.subscribe('dueCards', allUsers);
// Update loading state based on subscription
this.autorun(() => {
if (this.subscriptionHandle && this.subscriptionHandle.ready()) {
@ -162,7 +162,7 @@ class DueCardsComponent extends BlazeComponent {
// 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);
}
@ -196,10 +196,10 @@ class DueCardsComponent extends BlazeComponent {
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,
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,
@ -223,11 +223,11 @@ class DueCardsComponent extends BlazeComponent {
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;
});
}

View file

@ -1,18 +1,19 @@
.new-comment, .inlined-form {
a.fa.fa-brands.fa-markdown, a.fa.fa-copy {
display: flex;
justify-content: end;
}
.new-comment a.fa.fa-brands.fa-markdown,
.inlined-form a.fa.fa-brands.fa-markdown {
float: right;
position: absolute;
top: -10px;
right: 60px;
}
.editor-controls {
display: flex;
justify-content: end;
grid-area: editor-controls;
align-items: center;
align-self: start;
gap: 1ch;
.new-comment a.fa.fa-copy,
.inlined-form a.fa.fa-copy {
float: right;
position: relative;
top: -10px;
right: 5px;
}
.js-inlined-form.viewer.btn-sm {
position: absolute;
top: 20px;
right: 6px;
}
.editor {
grid-area: editor;
}

View file

@ -1,12 +1,12 @@
template(name="editor")
.editor-controls
a.fa.fa-brands.fa-markdown(title="{{_ 'convert-to-markdown'}}")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip.copied-tooltip-hidden {{_ 'copied'}}
a.fa.fa-brands.fa-markdown(title="{{_ 'convert-to-markdown'}}")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip {{_ 'copied'}}
textarea.editor(
dir="auto"
class="{{class}}"
id=id
autofocus=autofocus
placeholder="{{_ 'comment-placeholder'}}")
+Template.contentBlock

View file

@ -90,6 +90,7 @@ BlazeComponent.extendComponent({
const enableTextarea = function() {
const $textarea = this.$(textareaSelector);
autosize($textarea);
$textarea.escapeableTextComplete(mentions);
};
if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === true || Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') {
@ -411,14 +412,14 @@ Blaze.Template.registerHelper(
if (knowedUser.userId === Meteor.userId()) {
linkClass += ' me';
}
// For special group mentions, display translated text
let displayText = knowedUser.username;
if (specialHandleNames.includes(knowedUser.username)) {
displayText = TAPi18n.__(knowedUser.username);
linkClass = 'atMention'; // Remove js-open-member for special handles
}
// This @user mention link generation did open same Wekan
// window in new tab, so now A is changed to U so it's
// underlined and there is no link popup. This way also

View file

@ -1,6 +1,6 @@
.global-search-board-wrapper {
border-radius: 0.8ch;
min-width: min(100%, 400px);
border-radius: 8px;
min-width: 400px;
border-width: 8px;
border-color: #808080;
border-style: solid;
@ -67,6 +67,8 @@
color: #8b0000;
}
.global-search-page {
width: 40%;
min-width: 400px;
margin-right: auto;
margin-left: auto;
line-height: 150%;
@ -89,13 +91,6 @@
font-family: Courier;
font-style: italic;
}
.lists-wrapper {
display: flex;
flex-wrap: wrap;
gap: 1ch 0.3lh;
}
code {
color: #000;
background-color: #d3d3d3;

File diff suppressed because it is too large Load diff

View file

@ -5,81 +5,106 @@ template(name="header")
Reddit "subreddit" bar.
The first link goes to the boards page.
if currentUser
#header-quick-access(class="currentBoard.colorClass {{#if isMiniScreen}}mobile-view{{/if}}")
#header-quick-access(class=currentBoard.colorClass)
// Home icon - always at left side of logo
#header-quick-access-left
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
span.emoji-icon
i.fa.fa-home
span
| {{_ 'all-boards'}}
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
i.fa.fa-home
| {{_ 'all-boards'}}
if isMiniScreen
ul.header-quick-access-list
if currentList
each currentBoard.lists
li(class="{{#if $.Session.equals 'currentList' _id}}current{{/if}}")
a.js-select-list.
+viewer
= title
else
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
else
ul.header-quick-access-list
//li
// a(href="{{pathFor 'public'}}")
// span.fa.fa-globe
// | {{_ 'public'}}
// Logo - visible; on mobile constrained by CSS
unless currentSetting.hideLogo
if currentSetting.customTopLeftCornerLogoImageUrl
if currentSetting.customTopLeftCornerLogoLinkUrl
a(href="{{currentSetting.customTopLeftCornerLogoLinkUrl}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" height="{{#if currentSetting.customTopLeftCornerLogoHeight}}#{currentSetting.customTopLeftCornerLogoHeight}{{else}}27{{/if}}" width="auto" margin="0" padding="0")
unless currentSetting.customTopLeftCornerLogoLinkUrl
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" height="{{#if currentSetting.customTopLeftCornerLogoHeight}}#{currentSetting.customTopLeftCornerLogoHeight}{{else}}27{{/if}}" width="auto" margin="0" padding="0" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
unless currentSetting.customTopLeftCornerLogoImageUrl
div#headerIsSettingDatabaseCallDone
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
// Zoom controls - always visible
.zoom-controls
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
span.zoom-display {{zoomLevel}}%
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
// Drag handles toggle - between zoom and mobile mode toggle
a.board-header-btn.js-toggle-desktop-drag-handles(title="{{_ 'show-desktop-drag-handles'}}")
i.fa.fa-arrows
if isShowDesktopDragHandles
i.fa.fa-check
unless isShowDesktopDragHandles
i.fa.fa-ban
if isMiniScreen
ul.header-quick-access-list
if currentList
each currentBoard.lists
li(class="{{#if $.Session.equals 'currentList' _id}}current{{/if}}")
a.js-select-list
+viewer
= title
else
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
else
li.current.empty(title="{{_ 'quick-access-description'}}")
| {{_ 'quick-access-description'}}
#header-new-board-icon
// Next line is used only for spacing at header,
// there is no visible clickable icon.
#header-new-board-icon
// Hide duplicate create board button,
// because it did not show board templates correctly.
//a#header-new-board-icon.js-create-board
// i.fa.fa-plus(title="Create a new board")
// Logo - visible; on mobile constrained by CSS
unless currentSetting.hideLogo
.logo-container
if currentSetting.customTopLeftCornerLogoImageUrl
if currentSetting.customTopLeftCornerLogoLinkUrl
a.logo(href="{{currentSetting.customTopLeftCornerLogoLinkUrl}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
+logo
else
+logo
else
ul.header-quick-access-list
//li
//
a(href="{{pathFor 'public'}}")
//
span.fa.fa-globe
//
| {{_ 'public'}}
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
else
div#headerIsSettingDatabaseCallDone.logo
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
li.current.empty(title="{{_ 'quick-access-description'}}")
| {{_ 'quick-access-description'}}
#header-new-board-icon
// Next line is used only for spacing at header,
// there is no visible clickable icon.
#header-new-board-icon
//
Hide duplicate create board button,
//
because it did not show board templates correctly.
//a#header-new-board-icon.js-create-board
//
i.fa.fa-plus(title="Create a new board")
#header-quick-access-right
if currentSetting.customHelpLinkUrl
#header-help
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
i.fa.fa-question-circle
#header-quick-access-icons
+headerUserBar
// Notifications
+notifications
.mobile-mode-toggle
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
i.fa.fa-mobile
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
i.fa.fa-desktop
// Notifications
+notifications
if currentSetting.customHelpLinkUrl
#header-help
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
i.fa.fa-question-circle
+headerUserBar
#header(class=currentBoard.colorClass)
//-
The main bar is a colorful bar that provide all the meta-data for the
current page. This bar is contextual based.
If the user is not connected we display "sign in" and "log in" buttons.
#header-main-bar(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if wrappedHeader}}wrapper{{/if}}")
#header-main-bar(class="{{#if wrappedHeader}}wrapper{{/if}}")
+Template.dynamic(template=headerBar)
if appIsOffline
@ -103,7 +128,3 @@ template(name="offlineWarning")
| {{_ 'app-is-offline'}}
a.app-try-reconnect {{_ 'app-try-reconnect'}}
//- a little helper to avoid duplication
template(name="logo")
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" style="{{#if currentSetting.customTopLeftCornerLogoHeight}}min-height: #{currentSetting.customTopLeftCornerLogoHeight};{{/if}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")

View file

@ -22,13 +22,13 @@ Template.header.onCreated(function () {
)
document.getElementById(
'headerIsSettingDatabaseCallDone',
).style.visibility = 'hidden';
).style.display = 'none';
else if (
document.getElementById('headerIsSettingDatabaseCallDone') != null
)
document.getElementById(
'headerIsSettingDatabaseCallDone',
).style.visibility = 'visible';
).style.display = 'block';
return this.stop();
},
});
@ -57,6 +57,14 @@ Template.header.helpers({
return announcements && announcements.body;
},
zoomLevel() {
const sessionZoom = Session.get('wekan-zoom-level');
if (sessionZoom !== undefined) {
return Math.round(sessionZoom * 100);
}
return Math.round(Utils.getZoomLevel() * 100);
},
mobileMode() {
const sessionMode = Session.get('wekan-mobile-mode');
if (sessionMode !== undefined) {
@ -68,6 +76,51 @@ Template.header.helpers({
Template.header.events({
'click .js-create-board': Popup.open('headerBarCreateBoard'),
'click .js-zoom-level-click'(evt) {
const $zoomDisplay = $(evt.currentTarget).find('.zoom-display');
const $zoomInput = $(evt.currentTarget).find('.zoom-input');
// Hide display, show input
$zoomDisplay.hide();
$zoomInput.show().focus().select();
},
'keypress .js-zoom-input'(evt) {
if (evt.which === 13) {
// Enter key
const newZoomPercent = parseInt(evt.target.value);
if (
!isNaN(newZoomPercent) &&
newZoomPercent >= 50 &&
newZoomPercent <= 300
) {
const newZoom = newZoomPercent / 100;
Utils.setZoomLevel(newZoom);
// Hide input, show display
const $zoomDisplay = $(evt.target).siblings('.zoom-display');
const $zoomInput = $(evt.target);
$zoomInput.hide();
$zoomDisplay.show();
} else {
alert('Please enter a zoom level between 50% and 300%');
evt.target.focus().select();
}
}
},
'blur .js-zoom-input'(evt) {
// When input loses focus, hide it and show display
const $zoomDisplay = $(evt.target).siblings('.zoom-display');
const $zoomInput = $(evt.target);
$zoomInput.hide();
$zoomDisplay.show();
},
'click .js-mobile-mode-toggle'() {
const currentMode = Utils.getMobileMode();
Utils.setMobileMode(!currentMode);
},
'click .js-open-bookmarks'(evt) {
// Already added but ensure single definition -- safe guard
},

View file

@ -12,7 +12,7 @@
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys kbd {
padding: 5px 8px;
margin: 5px;
font-size: 18px;
}
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-action {
font-size: 1.4em;

View file

@ -1,33 +1,7 @@
/* Global variables that we can use to easily test and change layout
Later it could be useful to use a CSS superset */
/* this makes the property computable */
@property --popup-margin {
syntax: "<length>";
inherits: true;
initial-value: 0px;
* {
-webkit-box-sizing: unset;
box-sizing: unset;
}
:root {
scroll-behavior: smooth;
--label-height: 1.7lh;
--header-scale: clamp(1rem, 1.333rem + -0.333vw, 1.3rem)
--popup-margin: 2vmax;
/* regarding fonts, this is one of the clearest I found: https://modern-fluid-typography.vercel.app/ */
&:has(body.desktop-mode) {
font-size: clamp(1rem, 1.68rem + -0.57vw, 1.4rem);
--quick-header-scale: clamp(0.8rem, 0.6rem + 0.4vw, 1.2rem);
--list-item-size: 1.2em;
}
&:has(body.mobile-mode) {
font-size: clamp(2.5rem, 3vw + 1.7rem, 3.5rem);
--quick-header-scale: 1.3em;
--header-scale: clamp(1rem, -0.5vw + 1.25rem, 1.125rem);
--list-item-size: 1.6em;
}
}
/* Fixed missing 'import nib' stylesheet reset and extra li bullet points
* https://github.com/wekan/wekan/issues/4512#issuecomment-1129347536
*/
@ -58,26 +32,29 @@ a:focus {
color: unset;
text-decoration: unset;
}
.badge {
display: flex;
gap: 0 0.3ch;
align-items: center;
display: unset;
min-width: unset;
padding: unset;
font-size: unset;
font-weight: unset;
line-height: unset;
color: unset;
text-align: unset;
white-space: unset;
vertical-align: unset;
background-color: unset;
border-radius: unset;
}
body {
/* changed programmatically on swimlane resizes, or e.g. when un-collapsed */
transition: height 0.2s ease-out, width 0.2s ease-out;
}
html,
body,
input,
select,
textarea,
button {
font-family: Roboto, Poppins, "Helvetica Neue", "Liberation Sans", Arial, Helvetica, sans-serif;
color: hsl(0, 0%, 30%);
font: clamp(14px, 2.5vw, 18px) Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 1.4;
color: #4d4d4d;
/* Improve text rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@ -86,74 +63,58 @@ button {
user-select: text;
}
html {
font-size: 100%;
max-height: 100%;
-webkit-user-select: text;
user-select: text;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
overscroll-behavior: none;
text-size-adjust: 100%;
}
body {
background: #dedede;
margin: 0;
position: relative;
overflow-x: hidden;
z-index: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: start;
/* height is auto; if set to 100vh, it prevents navbar to disappear on scroll... */
width: 100%;
/* Needs to be set on body and html. Feels ok to disable entirely as Wekan is really drag/scroll-heavy */
overscroll-behavior: none;
min-height: 100vh;
line-height: 1.4;
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 {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: calc(100vh - 48px);
}
/* Prevent scroll through popups */
body:has(.pop-over:hover) {
overflow: hidden;
}
/* Some forms will need extra adjustement (removing margins, etc)
but it worth it to let browsers take care of exact placement/sizing */
.inlined-form {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 0.3lh;
width: 100%;
}
#content {
display: flex;
position: relative;
flex: 1;
overflow-x: hidden;
margin-bottom: 1vh;
min-height: 100vh;
max-width: min(100%, 100vw);
}
#content .sk-spinner {
margin-top: 30vh;
}
#content > .wrapper {
margin-top: 1vh;
padding: 2vh 2vw;
}
#modal {
position: absolute;
top: 0;
@ -196,6 +157,25 @@ but it worth it to let browsers take care of exact placement/sizing */
#modal .modal-content-wide .modal-close-btn {
display: block;
float: right;
font-size: clamp(18px, 4vw, 24px);
}
h1 {
font-size: clamp(18px, 4vw, 24px);
line-height: 1.2em;
margin: 0 0 1vh;
}
h2 {
font-size: clamp(16px, 3.5vw, 20px);
line-height: 1.2em;
margin: 0 0 0.8vh;
}
h3,
h4,
h5,
h6 {
font-size: clamp(14px, 3vw, 18px);
line-height: 1.25em;
margin: 0 0 0.6vh;
}
.quiet,
.quiet a {
@ -246,7 +226,7 @@ p {
}
p a {
text-decoration: underline;
overflow-wrap: break-word;
word-wrap: break-word;
}
table,
p {
@ -270,13 +250,13 @@ blockquote {
padding: 0 0 0 1vw;
}
hr {
height: 0.2ch;
height: 1px;
border: 0;
border: none;
width: 100%;
background: #dbdbdb;
color: #dbdbdb;
margin: 0.2lh 0;
margin: 2vh 0;
padding: 0;
}
table,
@ -323,7 +303,7 @@ kbd {
clear: both;
}
.hide {
display: none !important;
display: none;
}
.show {
display: block;
@ -357,11 +337,8 @@ kbd {
padding-bottom: 0;
}
.wrapper {
margin: 0;
flex: 1;
width: auto;
height: fit-content;
display: grid;
width: calc(100% - 2vw);
margin: 0 auto;
}
.relative {
position: relative;
@ -392,12 +369,8 @@ kbd {
.invisible {
visibility: hidden;
}
.invisible-line {
height: 1.3lh;
visibility: hidden;
}
.wrapword {
overflow-wrap: break-word;
word-wrap: break-word;
}
.grab {
cursor: grab;
@ -472,39 +445,8 @@ a:not(.disabled).is-active i.fa {
}
.viewer {
min-height: 2.5vh;
display: flex;
flex-direction: column;
align-items: start;
justify-content: center;
/* a tentative to get layout less dependant of content,
especially for small elements e.g. labels: the goal is that
content will be cut with `...` if too large (but will be fully
rendered in dedicated interfaces)
the classic technique is to use flex-basis, but it depends
on the parent not overflowing to get the right size; also,
specifying in terms of lines makes the browser act clever, by
fitting the available space and cutting after N lines, whatever
is the text's length */
min-width: 0;
p, ul {
margin: 0;
padding: 0;
text-overflow: ellipsis;
overflow: hidden;
/* See https: //css-tricks.com/line-clampin/,
it is widely supported and waiting standardization https: //caniuse.com/?search=-webkit-line-clamp */
display: -webkit-box !important;
/* 0 has no effect; ensures will not interfere unless asked */
-webkit-line-clamp: var(--overflow-lines, 0);
-webkit-box-orient: vertical;
-webkit-align-items: center;
/* grid properties apply */
align-content: center;
word-break: break-word;
white-space: normal;
}
display: block;
word-wrap: break-word;
}
.viewer table {
word-wrap: normal;
@ -539,12 +481,6 @@ a:not(.disabled).is-active i.fa {
padding: 0;
padding-top: 15px;
}
.basicTabs-container .tabs-list .tab-item {
/* where does templates_tabs.css come from? visible in
devtools but not in sources */
font-size: unset !important;
}
.no-scrollbars {
scrollbar-width: none;
}
@ -559,7 +495,21 @@ a:not(.disabled).is-active i.fa {
@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);
/* Improve touch scrolling */
-webkit-overflow-scrolling: touch;
}
#content > .wrapper {
margin-top: 0px;
padding: 8px;
}
.wrapper {
height: calc(100% - 31px);
margin: 0px;
padding: 8px;
}
.panel-default {
width: 95vw;
max-width: 95vw;
@ -571,18 +521,107 @@ a:not(.disabled).is-active i.fa {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
/* Prevent zoom on iOS */
font-size: 16px; /* Prevent zoom on iOS */
touch-action: manipulation;
}
/* Form elements */
input, select, textarea {
/* Prevent zoom on iOS */
font-size: 16px; /* Prevent zoom on iOS */
padding: 12px;
min-height: 44px;
touch-action: manipulation;
}
/* Cards and lists */
.minicard {
min-height: 48px;
padding: 12px;
margin-bottom: 8px;
touch-action: manipulation;
}
.list {
margin: 0 8px;
min-width: 280px;
}
/* Board canvas */
.board-canvas {
padding: 0 8px 8px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Header mobile layout */
#header {
padding: 8px;
/* Keep top bar on a single row on small screens */
flex-wrap: nowrap;
align-items: center;
gap: 8px;
}
#header-quick-access {
/* Keep quick-access items in one row */
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
}
/* Hide elements that should move to the hamburger menu on mobile */
#header-quick-access .header-quick-access-list,
#header-quick-access #header-help {
display: none !important;
}
/* Show only the home icon (hide the trailing text) on mobile */
#header-quick-access .home-icon a {
display: inline-flex;
align-items: center;
max-width: 28px; /* enough to display the icon */
overflow: hidden;
white-space: nowrap;
}
/* Hide text in home icon on mobile, show only icon */
#header-quick-access .home-icon a span:not(.fa) {
display: none !important;
}
/* Ensure proper spacing for mobile header elements */
#header-quick-access .zoom-controls {
margin-left: auto;
margin-right: 8px;
}
.mobile-mode-toggle {
margin-right: 8px;
}
#header-user-bar {
margin-left: auto;
}
/* Ensure header elements don't wrap on very small screens */
#header-quick-access {
min-width: 0; /* Allow flexbox to shrink */
}
/* Make sure logo doesn't take too much space on mobile */
#header-quick-access img {
max-height: 24px;
max-width: 120px;
}
/* Ensure zoom controls are compact on mobile */
.zoom-controls .zoom-level {
padding: 4px 8px;
font-size: 12px;
}
/* Modal mobile optimization */
#modal .modal-content,
#modal .modal-content-wide {
@ -596,7 +635,7 @@ a:not(.disabled).is-active i.fa {
/* Table mobile optimization */
table {
font-size: 14px;
width: 100%;
display: block;
overflow-x: auto;
@ -613,6 +652,7 @@ a:not(.disabled).is-active i.fa {
.setting-content .content-body .side-menu {
width: 100%;
order: 2;
}
.setting-content .content-body .main-body {
@ -623,8 +663,94 @@ a:not(.disabled).is-active i.fa {
}
}
/* Tablet devices (768px - 1024px) */
@media screen and (min-width: 768px) and (max-width: 1024px) {
#content > .wrapper {
padding: 12px;
}
.wrapper {
padding: 12px;
}
.panel-default {
width: 90vw;
max-width: 90vw;
}
/* Touch-friendly but more compact */
button, .btn, .js-toggle, .js-color-choice, .js-reaction, .close {
min-height: 48px;
min-width: 48px;
padding: 10px 14px;
}
.minicard {
min-height: 40px;
padding: 10px;
}
.list {
margin: 0 12px;
min-width: 300px;
}
.board-canvas {
padding: 0 12px 12px 0;
}
#header {
padding: 12px 16px;
}
#modal .modal-content {
width: 80vw;
max-width: 600px;
}
#modal .modal-content-wide {
width: 90vw;
max-width: 800px;
}
.setting-content .content-body {
gap: 20px;
}
.setting-content .content-body .side-menu {
width: 250px;
}
/* Responsive handling for quick-access description on tablets */
#header-quick-access ul.header-quick-access-list li.current.empty {
max-width: 300px;
}
}
/* Large displays and digital signage (1920px+) */
@media screen and (min-width: 1920px) {
body {
font-size: 18px;
}
button, .btn, .js-toggle, .js-color-choice, .js-reaction, .close {
min-height: 56px;
min-width: 56px;
padding: 16px 20px;
font-size: 18px;
}
.minicard {
min-height: 56px;
padding: 16px;
font-size: 18px;
}
.list {
margin: 0 8px;
min-width: 360px;
}
.board-canvas {
padding: 0;
}
@ -653,19 +779,23 @@ a:not(.disabled).is-active i.fa {
width: 320px;
}
}
.ui-sortable-handle {
cursor: grab !important;
.inline-input {
height: 37px;
margin: 8px 10px 0 0;
width: 100px;
}
.select-authentication {
width: 100%;
}
#rescue-card-description {
.textBelowCustomLoginLogo,
.auth-layout {
display: flex;
flex: 1 0 auto;
align-self: center;
margin: 0 0.2lh;
flex-direction: column;
align-items: center;
justify-content: center;
}
.auth-layout .auth-dialog {
margin: 0 !important;
}
.loadingText {
text-align: center;
@ -752,18 +882,8 @@ a:not(.disabled).is-active i.fa {
text-decoration: underline;
text-decoration-color: #17683a;
}
/*
Prevents popups to compute real size, trying to comment
.at-pwd-form, .at-sep, .at-oauth {
display: none;
}*/
#at-pwd-form {
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: stretch;
gap: 0.3lh;
}
@-moz-keyframes fadeIn {
from {
@ -808,11 +928,23 @@ Prevents popups to compute real size, trying to comment
/* 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;
}

View file

@ -23,56 +23,61 @@ template(name="main")
//link(rel="stylesheet" type="text/css" class="__meteor-css__" href="css/html5-default-theme.css")
template(name="userFormsLayout")
.auth-container
section.auth-layout.auth-logo
if currentSetting.hideLogo
h1.at-form-landing-logo
unless currentSetting.hideLogo
if currentSetting.customLoginLogoImageUrl
if currentSetting.customLoginLogoLinkUrl
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
img(src="{{currentSetting.customLoginLogoImageUrl}}")
unless currentSetting.customLoginLogoLinkUrl
a
img(src="{{currentSetting.customLoginLogoImageUrl}}")
else
a
img(src="{{pathFor '/wekan-logo.svg'}}" alt="")
section.auth-layout
if currentSetting.hideLogo
h1.at-form-landing-logo
br
br
unless currentSetting.hideLogo
h1.at-form-landing-logo
if currentSetting.customLoginLogoImageUrl
if currentSetting.customLoginLogoLinkUrl
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
img(src="{{currentSetting.customLoginLogoImageUrl}}" width="300" height="auto")
br
section.auth-custom-text
if currentSetting.textBelowCustomLoginLogo
section.textBelowCustomLoginLogo
+viewer
| {{currentSetting.textBelowCustomLoginLogo}}
section.auth-layout.auth-form
section.auth-dialog
if isLoading
+loader
else
// ARIA live region for error messages
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f;")
+Template.dynamic(template=content)
if currentSetting.displayAuthenticationMethod
+connectionMethod(authenticationMethod=currentSetting.defaultAuthenticationMethod)
if isLegalNoticeLinkExist
div#legalNoticeDiv
span#legalNoticeSpan {{_ 'acceptance_of_our_legalNotice'}}
a#legalNoticeAtLink.at-link(href="{{currentSetting.legalNotice}}", target="_blank", rel="noopener noreferrer")
| {{_ 'legalNotice'}}
div.at-form-lang
label(for="userform-set-language-select") {{_ 'changeLanguagePopup-title'}}
select.select-lang.js-userform-set-language#userform-set-language-select(aria-label="{{_ 'changeLanguagePopup-title'}}")
each languages
if isCurrentLanguage
if rtl
option(value="{{tag}}" selected="selected") {{name}} (RTL)
else
option(value="{{tag}}" selected="selected") {{name}}
unless currentSetting.customLoginLogoLinkUrl
img(src="{{currentSetting.customLoginLogoImageUrl}}" width="300" height="auto")
br
else
img(src="{{pathFor '/wekan-logo.svg'}}" alt="" width="300" height="auto")
br
if currentSetting.textBelowCustomLoginLogo
hr
section.textBelowCustomLoginLogo
+viewer
| {{currentSetting.textBelowCustomLoginLogo}}
hr
section.auth-layout
section.auth-dialog
if isLoading
+loader
else
// ARIA live region for error messages
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")
+Template.dynamic(template=content)
if currentSetting.displayAuthenticationMethod
+connectionMethod(authenticationMethod=currentSetting.defaultAuthenticationMethod)
if isLegalNoticeLinkExist
div#legalNoticeDiv
span#legalNoticeSpan {{_ 'acceptance_of_our_legalNotice'}}
a#legalNoticeAtLink.at-link(href="{{currentSetting.legalNotice}}", target="_blank", rel="noopener noreferrer")
| {{_ 'legalNotice'}}
if getLegalNoticeWithWritTraduction
div
div.at-form-lang
label(for="userform-set-language-select") {{_ 'changeLanguagePopup-title'}}
select.select-lang.js-userform-set-language#userform-set-language-select(aria-label="{{_ 'changeLanguagePopup-title'}}")
each languages
if isCurrentLanguage
if rtl
option(value="{{tag}}" selected="selected") {{name}} (RTL)
else
if rtl
option(value="{{tag}}") {{name}} (RTL)
else
option(value="{{tag}}") {{name}}
option(value="{{tag}}" selected="selected") {{name}}
else
if rtl
option(value="{{tag}}") {{name}} (RTL)
else
option(value="{{tag}}") {{name}}
template(name="defaultLayout")
+header

View file

@ -85,7 +85,7 @@ Template.userFormsLayout.onRendered(() => {
validator,
);
EscapeActions.executeAll();
// Set up MutationObserver for OIDC button instead of deprecated DOMSubtreeModified
const oidcButton = document.getElementById('at-oidc');
if (oidcButton) {
@ -115,7 +115,7 @@ Template.userFormsLayout.onRendered(() => {
});
observer.observe(oidcButton, { childList: true, subtree: true });
}
// Set up MutationObserver for .at-form instead of deprecated DOMSubtreeModified
const atForm = document.querySelector('.at-form');
if (atForm) {
@ -312,9 +312,9 @@ function getAuthenticationMethod(
if (!settings) {
return getUserAuthenticationMethod(undefined, match);
}
const { displayAuthenticationMethod, defaultAuthenticationMethod } = settings;
if (displayAuthenticationMethod) {
return $('.select-authentication').val();
}

View file

@ -1,18 +1,22 @@
body.mobile-mode {
.my-cards-board-wrapper {
width: 100vw;
}
.my-cards-swimlane-body {
grid-auto-flow: row;
}
.my-cards-board-wrapper {
border-radius: 0 0 0.5vw 0.5vw;
min-width: min(400px, 52vw);
margin-bottom: 2.5vh;
margin-right: auto;
margin-left: auto;
border-width: 0.3vw;
border-style: solid;
border-color: #a2a2a2;
}
.my-cards-swimlane-body {
display: grid;
grid-auto-flow: column;
gap: 1ch;
.my-cards-board-title {
font-size: clamp(1.2rem, 3vw, 1.6rem);
font-weight: bold;
padding: 0.7vh 0.7vw;
background-color: #808080;
color: #fff;
}
.my-cards-swimlane-title {
font-size: clamp(1em, 2.5vw, 1.3rem);
font-size: clamp(1rem, 2.5vw, 1.3rem);
font-weight: bold;
padding: 0.7vh 0.7vw;
padding-bottom: 0.5vh;
@ -23,12 +27,48 @@ body.mobile-mode {
.swimlane-default-color {
background-color: #d3d3d3;
}
.my-cards-list-title {
font-weight: bold;
font-size: clamp(1rem, 2.5vw, 1.3rem);
text-align: center;
margin-bottom: 0.9vh;
}
.my-cards-list-wrapper {
display: flex;
flex-direction: column;
max-width: clamp(300px, 20vw, 30vw);
margin: 1.3vh 1.3vw;
border-radius: 0.7vw;
display: inline-grid;
min-width: min(250px, 32vw);
max-width: min(350px, 45vw);
}
body.mobile-mode .my-cards-list-wrapper {
max-width: unset;
.my-cards-card-wrapper {
margin-top: 0;
margin-bottom: 1.3vh;
}
.my-cards-dueat-list-wrapper {
max-width: min(500px, 65vw);
margin-right: auto;
margin-left: auto;
}
.my-cards-board-table thead {
border-bottom: 3px solid #4d4d4d;
background-color: transparent;
}
.my-cards-board-table th,
.my-cards-board-table td {
border: 0;
}
.my-cards-board-table tr {
border-bottom: 2px solid #a2a2a2;
}
.my-cards-card-title-table {
font-weight: bold;
padding-left: 2px;
max-width: 243px;
}
.my-cards-board-badge {
width: 36px;
height: 24px;
float: left;
border-radius: 5px;
margin-right: 5px;
}

View file

@ -2,7 +2,8 @@ template(name="myCardsHeaderBar")
if currentUser
h1
//a.back-btn(href="{{pathFor 'home'}}")
// i.fa.fa-chevron-left
//
i.fa.fa-chevron-left
i.fa.fa-list
| {{_ 'my-cards'}}
@ -39,16 +40,15 @@ template(name="myCards")
.my-cards-swimlane-title(class="{{#if swimlane.colorClass}}{{ swimlane.colorClass }}{{else}}swimlane-default-color{{/if}}")
+viewer
= swimlane.title
.my-cards-swimlane-body
each list in swimlane.myLists
.my-cards-list-wrapper
.my-cards-list-title(class=list.colorClass)
+viewer
= list.title
each card in list.myCards
.my-cards-card-wrapper
a.minicard-wrapper(href=card.originRelativeUrl)
+minicard(card)
each list in swimlane.myLists
.my-cards-list-wrapper
.my-cards-list-title(class=list.colorClass)
+viewer
= list.title
each card in list.myCards
.my-cards-card-wrapper
a.minicard-wrapper(href=card.originRelativeUrl)
+minicard(card)
if $eq myCardsView 'table'
.wrapper
table.my-cards-board-table
@ -73,7 +73,8 @@ template(name="myCards")
.my-cards-card-title-table
| {{card.title}}
//a.minicard-wrapper(href=card.originRelativeUrl)
// | {{card.title}}
//
| {{card.title}}
td
| {{list.title}}
td

View file

@ -1,121 +1,91 @@
.pop-over {
background: #ededed;
background: #fff;
border-radius: 0.4vw;
border: 1px solid #dbdbdb;
border-bottom-color: #c2c2c2;
box-shadow: 0 0.2vh 0.8vh rgba(0, 0, 0, 0.3);
/* so they can easily travel with mouse */
position: fixed;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: stretch;
resize: both;
pointer-events: all;
max-height: 100vh;
.content-wrapper {
width: auto;
height: auto;
position: relative;
overflow-y: auto;
}
.content-wrapper >* {
/* low specificity so that it can be transparently overriden,
but could have side effects if no display is explicitely specific in inner content */
display: flex;
flex: 1;
flex-direction: column;
width: auto;
height: auto;
}
}
.pop-over a:has(.fa-plus)+ :not(*) {
min-height: 1.5lh;
aspect-ratio: 1/1;
display: flex;
justify-content: center;
margin-top: 0.2lh;
box-shadow: 0 0.2vh 0.8vh rgba(0,0,0,0.3);
position: absolute;
/* Wider default to fit full color palette */
width: min(380px, 55vw);
z-index: 99999;
margin-top: 0.7vh;
}
.pop-over hr {
margin: 0.3lh 0;
/* below everything in the same stacking context when
after, child or explicit z-index */
z-index: 0;
margin: 0.5vh 0px;
}
.pop-over {
/* feels like it's too ad-hod */
input, a:not(.js-board-template, .member, .edit-avatar) {
display: inline-flex;
align-items: center;
gap: 1ch;
min-height: 1.5lh;
}
.pop-over p,
.pop-over textarea,
.pop-over input[type="text"],
.pop-over input[type="email"],
.pop-over input[type="password"],
.pop-over input[type="file"] {
width: 100%;
}
.pop-over .sub-name {
max-width: clamp(30vw, 500px, 80%);
.pop-over select {
width: 100%;
margin-bottom: 1.8vh;
}
.pop-over textarea {
height: 9vh;
}
.pop-over form a span {
padding: 0 0.7vw;
}
.pop-over .header {
display: flex;
justify-content: space-between;
gap: 1ch;
align-items: center;
padding: 0 1ch;
height: 4.5vh;
position: relative;
margin-bottom: 1vh;
background: #f7f7f7;
border-bottom: 1px solid #dcdcdc;
color: #666;
min-height: 2lh;
}
.pop-over .header .header-title {
display: flex;
display: block;
line-height: 4vh;
padding-top: 0.5vh;
margin: 0 1.3vw;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.2em;
flex: 1;
cursor: grab !important;
}
.pop-over .back-btn {
.pop-over .header .back-btn {
float: left;
overflow: hidden;
width: 4vw;
transition: width 0.2s;
}
.pop-over .back-btn.is-hidden {
.pop-over .header .back-btn i.fa {
margin: 1.3vw;
margin-top: 1.5vh;
}
.pop-over .header .back-btn.is-hidden {
width: 0;
}
.pop-over .header .close-btn {
padding: 1.3vh 1.3vw 1.3vh 0.5vw;
position: absolute;
top: 0;
right: 0;
}
.pop-over.no-title .header {
background: none;
}
.pop-over {
.content-wrapper, .header {
display: flex;
align-items: center;
}
}
.pop-over:has(.header) .content {
/* inner content has full width available,
so it is also responsive for margins, sizes, etc */
.pop-over .content-wrapper {
width: 100%;
max-height: calc(70vh + 20px);
overflow-y: auto;
overflow-x: hidden;
}
.popup-placeholder {
/* This gives relative coordinates but height/width cannot fit the parent's
without it having position: relative; we need to get them programmatically */
position: absolute;
/* Take all size of parent so it can be useful in computations */
visibility: hidden;
display: none;
/* Allow dynamic max-height to override default constraint */
.pop-over[style*="max-height"] .content-wrapper {
max-height: inherit;
}
.pop-over .content-container {
display: flex;
align-items: stretch;
flex: 1;
width: 100%;
max-height: calc(70vh + 20px);
transition: transform 0.2s;
}
/* Allow dynamic max-height to override default constraint for content-container */
@ -123,42 +93,270 @@
max-height: inherit;
}
.pop-over .popup-drag-handle {
cursor: move;
/* Fix overflow in the Member Settings (member menu) popup:
the popup itself gets a max-height inline style, but the header consumes space.
Make the header overlay the scrollable area so the list can't spill out. */
.pop-over[data-popup="memberMenuPopup"] {
overflow: hidden;
}
.pop-over[data-popup="memberMenuPopup"] > .header {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-bottom: 0;
z-index: 1;
}
.pop-over[data-popup="memberMenuPopup"] > .content-wrapper {
padding-top: calc(4.5vh + 1vh);
box-sizing: border-box;
}
body.mobile-mode {
.popup-drag-handle, .close-btn {
font-size: 1.4em;
align-self: center;
}
.pop-over:has(.pop-over-list) {
min-width: 70vw;
}
/* Admin edit popups: use full height */
.pop-over[data-popup="editUserPopup"],
.pop-over[data-popup="editOrgPopup"],
.pop-over[data-popup="editTeamPopup"] {
height: calc(100vh - 20px) !important;
max-height: calc(100vh - 20px) !important;
}
.pop-over .header-controls {
display: flex;
gap: 1ch;
.pop-over[data-popup="editUserPopup"] .content-wrapper,
.pop-over[data-popup="editOrgPopup"] .content-wrapper,
.pop-over[data-popup="editTeamPopup"] .content-wrapper {
max-height: calc(100vh - 80px) !important; /* Subtract header height */
height: calc(100vh - 80px) !important;
overflow-y: auto !important;
}
.pop-over[data-popup="editUserPopup"] .content-container,
.pop-over[data-popup="editOrgPopup"] .content-container,
.pop-over[data-popup="editTeamPopup"] .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;
overflow: visible;
}
/* Specific styling for language popup list */
.pop-over[data-popup="changeLanguagePopup"] .pop-over-list {
max-height: none;
overflow: visible;
height: auto;
flex: 1;
}
/* Ensure content div in language popup contains all items */
.pop-over[data-popup="changeLanguagePopup"] .content {
height: auto;
/* Remove forced min-height to avoid top gap */
display: flex;
flex-direction: column;
flex: 1;
font-size: 1.1rem;
padding: 0 1ch;
>li>a {
display: grid;
grid-auto-flow: column;
grid-auto-columns: fit-content;
justify-content: start;
padding: 0 0.5ch;
column-gap: 1ch;
.sub-name {
text-align: end;
}
}
}
/* Ensure hidden stack pages truly take no space */
.pop-over[data-popup="changeLanguagePopup"] .content.no-height {
min-height: 0 !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
visibility: hidden !important;
}
/* Make language popup extend to bottom of browser window */
.pop-over[data-popup="changeLanguagePopup"] {
position: fixed !important;
bottom: 0 !important;
top: auto !important;
left: auto !important;
right: 20px !important;
width: auto !important;
max-width: 450px !important;
height: 100vh !important;
max-height: 100vh !important;
min-height: 300px !important;
display: flex !important;
flex-direction: column !important;
margin: 0 !important;
}
/* Allow dynamic height for Change Language popup */
.pop-over[data-popup="changeLanguagePopup"] .header {
flex-shrink: 0 !important;
height: auto !important;
}
.pop-over[data-popup="changeLanguagePopup"] .content-wrapper {
flex: 1 !important;
overflow-y: auto !important;
overflow-x: hidden !important;
min-height: 0 !important;
max-height: none !important;
height: auto !important;
width: 100% !important;
}
.pop-over[data-popup="changeLanguagePopup"] .content-container {
height: auto !important;
max-height: none !important;
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
width: 100% !important;
}
.pop-over[data-popup="changeLanguagePopup"] .content {
height: auto !important;
max-height: none !important;
padding-bottom: 50px !important;
width: 100% !important;
}
/* Date popup sizing for native HTML inputs */
.pop-over[data-popup="editCardReceivedDatePopup"],
.pop-over[data-popup="editCardStartDatePopup"],
.pop-over[data-popup="editCardDueDatePopup"],
.pop-over[data-popup="editCardEndDatePopup"],
.pop-over[data-popup*="Date"] {
width: min(400px, 90vw) !important; /* Smaller width for native inputs */
min-width: 350px !important;
max-height: 80vh !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .content-wrapper,
.pop-over[data-popup="editCardStartDatePopup"] .content-wrapper,
.pop-over[data-popup="editCardDueDatePopup"] .content-wrapper,
.pop-over[data-popup="editCardEndDatePopup"] .content-wrapper,
.pop-over[data-popup*="Date"] .content-wrapper {
max-height: 60vh !important;
overflow-y: auto !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .content-container,
.pop-over[data-popup="editCardStartDatePopup"] .content-container,
.pop-over[data-popup="editCardDueDatePopup"] .content-container,
.pop-over[data-popup="editCardEndDatePopup"] .content-container,
.pop-over[data-popup*="Date"] .content-container {
max-height: 60vh !important;
}
/* Native HTML input styling */
.pop-over[data-popup*="Date"] .datepicker-container {
width: 100% !important;
padding: 15px !important;
}
.pop-over[data-popup*="Date"] .datepicker-container .fields {
display: flex !important;
gap: 15px !important;
margin-bottom: 15px !important;
}
.pop-over[data-popup*="Date"] .datepicker-container .fields .left,
.pop-over[data-popup*="Date"] .datepicker-container .fields .right {
flex: 1 !important;
width: auto !important;
}
.pop-over[data-popup*="Date"] .datepicker-container label {
display: block !important;
margin-bottom: 5px !important;
font-weight: bold !important;
}
.pop-over[data-popup*="Date"] .datepicker-container input[type="date"],
.pop-over[data-popup*="Date"] .datepicker-container input[type="time"] {
width: 100% !important;
padding: 8px !important;
border: 1px solid #ccc !important;
border-radius: 4px !important;
font-size: 14px !important;
box-sizing: border-box !important;
}
.pop-over[data-popup*="Date"] .datepicker-container input[type="date"]:focus,
.pop-over[data-popup*="Date"] .datepicker-container input[type="time"]:focus {
outline: none !important;
border-color: #007cba !important;
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2) !important;
}
/* Ensure date popup buttons stay within popup boundaries */
.pop-over[data-popup="editCardReceivedDatePopup"] .content,
.pop-over[data-popup="editCardStartDatePopup"] .content,
.pop-over[data-popup="editCardDueDatePopup"] .content,
.pop-over[data-popup="editCardEndDatePopup"] .content,
.pop-over[data-popup*="Date"] .content {
max-height: 60vh !important; /* Leave space for buttons */
overflow-y: auto !important;
padding-bottom: 100px !important; /* More space for buttons */
margin-bottom: 0 !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .datepicker-container,
.pop-over[data-popup="editCardStartDatePopup"] .datepicker-container,
.pop-over[data-popup="editCardDueDatePopup"] .datepicker-container,
.pop-over[data-popup="editCardEndDatePopup"] .datepicker-container,
.pop-over[data-popup*="Date"] .datepicker-container {
max-height: 50vh !important; /* Limit calendar height */
overflow-y: auto !important;
margin-bottom: 20px !important; /* Space before buttons */
}
/* Ensure buttons are properly positioned */
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date,
.pop-over[data-popup*="Date"] .edit-date {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date .fields,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date .fields,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date .fields,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date .fields,
.pop-over[data-popup*="Date"] .edit-date .fields {
flex-shrink: 0 !important;
margin-bottom: 15px !important;
}
.pop-over[data-popup="editCardReceivedDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup="editCardStartDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup="editCardDueDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date .js-datepicker,
.pop-over[data-popup*="Date"] .edit-date .js-datepicker {
flex: 1 !important;
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,
.pop-over[data-popup="editCardEndDatePopup"] .edit-date button,
.pop-over[data-popup*="Date"] .edit-date button {
flex-shrink: 0 !important;
margin-top: 15px !important;
position: relative !important;
z-index: 10 !important;
}
.pop-over .content-container .content {
/* Match wider popover, leave padding */
width: 100%;
padding: 0 1.3vw 1.3vh;
box-sizing: border-box;
/* Ensure content is not shifted left */
margin-left: 0 !important;
transform: none !important;
}
/* Utility: remove left gutter inside specific popups */
.pop-over .content .flush-left {
margin-left: 0;
@ -180,15 +378,58 @@ body.mobile-mode {
.pop-over .content form.create-label .palette-colors {
margin-left: 0;
padding-left: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
width: 100%;
}
/* Color palette items: ensure proper positioning */
.pop-over .content .palette-colors .palette-color {
margin-left: 0;
margin-right: 2px;
margin-bottom: 2px;
}
/* Global fix for all popup content to prevent left shifting */
.pop-over .content * {
margin-left: 0 !important;
transform: none !important;
}
/* Override any potential left shifting for specific elements */
.pop-over .content form,
.pop-over .content .palette-colors,
.pop-over .content .pop-over-list,
.pop-over .content .flush-left {
margin-left: 0 !important;
padding-left: 0 !important;
transform: none !important;
}
/* Fix popup depth containers that cause left shifting */
.pop-over .popup-container-depth-1,
.pop-over .popup-container-depth-2,
.pop-over .popup-container-depth-3,
.pop-over .popup-container-depth-4,
.pop-over .popup-container-depth-5,
.pop-over .popup-container-depth-6 {
transform: none !important;
margin-left: 0 !important;
padding-left: 0 !important;
}
/* Ensure buttons dont reserve left space; align to flow */
.pop-over .content form.swimlane-color-popup .primary.confirm,
.pop-over .content form.swimlane-color-popup .negate.wide.right,
.pop-over .content .swimlane-height-popup .primary.confirm,
.pop-over .content .swimlane-height-popup .negate.wide.right {
float: none;
margin-left: 0;
}
.pop-over .content-container .content.no-height {
height: 0;
overflow: hidden;
padding: 0;
margin: 0;
border-radius: 0;
outline: 0.1ch solid black;
visibility: hidden;
}
.pop-over.search-over {
background: #f0f0f0;
@ -215,6 +456,24 @@ body.mobile-mode {
.pop-over .sk-spinner {
margin: 40px auto;
}
.pop-over .popup-container-depth-1 {
transform: translateX(-300px);
}
.pop-over .popup-container-depth-2 {
transform: translateX(-600px);
}
.pop-over .popup-container-depth-3 {
transform: translateX(-900px);
}
.pop-over .popup-container-depth-4 {
transform: translateX(-1200px);
}
.pop-over .popup-container-depth-5 {
transform: translateX(-1500px);
}
.pop-over .popup-container-depth-6 {
transform: translateX(-1800px);
}
.select-members-list,
.select-avatars-list {
margin-bottom: 8px;
@ -228,12 +487,15 @@ body.mobile-mode {
cursor: pointer;
display: block;
font-weight: 700;
padding-inline: 2vmin 10vmin;
padding: 1.5px 10px;
position: relative;
margin: 0;
text-decoration: none;
overflow: hidden;
line-height: 33px;
display:flex;
/* flex-wrap:wrap;*/
gap:5px;
align-items: center;
color: #000 !important;
}
@ -244,6 +506,7 @@ body.mobile-mode {
.pop-over-list li > a .item-name {
display: block;
width: auto;
padding-right: 22px;
}
.pop-over-list li > a:not(.disabled):hover {
background-color: #005377;
@ -259,9 +522,9 @@ body.mobile-mode {
.pop-over-list li > a .sub-name {
color: #8c8c8c;
display: block;
font-size: 0.8em;
font-size: 12px;
font-weight: 400;
line-height: 1.2em;
line-height: 15px;
}
.pop-over-list li > a.current {
background-color: #e2e6e9;
@ -307,21 +570,156 @@ body.mobile-mode {
body.grey-icons-enabled .pop-over-list .pop-over-list.checkable .fa-check {
color: #7a7a7a;
}
.pop-over .content > form {
padding: 0 1ch;
gap: 0.2lh;
display: flex;
max-width: clamp(20vw, 400px, 50vw);
.pop-over.miniprofile .header {
border-bottom-color: transparent;
height: 30px;
position: absolute;
right: 0;
top: 0;
width: 60px;
z-index: 1;
}
body.mobile-mode .pop-over .content>form {
max-width: 100%;
.pop-over.miniprofile .header-title {
display: none;
}
.pop-over .board-subtask-settings {
>h3 {
display: flex;
flex-direction: column;
.pop-over.miniprofile .pop-over-list {
padding-top: 8px;
}
.pop-over.miniprofile .miniprofile-header {
margin-top: 8px;
min-height: 56px;
position: relative;
}
.pop-over.miniprofile .miniprofile-header .member,
.pop-over.miniprofile .miniprofile-header .avatar {
position: absolute;
top: 2px;
left: 2px;
height: 50px;
width: 50px;
}
.pop-over.miniprofile .miniprofile-header .info {
margin: 0 0 0 64px;
word-wrap: break-word;
}
.pop-over.miniprofile .miniprofile-header .info h3 a {
text-decoration: none;
}
.pop-over.miniprofile .miniprofile-header .info h3 a:hover {
text-decoration: underline;
}
@media screen and (max-width: 800px) {
.pop-over {
width: 100%;
height: 100%;
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;
background: #2980b9;
height: 48px;
padding: 0px 0px;
border: 0px;
margin: 0px 0px;
width: 100%;
position: absolute;
top: 0px;
}
.pop-over .header .header-title {
font-size: 20px;
font-weight: normal;
padding-top: 8px;
}
.pop-over .header .back-btn {
width: 30px;
padding: 8px 12px 8px 12px;
}
.pop-over .header .back-btn i.fa {
color: #fff;
}
.pop-over .header .close-btn {
padding: 10px 12px;
}
.pop-over .header .close-btn i.fa {
font-size: 24px;
color: #fff;
}
.pop-over .content-wrapper {
width: 100%;
height: calc(100% - 48px);
overflow-y: scroll;
overflow-x: hidden;
margin: 48px 0px 0px 0px;
}
.pop-over .content-container {
width: 100%;
height: 100%;
max-height: 100%;
}
.pop-over .content-container .content {
width: calc(100% - 20px);
height: calc(100% - 20px);
padding: 10px;
}
.pop-over .content-container .content form {
margin: 10px 10px;
width: calc(100% - 20px);
}
.pop-over .content-container .content p,
.pop-over .content-container .content textarea,
.pop-over .content-container .content input[type="text"],
.pop-over .content-container .content input[type="email"],
.pop-over .content-container .content input[type="password"],
.pop-over .content-container .content input[type="file"] {
width: 100%;
box-sizing: border-box;
}
.pop-over .pop-over-list li > a {
width: calc(100% - 20px);
margin: 0px 0px;
}
.pop-over .popup-container-depth-1 {
transform: none !important;
}
.pop-over .popup-container-depth-2 {
transform: none !important;
}
.pop-over .popup-container-depth-3 {
transform: none !important;
}
.pop-over .popup-container-depth-4 {
transform: none !important;
}
.pop-over .popup-container-depth-5 {
transform: none !important;
}
.pop-over .popup-container-depth-6 {
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;
}

View file

@ -1,696 +1,39 @@
import { BlazeComponent } from 'meteor/peerlibrary:blaze-components';
import { Template } from 'meteor/templating';
Popup.template.events({
'click .js-back-view'() {
Popup.back();
},
'click .js-close-pop-over'() {
Popup.close();
},
'click .js-confirm'() {
this.__afterConfirmAction.call(this);
},
// This handler intends to solve a pretty tricky bug with our popup
// transition. The transition is implemented using a large container
// (.content-container) that is moved on the x-axis (from 0 to n*PopupSize)
// inside a wrapper (.container-wrapper) with a hidden overflow. The problem
// is that sometimes the wrapper is scrolled -- even if there are no
// scrollbars. This happen for instance when the newly opened popup has some
// focused field, the browser will automatically scroll the wrapper, resulting
// in moving the whole popup container outside of the popup wrapper. To
// disable this behavior we have to manually reset the scrollLeft position
// whenever it is modified.
'scroll .content-wrapper'(evt) {
evt.currentTarget.scrollLeft = 0;
},
});
const PopupBias = {
Before: Symbol("S"),
Overlap: Symbol("M"),
After: Symbol("A"),
Fullscreen: Symbol("F"),
includes(e) {
return Object.values(this).includes(e);
}
}
// this class is a bit cumbersome and could probably be done simpler.
// it manages two things : initial placement and sizing given an opener element,
// and then movement and resizing. one difficulty was to be able, as a popup
// which can be resized from the "outside" (CSS4) and move from the inside (inner
// component), which also grows and shrinks frequently, to adapt.
// I tried many approach and failed to get the perfect fit; I feel that there is
// always something indeterminate at some point. so the only drawback is that
// if a popup contains another resizable component (e.g. card details), and if
// it has been resized (with CSS handle), it will lose its dimensions when dragging
// it next time.
class PopupDetachedComponent extends BlazeComponent {
onCreated() {
// Set by parent/caller (usually PopupComponent)
({ nonPlaceholderOpener: this.nonPlaceholderOpener, closeDOMs: this.closeDOMs = [], followDOM: this.followDOM } = this.data());
if (typeof(this.closeDOMs) === "string") {
// helper for passing arg in JADE template
this.closeDOMs = this.closeDOMs.split(';');
}
// The popup's own header, if it exists
this.closeDOMs.push("click .js-close-detached-popup");
}
// Main intent of this component is to have a modular popup with defaults:
// - sticks to its opener while being a child of body (thus in the same stacking context, no z-index issue)
// - is responsive on shrink while keeping position absolute
// - can grow back to initial position step by step
// - exposes various sizes as CSS variables so each rendered popup can use them to adapt defaults
// * issue is that it is done by hand, with heurisitic/simple algorithm from my thoughts, not sure it covers edge cases
// * however it works well so far and maybe more "fixed" element should be popups
onRendered() {
// Remember initial ratio between initial dimensions and viewport
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
this.popup = this.firstNode();
this.popupOpener = this.data().openerElement;
const popupStyle = window.getComputedStyle(this.firstNode());
// margin may be in a relative unit, not computable in JS, but we get the actual pixels here
this.popupMargin = parseFloat(popupStyle.getPropertyValue("--popup-margin"), 10) || Math.min(window.innerWidth / 50, window.innerHeight / 50);
this.dims(this.computeMaxDims());
this.initialPopupWidth = this.popupDims.width;
this.initialPopupHeight = this.popupDims.height;
this.initialHeightRatio = this.initialPopupHeight / viewportHeight;
this.initialWidthRatio = this.initialPopupWidth / viewportWidth;
this.dims(this.computePopupDims());
if (this.followDOM) {
this.innerElement = this.find(this.followDOM) ?? document.querySelector(this.followDOM);
}
this.follow();
this.toFront();
// #FIXME the idea of keeping the initial ratio on resize is quite bad. remove that part.
// there is a reactive variable for window resize in Utils, but the interface is too slow
// with all reactive stuff, use events when possible and when not really bypassing logic
$(window).on('resize', () => {
// #FIXME there is a bug when window grows; popup outer container
// will grow beyond the size of content and it's not easy to fix for me (and I feel tired of this popup)
this.dims(this.computePopupDims());
});
}
margin() {
return this.popupMargin;
}
ensureDimsLimit(dims) {
// boilerplate to make sure that popup visually fits
let { left, top, width, height } = dims;
let overflowBottom = top + height + 2 * this.margin() - window.innerHeight;
let overflowRight = left + width + 2 * this.margin() - window.innerWidth;
if (overflowRight > 0) {
width = Math.max(20 * this.margin(), Math.min(width - overflowRight, window.innerWidth - 2 * this.margin()));
}
if (overflowBottom > 0) {
height = Math.max(10 * this.margin(), Math.min(height - overflowBottom, window.innerHeight - 2 * this.margin()));
}
left = Math.max(left, this.margin());
top = Math.max(top, this.margin());
return { left, top, width, height }
}
dims(newDims) {
if (!this.popupDims) {
this.popupDims = {};
}
if (newDims) {
newDims = this.ensureDimsLimit(newDims);
for (const e of Object.keys(newDims)) {
let value = parseFloat(newDims[e]);
if (!isNaN(value)) {
$(this.popup).css(e, `${value}px`);
this.popupDims[e] = value;
}
}
}
return this.popupDims;
}
isFullscreen() {
return this.fullscreen;
}
maximize() {
this.fullscreen = true;
this.dims(this.computePopupDims());
if (this.innerElement) {
$(this.innerElement).css('width', '');
$(this.innerElement).css('height', '')
}
}
minimize() {
this.fullscreen = false;
this.dims(this.computePopupDims());
}
follow() {
const adaptChild = new ResizeObserver((_) => {
if (this.fullscreen) {return}
const width = this.innerElement?.scrollWidth || this.popup.scrollWidth;
const height = this.innerElement?.scrollHeight || this.popup.scrollHeight;
// we don't want to run this during something that we have caused, eg. dragging
if (!this.mouseDown) {
// extra-"future-proof" stuff: if somebody adds a margin to the popup, it would trigger a loop
if (Math.abs(this.dims().width - width) < 20 && Math.abs(this.dims().height - height) < 20) { return }
// if inner shrinks, follow
if (width < this.dims().width || height < this.dims().height) {
this.dims({ width, height });
}
// otherwise it may be complicated to find a generic situation, but we have the
// classic positionning procedure which works, so use it and ignore positionning
else {
const newDims = this.computePopupDims();
// a bit twisted/ad-hoc for card details, in the edge case where they are opened when collapsed then uncollapsed,
// not sure to understand why the sizing works differently that starting uncollapsed then doing the same sequence
this.dims(this.ensureDimsLimit({
top: this.dims().top,
left: this.dims().left,
width: Math.max(newDims.width, width),
height: Math.max(newDims.height, height)
}));
}
}
else {
const { width, height } = this.popup.getBoundingClientRect();
// only case when we bypass .dims(), to avoid loop
this.popupDims.width = width;
this.popupDims.height = height;
}
});
if (this.innerElement) {
adaptChild.observe(this.innerElement);
} else {
adaptChild.observe(this.popup);
}
}
currentZ(z = undefined) {
// relative, add a constant to be above root elements
if (z !== undefined) {
this.firstNode().style.zIndex = parseInt(z) + 10;
}
return parseInt(this.firstNode().style.zIndex) - 10;
}
// a bit complex...
toFront() {
this.currentZ(Math.max(...PopupComponent.stack.map(p => BlazeComponent.getComponentForElement(p.outerView.firstNode()).currentZ())) || 0 + 1);
}
toBack() {
this.currentZ(Math.min(...PopupComponent.stack.map(p => BlazeComponent.getComponentForElement(p.outerView.firstNode()).currentZ())) || 1 - 1);
}
events() {
// needs to be done at this level; "parent" is not a parent in DOM
let closeEvents = {};
this.closeDOMs?.forEach((e) => {
closeEvents[e] = (_) => {
this.parentComponent().destroy();
}
})
const miscEvents = {
'click .js-confirm'() {
this.data().afterConfirm?.call(this);
},
// bad heuristic but only for best-effort UI
'pointerdown .pop-over'() {
this.mouseDown = true;
},
'pointerup .pop-over'() {
this.mouseDown = false;
}
};
const movePopup = (event) => {
event.preventDefault();
$(event.target).addClass('is-active');
const deltaHandleX = this.dims().left - event.clientX;
const deltaHandleY = this.dims().top - event.clientY;
const onPointerMove = (e) => {
this.dims(this.ensureDimsLimit({ left: e.clientX + deltaHandleX, top: e.clientY + deltaHandleY, width: this.dims().width, height: this.dims().height }));
if (this.popup.scrollY) {
this.popup.scrollTo(0, 0);
}
};
const onPointerUp = (event) => {
$(document).off('pointermove', onPointerMove);
$(document).off('pointerup', onPointerUp);
$(event.target).removeClass('is-active');
};
if (Utils.shouldIgnorePointer(event)) {
onPointerUp(event);
return;
}
$(document).on('pointermove', onPointerMove);
$(document).on('pointerup', onPointerUp);
};
// We do not manage dragging without our own header
const handleDOM = this.data().handleDOM;
if (this.data().showHeader) {
const handleSelector = Utils.isMiniScreen() ? '.js-popup-drag-handle' : '.header-title';
miscEvents[`pointerdown ${handleSelector}`] = (e) => movePopup(e);
}
if (handleDOM) {
miscEvents[`pointerdown ${handleDOM}`] = (e) => movePopup(e);
}
return super.events().concat(closeEvents).concat(miscEvents);
}
computeMaxDims() {
// Get size of inner content, even if it overflows
const content = this.find('.content');
let popupHeight = content.scrollHeight;
let popupWidth = content.scrollWidth;
if (this.data().showHeader) {
const headerRect = this.find('.header');
popupHeight += headerRect.scrollHeight;
popupWidth = Math.max(popupWidth, headerRect.scrollWidth)
}
return { width: Math.max(popupWidth, $(this.popup).width()), height: Math.max(popupHeight, $(this.popup).height()) };
}
placeOnSingleDimension(elementLength, openerPos, openerLength, maxLength, biases, n) {
// avoid too much recursion if no solution
if (!n) {
n = 0;
}
if (n >= 5) {
// if we exhausted a bias, remove it
n = 0;
biases.pop();
if (biases.length === 0) {
return -1;
}
} else {
n += 1;
}
if (!biases?.length) {
const cut = maxLength / 3;
if (openerPos < cut) {
// Corresponds to the default ordering: if element is close to the axe's start,
// try to put the popup after it; then to overlap; and give up otherwise.
biases = [PopupBias.After, PopupBias.Overlap]
}
else if (openerPos > 2 * cut) {
// Same idea if popup is close to the end
biases = [PopupBias.Before, PopupBias.Overlap]
}
else {
// If in the middle, try to overlap: choosing between start or end, even for
// default, is too arbitrary; a custom order can be passed in argument.
biases = [PopupBias.Overlap]
}
}
// Remove the first element and get it
const bias = biases.splice(0, 1)[0];
let factor;
const openerRef = openerPos + openerLength / 2;
if (bias === PopupBias.Before) {
factor = 1;
}
else if (bias === PopupBias.Overlap) {
factor = openerRef / maxLength;
}
else {
factor = 0;
}
let candidatePos = openerRef - elementLength * factor;
const deltaMax = candidatePos + elementLength - maxLength;
if (candidatePos < 0 || deltaMax > 0) {
if (deltaMax <= 2 * this.margin()) {
// if this is just a matter of margin, try again
// useful for (literal) corner cases
biases = [bias].concat(biases);
openerPos -= 5;
}
if (biases.length === 0) {
// we could have returned candidate position even if the size is too large, so
// that the caller can choose, but it means more computations and edge cases...
// any negative means fullscreen overall as the caller will take the maximum between
// margin and candidate.
return -1;
}
return this.placeOnSingleDimension(elementLength, openerPos, openerLength, maxLength, biases, n);
}
return candidatePos;
}
computePopupDims() {
if (!this.isRendered?.()) {
return;
}
// Coordinates of opener related to viewport
let { x: parentX, y: parentY } = this.nonPlaceholderOpener.getBoundingClientRect();
let { height: parentHeight, width: parentWidth } = this.nonPlaceholderOpener.getBoundingClientRect();
// Initial dimensions scaled to the viewport, if it has changed
let popupHeight = window.innerHeight * this.initialHeightRatio;
let popupWidth = window.innerWidth * this.initialWidthRatio;
if (this.fullscreen || Utils.isMiniScreen() && popupWidth >= 4 * window.innerWidth / 5 && popupHeight >= 4 * window.innerHeight / 5) {
// Go fullscreen!
popupWidth = window.innerWidth;
// Avoid address bar, let a bit of margin to scroll
popupHeight = 4 * window.innerHeight / 5;
return ({
width: window.innerWidth,
height: window.innerHeight,
left: 0,
top: 0,
// When a popup content is removed (ie, when the user press the "back" button),
// we need to wait for the container translation to end before removing the
// actual DOM element. For that purpose we use the undocumented `_uihooks` API.
Popup.template.onRendered(() => {
const container = this.find('.content-container');
container._uihooks = {
removeElement(node) {
$(node).addClass('no-height');
$(container).one(CSSEvents.transitionend, () => {
node.parentNode.removeChild(node);
});
} else {
// Current viewport dimensions
let maxHeight = window.innerHeight - this.margin() * 2;
let maxWidth = window.innerWidth - this.margin() * 2;
let biasX, biasY;
if (Utils.isMiniScreen()) {
// On mobile I found that being able to close a popup really close from where it has been clicked
// is comfortable; so given that the close button is top-right, we prefer the position of
// popup being right-bottom, when possible. We then try every position, rather than choosing
// relatively to the relative position of opener in viewport
biasX = [PopupBias.Before, PopupBias.Overlap, PopupBias.After];
biasY = [PopupBias.After, PopupBias.Overlap, PopupBias.Before];
}
const candidateX = this.placeOnSingleDimension(popupWidth, parentX, parentWidth, maxWidth, biasX);
const candidateY = this.placeOnSingleDimension(popupHeight, parentY, parentHeight, maxHeight, biasY);
// Reasonable defaults that can be overriden by CSS later: popups are tall, try to fit the reste
// of the screen starting from parent element, or full screen if element if not fitting
return ({
width: popupWidth,
height: popupHeight,
left: candidateX,
top: candidateY,
});
}
}
}
class PopupComponent extends BlazeComponent {
static stack = [];
// good enough as long as few occurences of such cases
static multipleBlacklist = ["cardDetails"];
// to provide compatibility with Popup.open().
static open(args) {
const openerView = Blaze.getView(args.openerElement);
if (!openerView) {
console.warn(`no parent found for popup ${args.name}, attaching to body: this should not happen`);
}
// render ourselves; everything is automatically managed from that moment, we just added
// a level of indirection but this will not interfere with data
const popup = new PopupComponent();
Blaze.renderWithData(
popup.renderComponent(BlazeComponent.currentComponent()),
args,
args.openerElement,
null,
openerView
);
return popup;
}
static destroy() {
PopupComponent.stack.at(-1)?.destroy();
}
static findParentPopup(element) {
return BlazeComponent.getComponentForElement($(element).closest('.pop-over')[0]);
}
static toFront(event) {
const popup = PopupComponent.findParentPopup(event.target)
popup?.toFront();
return popup;
}
static toBack(event) {
const popup = PopupComponent.findParentPopup(event.target);
popup?.toBack();
return popup;
}
static maximize(event) {
const popup = PopupComponent.findParentPopup(event.target);
popup?.toFront();
popup?.maximize();
return popup;
}
static minimize(event) {
const popup = PopupComponent.findParentPopup(event.target);
popup?.minimize();
return popup;
}
getOpenerElement(view) {
// Look for the first parent view whose first DOM element is not virtually us
const firstNode = $(view.firstNode());
// The goal is to have the best chances to get the element whose size and pos
// are relevant; e.g. when clicking on a date on a minicard, we don't wan't
// the opener to be set to the minicard.
// In order to work in general, we need to take special situations into account,
// e.g. the placeholder is isolated, or does not have previous node, and so on.
// In general we prefer previous node, then next, then any displayed sibling,
// then the parent, and so on.
let candidates = [];
if (!firstNode.hasClass(this.popupPlaceholderClass())) {
candidates.push(firstNode);
}
candidates = candidates.concat([firstNode.prev(), firstNode.next()]);
const otherSiblings = Array.from(firstNode.siblings()).filter(e => !candidates.includes(e));
for (const cand of candidates.concat(otherSiblings)) {
const displayCSS = cand?.css("display");
if (displayCSS && displayCSS !== "none") {
return cand[0];
}
}
return this.getOpenerElement(view.parentView);
}
getParentData(view) {;
let data;
// ⚠️ node can be a text node
while (view.firstNode?.()?.classList?.contains(this.popupPlaceholderClass())) {
view = view.parentView;
data = Blaze.getData(view);
}
// This is VERY IMPORTANT to get data like this and not with templateInstance.data,
// because this form is reactive. So all inner popups have reactive data, which is nice
return data;
}
onCreated() {
// #FIXME prevent secondary popups to open
// Special "magic number" case: never render, for any reason, the same card
// const maybeID = this.parentComponent?.()?.data?.()?._id;
// if (maybeID && PopupComponent.stack.find(e => e.parentComponent().data?.()?._id === maybeID)) {
// this.destroy();
// return;
// }
// do not render a template multiple times
const existing = PopupComponent.stack.find((e) => (e.name == this.data().name));
if (existing && PopupComponent.multipleBlacklist.indexOf(this.data().name)) {
// ⚠️ is there a default better than another? I feel that closing existing
// popup is not bad in general because having the same button for open and close
// is common
if (PopupComponent.multipleBlacklist.includes(existing.name)) {
existing.destroy();
}
// but is could also be re-rendering, eg
// existing.render();
return;
}
// All of this, except name, is optional. The rest is provided "just in case", for convenience (hopefully)
//
// - name is the name of a template to render inside the popup (to the detriment of its size) or the contrary
// - showHeader can be turned off if the inner content always have a header with buttons and so on
// - title is shown when header is shown
// - miscOptions is for compatibility
// - closeVar is an optional string representing a Session variable: if set, the popup reactively closes when the variable changes and set the variable to null on close
// - closeDOMs can be used alternatively; it is an array of "<event> <selector>" to listen that closes the popup.
// if header is shown, closing the popup is already managed. selector is relative to the inner template (same as its event map)
// - followDOM is an element whose dimension will serve as reference so that popup can react to inner changes; works only with inline styles (otherwise we probably would need IntersectionObserver-like stuff, async etc)
// - handleDOM is an element who can be clicked to move popup
// it is useful when the content can be redimensionned/moved by code or user; we still manage events, resizes etc
// but allow inner elements or handles to do it (and we adapt).
const data = this.data();
this.popupArgs = {
name: data.name,
showHeader: data.showHeader ?? true,
title: data.title,
openerElement: data.openerElement,
closeDOMs: data.closeDOMs,
followDOM: data.followDOM,
handleDOM: data.handleDOM,
forceData: data.miscOptions?.dataContextIfCurrentDataIsUndefined,
afterConfirm: data.miscOptions?.afterConfirm,
}
this.name = this.data().name;
this.innerTemplate = Template[this.name];
this.innerComponent = BlazeComponent.getComponent(this.name);
this.outerComponent = BlazeComponent.getComponent('popupDetached');
if (!(this.innerComponent || this.innerTemplate)) {
throw new Error(`template and/or component ${this.name} not found`);
}
// If arg is not set, must be closed manually by calling destroy()
if (this.popupArgs.closeVar) {
this.closeInitialValue = Session.get(this.data().closeVar);
if (!this.closeInitialValue === undefined) {
this.autorun(() => {
if (Session.get(this.data().closeVar) !== this.closeInitialValue) {
this.onDestroyed();
}
});
}
}
}
popupPlaceholderClass() {
return "popup-placeholder";
}
render() {
const oldOuterView = this.outerView;
// see below for comments
this.outerView = Blaze.renderWithData(
// data is passed through the parent relationship
// we need to render it again to keep events in sync with inner popup
this.outerComponent.renderComponent(this.component()),
this.popupArgs,
document.body,
null,
this.openerView
);
this.innerView = Blaze.renderWithData(
// the template to render: either the content is a BlazeComponent or a regular template
// if a BlazeComponent, render it as a template first
this.innerComponent?.renderComponent?.(this.component()) || this.innerTemplate,
// dataContext used for rendering: each time we go find data, because it is non-reactive
() => (this.popupArgs.forceData || this.getParentData(this.currentView)),
// DOM parent: ask to the detached popup, will be inserted at the last child
this.outerView.firstNode()?.getElementsByClassName('content')?.[0] || document.body,
// "stop" DOM element; we don't use
null,
// important: this is the Blaze.View object which will be set as `parentView` of
// the rendered view. we set it as the parent view, so that the detached popup
// can interact with its "parent" without being a child of it, and without
// manipulating DOM directly.
this.openerView
);
if (oldOuterView) {
Blaze.remove(oldOuterView);
}
}
onRendered() {
if (this.detached) {return}
// Use plain Blaze stuff to be able to render all templates, but use components when available/relevant
this.currentView = Blaze.currentView || Blaze.getView(this.component().firstNode());
// Placement will be related to the opener (usually clicked element)
// But template data and view related to the opener are not the same:
// - view is probably outer, as is was already rendered on click
// - template data could be found with Template.parentData(n), but `n` can
// vary depending on context: using those methods feels more reliable for this use case
this.popupArgs.openerElement ??= this.getOpenerElement(this.currentView);
this.openerView = Blaze.getView(this.popupArgs.openerElement);
// With programmatic/click opening, we get the "real" opener; with dynamic
// templating we get the placeholder and need to go up to get a glimpse of
// the "real" opener size. It is quite imprecise in that case (maybe the
// interesting opener is a sibling, not an ancestor), but seems to do the job
// for now.
// Also it feels sane that inner content does not have a reference to
// a virtual placeholder.
const opener = this.popupArgs.openerElement;
let sizedOpener = opener;
if (opener.classList?.contains?.(this.popupPlaceholderClass())) {
sizedOpener = opener.parentNode;
}
this.popupArgs.nonPlaceholderOpener = sizedOpener;
PopupComponent.stack.push(this);
try {
this.render();
// Render above other popups by default
} catch(e) {
// If something went wrong during rendering, do not create
// "zombie" popups
console.error(`cannot render popup ${this.name}: ${e}`);
this.destroy();
}
}
destroy() {
this.detached = true;
if (!PopupComponent.stack.includes(this)) {
// Avoid loop destroy
return;
}
// Maybe overkill but may help to avoid leaking memory
// as programmatic rendering is less usual
for (const view of [this.innerView, this.currentView, this.outerView]) {
try {
Blaze.remove(view);
} catch {
console.warn(`A view failed to be removed: ${view}`)
}
}
this.innerComponent?.removeComponent?.();
this.outerComponent?.removeComponent?.();
this.removeComponent();
// not necesserly removed in order, e.g. multiple cards
PopupComponent.stack = PopupComponent.stack.filter(e => e !== this);
}
closeWithPlaceholder(parentElement) {
// adapted from https://stackoverflow.com/questions/52834774/dom-event-when-element-is-removed
// strangely, when opener is removed because of a reactive change, this component
// do not get any lifecycle hook called, so we need to bridge the gap. Simply
// "close" popup when placeholder is off-DOM.
while (parentElement.nodeType === Node.TEXT_NODE) {
parentElement = parentElement.parentElement;
}
const placeholder = parentElement.getElementsByClassName(this.popupPlaceholderClass());
if (!placeholder.length) {
return;
}
const observer = new MutationObserver(() => {
// DOM element being suppressed is reflected in array
if (placeholder.length === 0) {
this.destroy();
}
});
observer.observe(parentElement, {childList: true});
}
}
PopupComponent.register("popup");
PopupDetachedComponent.register('popupDetached');
export default PopupComponent;
},
};
});

View file

@ -0,0 +1,24 @@
.pop-over.js-pop-over(
class="{{#unless title}}miniprofile{{/unless}}"
class=currentBoard.colorClass
class="{{#unless title}}no-title{{/unless}}"
data-popup="{{popupName}}"
style="left:{{offset.left}}px; top:{{offset.top}}px;{{#if offset.maxHeight}} max-height:{{offset.maxHeight}}px;{{/if}}")
.header
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}")
i.fa.fa-caret-left
span.header-title= title
a.close-btn.js-close-pop-over
i.fa.fa-times-thin
.content-wrapper
//-
We display the all stack of popup content next to each other and move
the "window" by translating .content-container inside .content-wrapper.
.content-container(class="popup-container-depth-{{depth}}")
each stack
//-
XXX We need a better way to express the "is the last element" condition.
Hopefully the @last helper will come soon (or at least @index)
.content(class="{{#unless $eq popupName ../popupName}}no-height{{/unless}}")
+Template.dynamic(template=popupName data=dataContext)
.clearfix

View file

@ -3,7 +3,7 @@
height: 50px;
margin: auto;
text-align: center;
font-size: 10px;
}
.sk-spinner-wave div {
background-color: #333;

View file

@ -5,7 +5,7 @@ Template.notification.events({
const update = {};
const newReadValue = this.read ? null : Date.now();
update[`profile.notifications.${this.index}.read`] = newReadValue;
Users.update(Meteor.userId(), { $set: update }, (error, result) => {
if (error) {
console.error('Error updating notification:', error);
@ -34,13 +34,13 @@ Template.notification.helpers({
activityDate() {
const activity = this.activityData;
if (!activity || !activity.createdAt) return '';
const user = ReactiveCache.getCurrentUser();
if (!user) return '';
const dateFormat = user.getDateFormat ? user.getDateFormat() : 'L';
const timeFormat = user.getTimeFormat ? user.getTimeFormat() : 'LT';
return moment(activity.createdAt).format(`${dateFormat} ${timeFormat}`);
},
});

View file

@ -33,7 +33,7 @@ template(name='notificationIcon')
else if($in activityType 'createList' 'removeList' 'archivedList')
+listNotificationIcon
else if($in activityType 'importList')
else if($in activityType 'importList')
+listNotificationIcon
//- $in can only handle up to 3 cases so we have to break this case over 2 cases... use a simple template to keep it
//- DRY and consistant

View file

@ -1,40 +1,17 @@
.notifications-container {
/* absolute to render close to emoji and render on top,
"naturally" on top because no parent stacking context */
position: absolute;
right: 0;
top: 1.5lh;
background-color: #fafafa;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
border-radius: 2px;
color: #000;
z-index: 1;
#notifications {
position: relative;
}
#notifications .notifications-drawer-toggle {
display: block;
line-height: 28px;
color: #f2f2f2;
margin: 0 10px;
width: 28px;
height: 28px;
text-align: center;
border: 0;
padding: 0;
}
#notifications .notifications-drawer-toggle.alert {
background-color: #eb4646;
}
#notifications {
/* to position popup */
position: relative;
overflow: visible;
}
#notifications-drawer {
position: relative;
min-height: min-content;
height: fit-content;
max-height: 100vh;
z-index: 300;
width: max-content;
.fa {
color: #bcbcbc !important;
}
}
body.mobile-mode {
#notifications-drawer .header {
flex-direction: column;
}
}

View file

@ -1,7 +1,6 @@
template(name='notifications')
#notifications.board-header-btns.right
.notifications-container
if $.Session.get 'showNotificationsDrawer'
+notificationsDrawer(unreadNotifications=unreadNotifications)
a.notifications-drawer-toggle(class="{{#if $gt unreadNotifications 0}}alert{{/if}}" title="{{_ 'notifications'}}")
i.fa.fa-bell
if $.Session.get 'showNotificationsDrawer'
+notificationsDrawer(unreadNotifications=unreadNotifications)

View file

@ -1,16 +1,38 @@
section#notifications-drawer {
position: fixed;
top: 48px;
right: 0;
width: 400px;
background-color: #fafafa;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
border-radius: 2px;
max-height: calc(100vh - 28px - 36px);
color: #000;
padding-top: 36px;
}
section#notifications-drawer a:hover {
color: #2980b9 !important;
}
section#notifications-drawer .header {
display: flex;
justify-content: space-between;
padding: 0.5lh 2ch;
gap: 0.5lh;
align-items: center;
section#notifications-drawer .header {
position: fixed;
top: 48px;
right: 0;
width: calc(400px - 32px);
padding: 8px 16px;
background: #ededed;
border-bottom: 1px solid #dbdbdb;
z-index: 2;
}
section#notifications-drawer .header .toggle-read {
section#notifications-drawer .header .notification-menu-toggle {
position: absolute;
left: 16px;
top: calc(50% - 12px);
font-size: 20px;
cursor: pointer;
color: #333;
line-height: 24px;
}
section#notifications-drawer .header .notification-menu-toggle:hover {
color: #2980b9;
}
section#notifications-drawer .header .notification-menu {
@ -66,13 +88,19 @@ section#notifications-drawer .header h5 {
margin: 0;
}
section#notifications-drawer .header .close {
display: flex;
position: absolute;
top: calc(50% - 12px);
right: 12px;
font-size: 24px;
height: 24px;
line-height: 24px;
opacity: 1;
}
section#notifications-drawer ul.notifications {
display: block;
padding: 0px 16px 0px 16px;
margin: 0;
height: fit-content;
display: flex;
flex-direction: column;
height: calc(100vh - 122px);
overflow-y: scroll;
}

View file

@ -3,7 +3,6 @@ template(name='notificationsDrawer')
.header
a.notification-menu-toggle
i.fa.fa-bars
//- #FIXME could be replaced by a popup to help placement ?
.notification-menu(class="{{#if $.Session.get 'showNotificationMenu'}}is-open{{/if}}")
.menu-section
a.menu-item(class="{{#unless $.Session.get 'showReadNotifications'}}selected{{/unless}}")
@ -45,10 +44,9 @@ template(name='notificationsDrawer')
span.menu-icon
i.fa.fa-trash
span {{_ 'delete-all-notifications'}}
if($gt unreadNotifications 0)
|(#{unreadNotifications}) {{_ 'notifications'}}
else
|0 {{_ 'notifications'}}
h5 {{_ 'notifications'}}
if($gt unreadNotifications 0)
|(#{unreadNotifications})
a.close
i.fa.fa-times-thin
ul.notifications

View file

@ -36,7 +36,7 @@ Template.notificationsDrawer.events({
},
'click .notification-menu .menu-item'(event) {
const target = event.currentTarget;
if (target.classList.contains('mark-all-read')) {
const notifications = ReactiveCache.getCurrentUser().profile.notifications;
for (const index in notifications) {

View file

@ -85,5 +85,4 @@ template(name="setCardActionsColorPopup")
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
.form-buttons
button.primary.confirm.js-submit {{_ 'save'}}
button.primary.confirm.js-submit {{_ 'save'}}

View file

@ -5,10 +5,10 @@ template(name="checklistActions")
select(id="check-action")
option(value="add") {{_'r-add'}}
option(value="remove") {{_'r-remove'}}
div.trigger-text
div.trigger-text
| {{_'r-checklist'}}
div.trigger-dropdown
input(id="checklist-name",type=text,placeholder="{{_'r-name'}}")
input(id="checklist-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-button.js-add-checklist-action.js-goto-rules
i.fa.fa-plus
@ -18,10 +18,10 @@ template(name="checklistActions")
select(id="checkall-action")
option(value="check") {{_'r-check-all'}}
option(value="uncheck") {{_'r-uncheck-all'}}
div.trigger-text
div.trigger-text
| {{_'r-items-check'}}
div.trigger-dropdown
input(id="checklist-name2",type=text,placeholder="{{_'r-name'}}")
input(id="checklist-name2",type=text,placeholder="{{_'r-name'}}")
div.trigger-button.js-add-checkall-action.js-goto-rules
i.fa.fa-plus
@ -32,32 +32,32 @@ template(name="checklistActions")
select(id="check-item-action")
option(value="check") {{_'r-check'}}
option(value="uncheck") {{_'r-uncheck'}}
div.trigger-text
div.trigger-text
| {{_'r-item'}}
div.trigger-dropdown
input(id="checkitem-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
div.trigger-text
| {{_'r-of-checklist'}}
div.trigger-dropdown
input(id="checklist-name3",type=text,placeholder="{{_'r-name'}}")
input(id="checklist-name3",type=text,placeholder="{{_'r-name'}}")
div.trigger-button.js-add-check-item-action.js-goto-rules
i.fa.fa-plus
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-add-checklist'}}
div.trigger-dropdown
input(id="checklist-name-3",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
div.trigger-text
| {{_'r-with-items'}}
div.trigger-dropdown
input(id="checklist-items",type=text,placeholder="{{_'r-items-list'}}")
input(id="checklist-items",type=text,placeholder="{{_'r-items-list'}}")
div.trigger-button.js-add-checklist-items-action.js-goto-rules
i.fa.fa-plus
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-checklist-note'}}

View file

@ -6,6 +6,6 @@ template(name="mailActions")
div.trigger-dropdown-mail
input(id="email-to",type=text,placeholder="{{_'r-to'}}")
input(id="email-subject",type=text,placeholder="{{_'r-subject'}}")
textarea(id="email-msg")
textarea(id="email-msg")
div.trigger-button.trigger-button-email.js-mail-action.js-goto-rules
i.fa.fa-plus

View file

@ -10,14 +10,14 @@ template(name="ruleDetails")
| {{_ 'r-trigger'}}
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
= trigger
h4
h4
| {{_ 'r-action'}}
div.trigger-item
div.trigger-content
div.trigger-text
= action
div.trigger-text
= action
div.rules-back
button.js-goback
i.fa.fa-arrow-left

View file

@ -80,7 +80,7 @@
.triggers-content .triggers-body .triggers-side-menu {
background-color: #f7f7f7;
border: 1px solid #f0f0f0;
border-radius: 0.4ch;
border-radius: 4px;
height: intrinsic;
box-shadow: inset -1px -1px 3px rgba(0,0,0,0.05);
}
@ -89,7 +89,7 @@
width: 50px;
height: 50px;
text-align: center;
font-size: 25px;
position: relative;
}
.triggers-content .triggers-body .triggers-side-menu ul li i {
@ -112,7 +112,7 @@
width: 95%;
}
.triggers-content .triggers-body .triggers-side-menu ul li a span {
font-size: 13px;
}
.triggers-content .triggers-body .triggers-main-body {
padding: 0.1em 1em;
@ -134,15 +134,15 @@
left: 10px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-text {
font-size: 16px;
display: inline-block;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-inline-button {
font-size: 16px;
display: inline;
padding: 6px;
border: 1px solid #eee;
border-radius: 0.4ch;
border-radius: 4px;
box-shadow: inset -1px -1px 3px rgba(0,0,0,0.05);
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-inline-button:hover,
@ -179,10 +179,10 @@
width: 30px;
height: 30px;
border: 1px solid #eee;
border-radius: 0.4ch;
border-radius: 4px;
box-shadow: inset -1px -1px 3px rgba(0,0,0,0.05);
text-align: center;
font-size: 20px;
right: 10px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button i {
@ -206,7 +206,7 @@
top: unset;
position: unset;
transform: unset;
font-size: 16px;
width: auto;
padding-left: 10px;
padding-right: 10px;

View file

@ -7,7 +7,7 @@ template(name="rulesList")
ul.rules-list
each rules
li.rules-lists-item
p
p
= title
div.rules-btns-group
button.js-goto-details

View file

@ -1,22 +1,22 @@
template(name="boardTriggers")
div.trigger-item#trigger-two
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-a-card'}}
div.trigger-inline-button.js-open-card-title-popup
div.trigger-inline-button.js-open-card-title-popup
i.fa.fa-search
div.trigger-text
div.trigger-text
| {{_'r-is'}}
div.trigger-text
div.trigger-text
| {{_'r-added-to'}}
div.trigger-text
div.trigger-text
| {{_'r-list'}}
div.trigger-dropdown
input(id="create-list-name",type=text,placeholder="{{_'r-list-name'}}")
div.trigger-text
div.trigger-text
| {{_'r-in-swimlane'}}
div.trigger-dropdown
input(id="create-swimlane-name",type=text,placeholder="{{_'r-swimlane-name'}}")
input(id="create-swimlane-name",type=text,placeholder="{{_'r-swimlane-name'}}")
div.trigger-button.trigger-button-person.js-show-user-field
i.fa.fa-user
div.user-details.hide-element
@ -29,11 +29,11 @@ template(name="boardTriggers")
div.trigger-item#trigger-three
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-a-card'}}
div.trigger-inline-button.js-open-card-title-popup
div.trigger-inline-button.js-open-card-title-popup
i.fa.fa-search
div.trigger-text
div.trigger-text
| {{_'r-is-moved'}}
div.trigger-button.trigger-button-person.js-show-user-field
i.fa.fa-user
@ -47,24 +47,24 @@ template(name="boardTriggers")
div.trigger-item#trigger-four
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-a-card'}}
div.trigger-inline-button.js-open-card-title-popup
div.trigger-inline-button.js-open-card-title-popup
i.fa.fa-search
div.trigger-text
div.trigger-text
| {{_'r-is'}}
div.trigger-dropdown
select(id="move-action")
option(value="moved-to") {{_'r-moved-to'}}
option(value="moved-from") {{_'r-moved-from'}}
div.trigger-text
div.trigger-text
| {{_'r-list'}}
div.trigger-dropdown
input(id="move-list-name",type=text,placeholder="{{_'r-list-name'}}")
div.trigger-text
div.trigger-text
| {{_'r-in-swimlane'}}
div.trigger-dropdown
input(id="create-swimlane-name-2",type=text,placeholder="{{_'r-swimlane-name'}}")
input(id="create-swimlane-name-2",type=text,placeholder="{{_'r-swimlane-name'}}")
div.trigger-button.trigger-button-person.js-show-user-field
i.fa.fa-user
div.user-details.hide-element
@ -77,11 +77,11 @@ template(name="boardTriggers")
div.trigger-item#trigger-five
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-a-card'}}
div.trigger-inline-button.js-open-card-title-popup
div.trigger-inline-button.js-open-card-title-popup
i.fa.fa-search
div.trigger-text
div.trigger-text
| {{_'r-is'}}
div.trigger-dropdown
select(id="arch-action")
@ -99,7 +99,7 @@ template(name="boardTriggers")
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-board-note'}}
template(name="boardCardTitlePopup")

View file

@ -1,13 +1,13 @@
template(name="checklistTriggers")
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-a-checklist'}}
div.trigger-dropdown
select(id="gen-check-action")
option(value="created") {{_'r-added-to'}}
option(value="removed") {{_'r-removed-from'}}
div.trigger-text
div.trigger-text
| {{_'r-a-card'}}
div.trigger-button.trigger-button-person.js-show-user-field
i.fa.fa-user
@ -22,17 +22,17 @@ template(name="checklistTriggers")
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-the-checklist'}}
div.trigger-dropdown
input(id="check-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
input(id="check-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
| {{_'r-is'}}
div.trigger-dropdown
select(id="spec-check-action")
option(value="created") {{_'r-added-to'}}
option(value="removed") {{_'r-removed-from'}}
div.trigger-text
div.trigger-text
| {{_'r-a-card'}}
div.trigger-button.trigger-button-person.js-show-user-field
i.fa.fa-user
@ -46,7 +46,7 @@ template(name="checklistTriggers")
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-a-checklist'}}
div.trigger-dropdown
select(id="gen-comp-check-action")
@ -64,11 +64,11 @@ template(name="checklistTriggers")
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-the-checklist'}}
div.trigger-dropdown
input(id="spec-comp-check-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
input(id="spec-comp-check-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
| {{_'r-is'}}
div.trigger-dropdown
select(id="spec-comp-check-action")
@ -86,7 +86,7 @@ template(name="checklistTriggers")
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-a-item'}}
div.trigger-dropdown
select(id="check-item-gen-action")
@ -104,11 +104,11 @@ template(name="checklistTriggers")
div.trigger-item
div.trigger-content
div.trigger-text
div.trigger-text
| {{_'r-when-the-item'}}
div.trigger-dropdown
input(id="check-item-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
input(id="check-item-name",type=text,placeholder="{{_'r-name'}}")
div.trigger-text
| {{_'r-is'}}
div.trigger-dropdown
select(id="check-item-spec-action")

View file

@ -6,12 +6,12 @@ template(name="attachmentSettings")
label {{_ 'writable-path'}}
input.wekan-form-control#filesystem-path(type="text" value="{{filesystemPath}}" readonly)
small.form-text.text-muted {{_ 'filesystem-path-description'}}
.form-group
label {{_ 'attachments-path'}}
input.wekan-form-control#attachments-path(type="text" value="{{attachmentsPath}}" readonly)
small.form-text.text-muted {{_ 'attachments-path-description'}}
.form-group
label {{_ 'avatars-path'}}
input.wekan-form-control#avatars-path(type="text" value="{{avatarsPath}}" readonly)
@ -30,42 +30,42 @@ template(name="attachmentSettings")
label {{_ 's3-enabled'}}
input.wekan-form-control#s3-enabled(type="checkbox" checked="{{s3Enabled}}" disabled)
small.form-text.text-muted {{_ 's3-enabled-description'}}
.form-group
label {{_ 's3-endpoint'}}
input.wekan-form-control#s3-endpoint(type="text" value="{{s3Endpoint}}" readonly)
small.form-text.text-muted {{_ 's3-endpoint-description'}}
.form-group
label {{_ 's3-bucket'}}
input.wekan-form-control#s3-bucket(type="text" value="{{s3Bucket}}" readonly)
small.form-text.text-muted {{_ 's3-bucket-description'}}
.form-group
label {{_ 's3-region'}}
input.wekan-form-control#s3-region(type="text" value="{{s3Region}}" readonly)
small.form-text.text-muted {{_ 's3-region-description'}}
.form-group
label {{_ 's3-access-key'}}
input.wekan-form-control#s3-access-key(type="text" placeholder="{{_ 's3-access-key-placeholder'}}" readonly)
small.form-text.text-muted {{_ 's3-access-key-description'}}
.form-group
label {{_ 's3-secret-key'}}
input.wekan-form-control#s3-secret-key(type="password" placeholder="{{_ 's3-secret-key-placeholder'}}")
small.form-text.text-muted {{_ 's3-secret-key-description'}}
.form-group
label {{_ 's3-ssl-enabled'}}
input.wekan-form-control#s3-ssl-enabled(type="checkbox" checked="{{s3SslEnabled}}" disabled)
small.form-text.text-muted {{_ 's3-ssl-enabled-description'}}
.form-group
label {{_ 's3-port'}}
input.wekan-form-control#s3-port(type="number" value="{{s3Port}}" readonly)
small.form-text.text-muted {{_ 's3-port-description'}}
.form-group
button.js-test-s3-connection.btn.btn-secondary {{_ 'test-s3-connection'}}
button.js-save-s3-settings.btn.btn-primary {{_ 'save-s3-settings'}}
@ -73,19 +73,19 @@ template(name="attachmentSettings")
template(name="storageSettings")
.storage-settings
h3 {{_ 'attachment-storage-configuration'}}
.storage-config-section
h4 {{_ 'filesystem-storage'}}
.form-group
label {{_ 'writable-path'}}
input.wekan-form-control#filesystem-path(type="text" value="{{filesystemPath}}" readonly)
small.form-text.text-muted {{_ 'filesystem-path-description'}}
.form-group
label {{_ 'attachments-path'}}
input.wekan-form-control#attachments-path(type="text" value="{{attachmentsPath}}" readonly)
small.form-text.text-muted {{_ 'attachments-path-description'}}
.form-group
label {{_ 'avatars-path'}}
input.wekan-form-control#avatars-path(type="text" value="{{avatarsPath}}" readonly)
@ -104,37 +104,37 @@ template(name="storageSettings")
label {{_ 's3-enabled'}}
input.wekan-form-control#s3-enabled(type="checkbox" checked="{{s3Enabled}}" disabled)
small.form-text.text-muted {{_ 's3-enabled-description'}}
.form-group
label {{_ 's3-endpoint'}}
input.wekan-form-control#s3-endpoint(type="text" value="{{s3Endpoint}}" readonly)
small.form-text.text-muted {{_ 's3-endpoint-description'}}
.form-group
label {{_ 's3-bucket'}}
input.wekan-form-control#s3-bucket(type="text" value="{{s3Bucket}}" readonly)
small.form-text.text-muted {{_ 's3-bucket-description'}}
.form-group
label {{_ 's3-region'}}
input.wekan-form-control#s3-region(type="text" value="{{s3Region}}" readonly)
small.form-text.text-muted {{_ 's3-region-description'}}
.form-group
label {{_ 's3-access-key'}}
input.wekan-form-control#s3-access-key(type="text" placeholder="{{_ 's3-access-key-placeholder'}}" readonly)
small.form-text.text-muted {{_ 's3-access-key-description'}}
.form-group
label {{_ 's3-secret-key'}}
input.wekan-form-control#s3-secret-key(type="password" placeholder="{{_ 's3-secret-key-placeholder'}}")
small.form-text.text-muted {{_ 's3-secret-key-description'}}
.form-group
label {{_ 's3-ssl-enabled'}}
input.wekan-form-control#s3-ssl-enabled(type="checkbox" checked="{{s3SslEnabled}}" disabled)
small.form-text.text-muted {{_ 's3-ssl-enabled-description'}}
.form-group
label {{_ 's3-port'}}
input.wekan-form-control#s3-port(type="number" value="{{s3Port}}" readonly)
@ -147,18 +147,18 @@ template(name="storageSettings")
template(name="attachmentMigration")
.attachment-migration
h3 {{_ 'attachment-migration'}}
.migration-controls
.form-group
label {{_ 'migration-batch-size'}}
input.wekan-form-control#migration-batch-size(type="number" value="{{migrationBatchSize}}" min="1" max="100")
small.form-text.text-muted {{_ 'migration-batch-size-description'}}
.form-group
label {{_ 'migration-delay-ms'}}
input.wekan-form-control#migration-delay-ms(type="number" value="{{migrationDelayMs}}" min="100" max="10000")
small.form-text.text-muted {{_ 'migration-delay-ms-description'}}
.form-group
label {{_ 'migration-cpu-threshold'}}
input.wekan-form-control#migration-cpu-threshold(type="number" value="{{migrationCpuThreshold}}" min="10" max="90")
@ -169,7 +169,7 @@ template(name="attachmentMigration")
button.js-migrate-all-to-filesystem.btn.btn-primary {{_ 'migrate-all-to-filesystem'}}
button.js-migrate-all-to-gridfs.btn.btn-primary {{_ 'migrate-all-to-gridfs'}}
button.js-migrate-all-to-s3.btn.btn-primary {{_ 'migrate-all-to-s3'}}
.migration-controls
button.js-pause-migration.btn.btn-warning {{_ 'pause-migration'}}
button.js-resume-migration.btn.btn-success {{_ 'resume-migration'}}
@ -180,7 +180,7 @@ template(name="attachmentMigration")
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.migration-stats
.stat-item
span.label {{_ 'total-attachments'}}:
@ -203,7 +203,7 @@ template(name="attachmentMigration")
template(name="attachmentMonitoring")
.attachment-monitoring
h3 {{_ 'attachment-monitoring'}}
.monitoring-stats
.stats-grid
.stat-card

View file

@ -19,7 +19,7 @@
.migration-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
@ -35,8 +35,8 @@
.migration-controls .btn {
padding: 8px 16px;
border-radius: 0.4ch;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
@ -72,7 +72,7 @@
.migration-progress {
background: #f8f9fa;
padding: 20px;
border-radius: 0.8ch;
border-radius: 8px;
margin-bottom: 30px;
border-left: 4px solid #667eea;
}
@ -128,20 +128,20 @@
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;
}
@ -154,7 +154,7 @@
.migration-status {
text-align: center;
color: #333;
font-size: 16px;
background-color: #e3f2fd;
padding: 12px 16px;
border-radius: 6px;
@ -173,7 +173,7 @@
.migration-steps h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 20px;
font-weight: 600;
}
@ -181,7 +181,7 @@
max-height: 400px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 0.8ch;
border-radius: 8px;
}
.migration-step {
@ -210,7 +210,7 @@
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4);
}
70% {
box-shadow: 0 0 0 0.5rem rgba(102, 126, 234, 0);
box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
@ -225,7 +225,7 @@
.step-icon {
margin-right: 12px;
font-size: 18px;
width: 24px;
text-align: center;
}
@ -249,13 +249,13 @@
.step-name {
font-weight: 600;
color: #333;
font-size: 14px;
margin-bottom: 2px;
}
.step-description {
color: #666;
font-size: 12px;
line-height: 1.3;
}
@ -265,7 +265,7 @@
}
.step-progress .progress-text {
font-size: 12px;
font-weight: 600;
}
@ -302,7 +302,7 @@
.jobs-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
@ -313,8 +313,8 @@
.jobs-controls .btn {
padding: 8px 16px;
border-radius: 0.4ch;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
@ -337,7 +337,7 @@
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 0.8ch;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
@ -356,18 +356,18 @@
.table th {
font-weight: 600;
color: #333;
font-size: 14px;
}
.table td {
font-size: 14px;
color: #666;
}
.status-badge {
padding: 4px 8px;
border-radius: 0.4ch;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
@ -404,7 +404,7 @@
.btn-group .btn {
padding: 4px 8px;
font-size: 12px;
border-radius: 3px;
border: none;
cursor: pointer;
@ -452,7 +452,7 @@
.add-job-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
@ -474,15 +474,15 @@
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 0.4ch;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s ease;
}
@ -504,8 +504,8 @@
.form-actions .btn {
padding: 10px 20px;
border-radius: 0.4ch;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
@ -546,7 +546,7 @@
.board-operations-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
@ -562,8 +562,8 @@
.board-operations-controls .btn {
padding: 8px 16px;
border-radius: 0.4ch;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
@ -590,7 +590,7 @@
.board-operations-stats {
background: #f8f9fa;
padding: 20px;
border-radius: 0.8ch;
border-radius: 8px;
margin-bottom: 30px;
border-left: 4px solid #667eea;
}
@ -606,14 +606,14 @@
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #667eea;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
@ -622,7 +622,7 @@
.system-resources {
background: #f8f9fa;
padding: 20px;
border-radius: 0.8ch;
border-radius: 8px;
margin-bottom: 30px;
border-left: 4px solid #28a745;
}
@ -641,7 +641,7 @@
min-width: 120px;
font-weight: 600;
color: #333;
font-size: 14px;
}
.resource-bar {
@ -674,7 +674,7 @@
text-align: right;
font-weight: 600;
color: #333;
font-size: 14px;
}
.board-operations-search {
@ -683,7 +683,7 @@
.search-box {
position: relative;
max-width: 50vw;
max-width: 400px;
}
.search-box .form-control {
@ -696,12 +696,12 @@
top: 50%;
transform: translateY(-50%);
color: #999;
font-size: 16px;
}
.board-operations-list {
background: white;
border-radius: 0.8ch;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
@ -718,13 +718,13 @@
.operations-header h3 {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 600;
}
.pagination-info {
color: #666;
font-size: 14px;
}
.operations-table {
@ -751,11 +751,11 @@
.board-id {
font-family: monospace;
font-size: 12px;
color: #666;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 0.4ch;
border-radius: 4px;
display: inline-block;
}
@ -776,19 +776,19 @@
flex: 1;
height: 8px;
background-color: #e0e0e0;
border-radius: 0.4ch;
border-radius: 4px;
overflow: hidden;
}
.progress-container .progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 0.4ch;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-container .progress-text {
font-size: 12px;
font-weight: 600;
color: #667eea;
min-width: 35px;
@ -806,8 +806,8 @@
.pagination .btn {
padding: 6px 12px;
border-radius: 0.4ch;
font-size: 12px;
border-radius: 4px;
border: 1px solid #ddd;
background: white;
color: #333;
@ -827,7 +827,7 @@
.page-info {
color: #666;
font-size: 14px;
}
/* Responsive design */
@ -846,7 +846,7 @@
}
.table {
font-size: 12px;
}
.table th,
@ -878,7 +878,7 @@
#cron-setting .progress {
height: 30px;
background-color: #e9ecef;
border-radius: 0.4ch;
border-radius: 4px;
overflow: visible;
margin-bottom: 5px;
max-width: calc(100% - 40px);
@ -893,7 +893,7 @@
font-size: 14px;
text-align: center;
transition: width 0.3s ease;
border-radius: 0.4ch;
border-radius: 4px;
}
#cron-setting .progress-text {

View file

@ -8,7 +8,7 @@ template(name="cronSettings")
option(value="0") 0 - {{_ 'all-migrations'}}
each migrationStepsWithIndex
option(value="{{index}}") {{index}} - {{name}}
.form-group
label {{_ 'migration-status'}}
.status-indicator
@ -18,16 +18,16 @@ template(name="cronSettings")
.step-counter
| Step {{migrationCurrentStepNum}}/{{migrationTotalSteps}}
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.progress-text
| {{migrationProgress}}% {{_ 'complete'}}
.form-group
button.js-start-migration.btn.btn-primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start'}}
button.js-pause-migration.btn.btn-warning(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause'}}
button.js-stop-migration.btn.btn-danger(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop'}}
.form-group.migration-errors-section
h4 {{_ 'cron-migration-errors'}}
if hasErrors
@ -49,7 +49,7 @@ template(name="cronSettings")
else
.no-errors
| {{_ 'cron-no-errors'}}
li
h3 {{_ 'board-operations'}}
.form-group
@ -57,7 +57,7 @@ template(name="cronSettings")
button.js-schedule-board-cleanup.btn.btn-primary {{_ 'schedule-board-cleanup'}}
button.js-schedule-board-archive.btn.btn-warning {{_ 'schedule-board-archive'}}
button.js-schedule-board-backup.btn.btn-info {{_ 'schedule-board-backup'}}
li
h3 {{_ 'cron-jobs'}}
.form-group
@ -90,22 +90,22 @@ template(name="cronMigrations")
button.btn.btn-danger.js-stop-all-migrations
i.fa.fa-stop
| {{_ 'stop-all-migrations'}}
.migration-progress
.progress-overview
.progress-bar
.progress-fill(style="width: {{migrationProgress}}%")
.progress-fill(style="width: {{migrationProgress}}%")
.progress-text {{migrationProgress}}%
.progress-label {{_ 'overall-progress'}}
.current-step
i.fa.fa-cog
| {{migrationCurrentStep}}
.migration-status
i.fa.fa-info-circle
| {{migrationStatus}}
.migration-steps
h3 {{_ 'migration-steps'}}
.steps-list
@ -149,7 +149,7 @@ template(name="cronBoardOperations")
button.btn.btn-info.js-force-board-scan
i.fa.fa-search
| {{_ 'force-board-scan'}}
.board-operations-stats
.stats-grid
.stat-item
@ -176,7 +176,7 @@ template(name="cronBoardOperations")
.stat-item
.stat-value {{boardMigrationStats.isScanning}}
.stat-label {{_ 'scanning-status'}}
.system-resources
.resource-item
.resource-label {{_ 'cpu-usage'}}
@ -191,18 +191,18 @@ template(name="cronBoardOperations")
.resource-item
.resource-label {{_ 'cpu-cores'}}
.resource-value {{systemResources.cpuCores}}
.board-operations-search
.search-box
input.form-control.js-search-board-operations(type="text" placeholder="{{_ 'search-boards-or-operations'}}")
i.fa.fa-search.search-icon
.board-operations-list
.operations-header
h3 {{_ 'board-operations'}} ({{pagination.total}})
.pagination-info
| {{_ 'showing'}} {{pagination.start}} - {{pagination.end}} {{_ 'of'}} {{pagination.total}}
.operations-table
table.table.table-striped
thead
@ -242,7 +242,7 @@ template(name="cronBoardOperations")
i.fa.fa-stop
button.btn.btn-sm.btn-info.js-view-details(data-operation="{{id}}")
i.fa.fa-info-circle
.pagination
if pagination.hasPrev
button.btn.btn-sm.btn-default.js-prev-page
@ -265,7 +265,7 @@ template(name="cronJobs")
button.btn.btn-success.js-refresh-jobs
i.fa.fa-refresh
| {{_ 'refresh'}}
.jobs-list
table.table.table-striped
thead
@ -304,17 +304,17 @@ template(name="cronAddJob")
h2
i.fa.fa-plus
| {{_ 'add-cron-job'}}
.add-job-form
form.js-add-cron-job-form
.form-group
label(for="job-name") {{_ 'job-name'}}
input.form-control#job-name(type="text" name="name" required)
.form-group
label(for="job-description") {{_ 'job-description'}}
textarea.form-control#job-description(name="description" rows="3")
.form-group
label(for="job-schedule") {{_ 'schedule'}}
select.form-control#job-schedule(name="schedule")
@ -326,11 +326,11 @@ template(name="cronAddJob")
option(value="every 6 hours") {{_ 'every-6-hours'}}
option(value="every 1 day") {{_ 'every-1-day'}}
option(value="once") {{_ 'run-once'}}
.form-group
label(for="job-weight") {{_ 'weight'}}
input.form-control#job-weight(type="number" name="weight" value="1" min="1" max="10")
.form-actions
button.btn.btn-primary(type="submit")
i.fa.fa-plus

View file

@ -15,7 +15,7 @@
.migration-progress-modal {
background: white;
border-radius: 0.8ch;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
@ -46,13 +46,13 @@
.migration-progress-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.migration-progress-close {
cursor: pointer;
font-size: 16px;
opacity: 0.8;
transition: opacity 0.2s ease;
}
@ -73,7 +73,7 @@
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 14px;
}
.migration-progress-overall-bar {
@ -110,7 +110,7 @@
.migration-progress-overall-percentage {
text-align: right;
font-size: 12px;
color: #666;
font-weight: 600;
}
@ -123,12 +123,12 @@
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 14px;
}
.migration-progress-step-bar {
background: #e9ecef;
border-radius: 0.8ch;
border-radius: 8px;
height: 8px;
overflow: hidden;
margin-bottom: 5px;
@ -137,13 +137,13 @@
.migration-progress-step-fill {
background: linear-gradient(90deg, #007bff, #0056b3);
height: 100%;
border-radius: 0.8ch;
border-radius: 8px;
transition: width 0.3s ease;
}
.migration-progress-step-percentage {
text-align: right;
font-size: 12px;
color: #666;
font-weight: 600;
}
@ -160,12 +160,12 @@
font-weight: 600;
color: #333;
margin-bottom: 5px;
font-size: 13px;
}
.migration-progress-status-text {
color: #555;
font-size: 14px;
line-height: 1.4;
}
@ -181,12 +181,12 @@
font-weight: 600;
color: #1976d2;
margin-bottom: 5px;
font-size: 13px;
}
.migration-progress-details-text {
color: #1565c0;
font-size: 13px;
line-height: 1.4;
}
@ -199,7 +199,7 @@
.migration-progress-note {
text-align: center;
color: #666;
font-size: 13px;
font-style: italic;
}
@ -219,7 +219,7 @@
}
.migration-progress-title {
font-size: 16px;
}
}
@ -285,7 +285,7 @@
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 0.4ch;
border-radius: 4px;
}
.migration-spinner {

View file

@ -8,7 +8,7 @@ template(name="migrationProgress")
| {{_ 'migration-progress-title'}}
.migration-progress-close.js-close-migration-progress
i.fa.fa-times-thin
.migration-progress-content
.migration-progress-overall
.migration-progress-overall-label
@ -17,7 +17,7 @@ template(name="migrationProgress")
.migration-progress-overall-fill(style="{{progressBarStyle}}")
.migration-progress-overall-percentage
| {{overallProgress}}%
.migration-progress-current-step
.migration-progress-step-label
| {{_ 'migration-progress-current-step'}}: {{stepNameFormatted}}
@ -25,20 +25,20 @@ template(name="migrationProgress")
.migration-progress-step-fill(style="{{stepProgressBarStyle}}")
.migration-progress-step-percentage
| {{stepProgress}}%
.migration-progress-status
.migration-progress-status-label
| {{_ 'migration-progress-status'}}:
.migration-progress-status-text
| {{stepStatus}}
if stepDetailsFormatted
.migration-progress-details
.migration-progress-details-label
| {{_ 'migration-progress-details'}}:
.migration-progress-details-text
| {{stepDetailsFormatted}}
.migration-progress-footer
.migration-progress-note
| {{_ 'migration-progress-note'}}

View file

@ -79,7 +79,7 @@ class MigrationProgressManager {
isMigrating.set(false);
migrationProgress.set(100);
migrationStatus.set('Migration completed successfully!');
// Clear step details after a delay
setTimeout(() => {
migrationStepName.set('');
@ -178,7 +178,7 @@ Template.migrationProgress.helpers({
stepNameFormatted() {
const stepName = migrationStepName.get();
if (!stepName) return '';
// Convert snake_case to Title Case
return stepName
.split('_')
@ -189,7 +189,7 @@ Template.migrationProgress.helpers({
stepDetailsFormatted() {
const details = migrationStepDetails.get();
if (!details) return '';
const formatted = [];
for (const [key, value] of Object.entries(details)) {
const formattedKey = key
@ -199,7 +199,7 @@ Template.migrationProgress.helpers({
.replace(/^\w/, c => c.toUpperCase());
formatted.push(`${formattedKey}: ${value}`);
}
return formatted.join(', ');
}
});

View file

@ -39,6 +39,9 @@ table tr:nth-child(even) {
.ext-box button {
min-width: 90px;
}
.content-wrapper {
margin-top: 10px;
}
.buttonsContainer {
display: flex;
}
@ -161,7 +164,7 @@ table td:first-child {
background-color: #27ae60;
color: white;
padding: 10px 20px;
border-radius: 0.4ch;
border-radius: 4px;
z-index: 9999;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
animation: fadeOut 3s ease-in forwards;

View file

@ -512,7 +512,8 @@ template(name="newUserPopup")
span.error.hide.username-taken
| {{_ 'error-username-taken'}}
//if isLdap
// input.js-profile-username(type="text" value=user.username readonly)
//
input.js-profile-username(type="text" value=user.username readonly)
//else
input.js-profile-username(type="text" value="" required)
label
@ -523,7 +524,8 @@ template(name="newUserPopup")
span.error.hide.email-taken
| {{_ 'error-email-taken'}}
//if isLdap
// input.js-profile-email(type="email" value="{{user.emails.[0].address}}" readonly)
//
input.js-profile-email(type="email" value="{{user.emails.[0].address}}" readonly)
//else
input.js-profile-email(type="email" value="" required)
label
@ -596,10 +598,14 @@ template(name="settingsOrgPopup")
// It's not yet possible to impersonate organization. Only impersonate user,
// because that changes current user ID. What would it mean in practice
// to impersonate organization?
// li
// a.impersonate-org
// i.fa.fa-user
// | {{_ 'impersonate-org'}}
//
li
//
a.impersonate-org
//
i.fa.fa-user
//
| {{_ 'impersonate-org'}}
//
//
@ -640,8 +646,10 @@ template(name="settingsUserPopup")
// - wekan/client/components/settings/peopleBody.jade deleteButton
// - wekan/client/components/settings/peopleBody.js deleteButton
// - wekan/client/components/sidebar/sidebar.js Popup.afterConfirm('removeMember'
// that does now remove member from board, card members and assignees correctly,
// but that should be used to remove user from all boards similarly
//
that does now remove member from board, card members and assignees correctly,
//
but that should be used to remove user from all boards similarly
// - wekan/models/users.js Delete is not enabled
template(name="lockedUsersGeneral")

View file

@ -9,32 +9,19 @@
display: flex;
height: 100%;
}
.setting-detail {
display: flex;
flex-direction: column;
flex: 1;
justify-content: stretch;
align-items: stretch;
}
.setting-content {
color: #727479;
background: #dedede;
overflow-y: scroll;
}
.setting-content .wekan-form-control:not([type="radio"]) {
display: flex;
width: 100%;
height: 100%;
position: absolute;
}
.setting-content .content-title {
font-size: 1.3em;
padding: 0.5lh 1ch;
font-size: clamp(16px, 3.5vw, 22px);
}
.setting-content .content-body {
display: flex;
padding-top: 2vh;
height: 100%;
gap: 1.3vw;
}
@ -42,15 +29,8 @@
background-color: #f7f7f7;
border: 1px solid #f0f0f0;
border-radius: 0.5vw;
min-width: fit-content;
width: min(250px, 32vw);
box-shadow: inset -0.2vh -0.2vh 0.4vh rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
padding-right: 2ch;
overflow-y: scroll;
min-height: 20vh;
flex-grow: 1;
}
.setting-content .content-body .side-menu ul li {
margin: 0.2vh 0.3vw;
@ -67,10 +47,12 @@
padding: 1.3vh 0 1.3vh 1.3vw;
width: 95%;
}
.setting-content .content-body .side-menu ul li a span {
font-size: 13px;
}
.setting-content .content-body .side-menu ul li a i {
margin-right: 20px;
}
.setting-content .content-body .main-body {
-webkit-user-select: text;
-moz-user-select: text;
@ -80,9 +62,9 @@
overflow-x: scroll !important;
overflow-y: scroll !important;
scrollbar-gutter: stable;
flex-grow: 5;
padding-right: 2ch;
padding-bottom: 1lh;
/* Force horizontal scrollbar to always be visible */
min-width: 100%;
width: 100%;
}
/* Ensure scrollbars are always visible with proper styling for all admin pages */
@ -135,6 +117,24 @@
padding-bottom: 50px;
}
/* Admin panel buttons should use theme darker color */
.setting-content .content-body .main-body .setting-detail button.btn {
background: #005377;
color: #fff;
border: none;
}
.setting-content .content-body .main-body .setting-detail button.btn:hover,
.setting-content .content-body .main-body .setting-detail button.btn:focus {
background: #004766;
color: #fff;
}
.setting-content .content-body .main-body .setting-detail button.btn:active {
background: #01628c;
color: #fff;
}
/* Force horizontal scrollbar to always be visible at bottom */
.setting-content .content-body .main-body {
position: relative;
@ -144,6 +144,7 @@
.setting-content .content-body .main-body::after {
content: '';
display: block;
width: 100vw;
height: 1px;
position: absolute;
bottom: 0;
@ -154,7 +155,7 @@
padding: 0.5rem 0.5rem;
}
.setting-content .content-body .main-body ul li a .is-checked {
border-bottom: 0.2ch solid #3cb500;
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
}
/* Grey checkmarks when grey icons setting is enabled */

View file

@ -107,7 +107,7 @@ template(name="setting")
a.js-setting-menu(data-id="cron-settings")
span.emoji-icon
i.fa.fa-clock
| {{_ 'cron'}}
| {{_ 'migrations'}}
.main-body
if isLoading
+spinner
@ -119,12 +119,12 @@ template(name="setting")
label {{_ 'writable-path'}}
input.wekan-form-control#filesystem-path(type="text" value="{{filesystemPath}}" readonly)
small.form-text.text-muted {{_ 'filesystem-path-description'}}
.form-group
label {{_ 'attachments-path'}}
input.wekan-form-control#attachments-path(type="text" value="{{attachmentsPath}}" readonly)
small.form-text.text-muted {{_ 'attachments-path-description'}}
.form-group
label {{_ 'avatars-path'}}
input.wekan-form-control#avatars-path(type="text" value="{{avatarsPath}}" readonly)
@ -143,49 +143,55 @@ template(name="setting")
label {{_ 's3-enabled'}}
input.wekan-form-control#s3-enabled(type="checkbox" checked="{{s3Enabled}}" disabled)
small.form-text.text-muted {{_ 's3-enabled-description'}}
.form-group
label {{_ 's3-endpoint'}}
input.wekan-form-control#s3-endpoint(type="text" value="{{s3Endpoint}}" readonly)
small.form-text.text-muted {{_ 's3-endpoint-description'}}
.form-group
label {{_ 's3-bucket'}}
input.wekan-form-control#s3-bucket(type="text" value="{{s3Bucket}}" readonly)
small.form-text.text-muted {{_ 's3-bucket-description'}}
.form-group
label {{_ 's3-region'}}
input.wekan-form-control#s3-region(type="text" value="{{s3Region}}" readonly)
small.form-text.text-muted {{_ 's3-region-description'}}
.form-group
label {{_ 's3-access-key'}}
input.wekan-form-control#s3-access-key(type="text" placeholder="{{_ 's3-access-key-placeholder'}}" readonly)
small.form-text.text-muted {{_ 's3-access-key-description'}}
.form-group
label {{_ 's3-secret-key'}}
input.wekan-form-control#s3-secret-key(type="password" placeholder="{{_ 's3-secret-key-placeholder'}}")
small.form-text.text-muted {{_ 's3-secret-key-description'}}
.form-group
label {{_ 's3-ssl-enabled'}}
input.wekan-form-control#s3-ssl-enabled(type="checkbox" checked="{{s3SslEnabled}}" disabled)
small.form-text.text-muted {{_ 's3-ssl-enabled-description'}}
.form-group
label {{_ 's3-port'}}
input.wekan-form-control#s3-port(type="number" value="{{s3Port}}" readonly)
small.form-text.text-muted {{_ 's3-port-description'}}
.form-group
button.js-test-s3-connection.btn.btn-secondary {{_ 'test-s3-connection'}}
button.js-save-s3-settings.btn.btn-primary {{_ 'save-s3-settings'}}
else if isCronSettings
ul#cron-setting.setting-detail
li
h3 {{_ 'cron-migrations'}}
h3 {{_ 'migrations'}}
.form-group
label {{_ 'select-migration'}}
select.js-migration-select.wekan-form-control
option(value="0") 0 - {{_ 'all-migrations'}}
each migrationStepsWithIndex
option(value="{{index}}") {{index}} - {{name}}
.form-group
label {{_ 'migration-status'}}
.status-indicator
@ -193,43 +199,45 @@ template(name="setting")
span.status-value
if isMigrating
i.fa.fa-spinner.fa-spin(style="margin-right: 8px;")
| {{#if isMigrating}}{{migrationStatus}}{{else}}{{_ 'idle'}}{{/if}}
else if isUpdatingMigrationDropdown
i.fa.fa-spinner.fa-spin(style="margin-right: 8px;")
| {{#if isMigrating}}{{migrationStatusLine}}{{else}}{{migrationStatus}}{{/if}}
if isMigrating
.progress-section
if migrationCurrentAction
.step-counter
| {{migrationCurrentAction}}
else if migrationJobTotalSteps
.step-counter
| Step {{migrationJobStepNum}}/{{migrationJobTotalSteps}}
else if migrationTotalSteps
.step-counter
| Migration {{migrationCurrentStepNum}}/{{migrationTotalSteps}}
else
.step-counter
i.fa.fa-spinner.fa-spin(style="margin-right: 8px;")
| Calculating migration scope...
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.progress-bar(role="progressbar" style="width: {{migrationJobProgress}}%" aria-valuenow="{{migrationJobProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationJobProgress}}%
.progress-text
| {{migrationProgress}}% {{_ 'complete'}}
| {{migrationJobProgress}}% {{_ 'complete'}}
.migration-details
if migrationJobTotalSteps
if migrationJobTotalSteps gt 1
.detail-line
| Job step: {{migrationJobStepNum}}/{{migrationJobTotalSteps}}
if migrationEtaSeconds
.detail-line
| ETA: {{formatDurationSeconds migrationEtaSeconds}}
if migrationElapsedSeconds
.detail-line
| Elapsed: {{formatDurationSeconds migrationElapsedSeconds}}
.form-group
button.js-start-all-migrations.btn.btn-primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start-all-migrations'}}
button.js-pause-all-migrations.btn.btn-warning(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause-all-migrations'}}
button.js-stop-all-migrations.btn.btn-danger(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop-all-migrations'}}
li
h3 {{_ 'board-operations'}}
.form-group
label {{_ 'scheduled-board-operations'}}
button.js-schedule-board-cleanup.btn.btn-primary {{_ 'schedule-board-cleanup'}}
button.js-schedule-board-archive.btn.btn-warning {{_ 'schedule-board-archive'}}
button.js-schedule-board-backup.btn.btn-info {{_ 'schedule-board-backup'}}
li
h3 {{_ 'cron-jobs'}}
.form-group
label {{_ 'active-cron-jobs'}}
each cronJobs
.job-item
.job-info
.job-name {{name}}
.job-schedule {{schedule}}
.job-status {{status}}
.job-actions
button.js-pause-job.btn.btn-sm.btn-warning(data-job-id="{{_id}}") {{_ 'pause'}}
button.js-delete-job.btn.btn-sm.btn-danger(data-job-id="{{_id}}") {{_ 'delete'}}
.add-job-section
button.js-add-cron-job.btn.btn-success {{_ 'add-cron-job'}}
button.js-start-migration.primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start-all-migrations'}}
button.js-pause-all-migrations.primary(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause-all-migrations'}}
button.js-stop-all-migrations.primary(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop-all-migrations'}}
else if isGeneralSetting
+general
else if isEmailSetting
@ -285,39 +293,69 @@ template(name="general")
template(name='email')
ul#email-setting.setting-detail
//if isSandstorm
// li.smtp-form
// .title {{_ 'smtp-host'}}
// .description {{_ 'smtp-host-description'}}
// .form-group
// input.wekan-form-control#mail-server-host(type="text", placeholder="smtp.domain.com" value="{{currentSetting.mailServer.host}}")
// li.smtp-form
// .title {{_ 'smtp-port'}}
// .description {{_ 'smtp-port-description'}}
// .form-group
// input.wekan-form-control#mail-server-port(type="text", placeholder="25" value="{{currentSetting.mailServer.port}}")
// li.smtp-form
// .title {{_ 'smtp-username'}}
// .form-group
// input.wekan-form-control#mail-server-username(type="text", placeholder="{{_ 'username'}}" value="{{currentSetting.mailServer.username}}")
// li.smtp-form
// .title {{_ 'smtp-password'}}
// .form-group
// input.wekan-form-control#mail-server-password(type="password", placeholder="{{_ 'password'}}" value="")
// li.smtp-form
// .title {{_ 'smtp-tls'}}
// .form-group
// a.flex.js-toggle-tls
// .materialCheckBox#mail-server-tls(class="{{#if currentSetting.mailServer.enableTLS}}is-checked{{/if}}")
//
// span {{_ 'smtp-tls-description'}}
li.smtp-form
//
// li.smtp-form
// .title {{_ 'send-from'}}
// .form-group
// input.wekan-form-control#mail-server-from(type="email", placeholder="no-reply@domain.com" value="{{currentSetting.mailServer.from}}")
.title {{_ 'smtp-host'}}
//
// li
// button.js-save.primary {{_ 'save'}}
.description {{_ 'smtp-host-description'}}
//
.form-group
//
input.wekan-form-control#mail-server-host(type="text", placeholder="smtp.domain.com" value="{{currentSetting.mailServer.host}}")
//
li.smtp-form
//
.title {{_ 'smtp-port'}}
//
.description {{_ 'smtp-port-description'}}
//
.form-group
//
input.wekan-form-control#mail-server-port(type="text", placeholder="25" value="{{currentSetting.mailServer.port}}")
//
li.smtp-form
//
.title {{_ 'smtp-username'}}
//
.form-group
//
input.wekan-form-control#mail-server-username(type="text", placeholder="{{_ 'username'}}" value="{{currentSetting.mailServer.username}}")
//
li.smtp-form
//
.title {{_ 'smtp-password'}}
//
.form-group
//
input.wekan-form-control#mail-server-password(type="password", placeholder="{{_ 'password'}}" value="")
//
li.smtp-form
//
.title {{_ 'smtp-tls'}}
//
.form-group
//
a.flex.js-toggle-tls
//
.materialCheckBox#mail-server-tls(class="{{#if currentSetting.mailServer.enableTLS}}is-checked{{/if}}")
//
//
span {{_ 'smtp-tls-description'}}
//
//
li.smtp-form
//
.title {{_ 'send-from'}}
//
.form-group
//
input.wekan-form-control#mail-server-from(type="email", placeholder="no-reply@domain.com" value="{{currentSetting.mailServer.from}}")
//
//
li
//
button.js-save.primary {{_ 'save'}}
li
button.js-send-smtp-test-email.primary {{_ 'send-smtp-test'}}

View file

@ -2,15 +2,23 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { ALLOWED_WAIT_SPINNERS } from '/config/const';
import LockoutSettings from '/models/lockoutSettings';
import {
cronMigrationProgress,
cronMigrationStatus,
cronMigrationCurrentStep,
cronMigrationSteps,
cronIsMigrating,
import {
cronMigrationProgress,
cronMigrationStatus,
cronMigrationCurrentStep,
cronMigrationSteps,
cronIsMigrating,
cronJobs,
cronMigrationCurrentStepNum,
cronMigrationTotalSteps
cronMigrationTotalSteps,
cronMigrationCurrentAction,
cronMigrationJobProgress,
cronMigrationJobStepNum,
cronMigrationJobTotalSteps,
cronMigrationEtaSeconds,
cronMigrationElapsedSeconds,
cronMigrationCurrentNumber,
cronMigrationCurrentName
} from '/imports/cronMigrationClient';
@ -39,7 +47,7 @@ BlazeComponent.extendComponent({
Meteor.subscribe('accessibilitySettings');
Meteor.subscribe('globalwebhooks');
Meteor.subscribe('lockoutSettings');
// Poll for migration errors
this.errorPollInterval = Meteor.setInterval(() => {
if (this.cronSettings.get()) {
@ -62,7 +70,7 @@ BlazeComponent.extendComponent({
setError(error) {
this.error.set(error);
},
// Template helpers moved to BlazeComponent - using different names to avoid conflicts
isGeneralSetting() {
return this.generalSetting && this.generalSetting.get();
@ -102,41 +110,41 @@ BlazeComponent.extendComponent({
filesystemPath() {
return process.env.WRITABLE_PATH || '/data';
},
attachmentsPath() {
const writablePath = process.env.WRITABLE_PATH || '/data';
return `${writablePath}/attachments`;
},
avatarsPath() {
const writablePath = process.env.WRITABLE_PATH || '/data';
return `${writablePath}/avatars`;
},
gridfsEnabled() {
return process.env.GRIDFS_ENABLED === 'true';
},
s3Enabled() {
return process.env.S3_ENABLED === 'true';
},
s3Endpoint() {
return process.env.S3_ENDPOINT || '';
},
s3Bucket() {
return process.env.S3_BUCKET || '';
},
s3Region() {
return process.env.S3_REGION || '';
},
s3SslEnabled() {
return process.env.S3_SSL_ENABLED === 'true';
},
s3Port() {
return process.env.S3_PORT || 443;
},
@ -145,23 +153,23 @@ BlazeComponent.extendComponent({
migrationStatus() {
return cronMigrationStatus.get() || TAPi18n.__('idle');
},
migrationProgress() {
return cronMigrationProgress.get() || 0;
},
migrationCurrentStep() {
return cronMigrationCurrentStep.get() || '';
},
isMigrating() {
return cronIsMigrating.get() || false;
},
migrationSteps() {
return cronMigrationSteps.get() || [];
},
migrationStepsWithIndex() {
const steps = cronMigrationSteps.get() || [];
return steps.map((step, idx) => ({
@ -169,11 +177,15 @@ BlazeComponent.extendComponent({
index: idx + 1
}));
},
cronJobs() {
return cronJobs.get() || [];
},
isCronJobPaused(status) {
return status === 'paused';
},
migrationCurrentStepNum() {
return cronMigrationCurrentStepNum.get() || 0;
},
@ -182,6 +194,52 @@ BlazeComponent.extendComponent({
return cronMigrationTotalSteps.get() || 0;
},
migrationCurrentAction() {
return cronMigrationCurrentAction.get() || '';
},
migrationJobProgress() {
return cronMigrationJobProgress.get() || 0;
},
migrationJobStepNum() {
return cronMigrationJobStepNum.get() || 0;
},
migrationJobTotalSteps() {
return cronMigrationJobTotalSteps.get() || 0;
},
migrationEtaSeconds() {
return cronMigrationEtaSeconds.get();
},
migrationElapsedSeconds() {
return cronMigrationElapsedSeconds.get();
},
migrationNumber() {
return cronMigrationCurrentNumber.get();
},
migrationName() {
return cronMigrationCurrentName.get() || '';
},
migrationStatusLine() {
const number = cronMigrationCurrentNumber.get();
const name = cronMigrationCurrentName.get();
if (number && name) {
return `${number} - ${name}`;
}
return this.migrationStatus();
},
isUpdatingMigrationDropdown() {
const status = this.migrationStatus();
return status && status.startsWith('Updating Select Migration dropdown menu');
},
migrationErrors() {
return this.migrationErrorsList ? this.migrationErrorsList.get() : [];
},
@ -196,6 +254,19 @@ BlazeComponent.extendComponent({
return moment(date).format('YYYY-MM-DD HH:mm:ss');
},
formatDurationSeconds(seconds) {
if (seconds === null || seconds === undefined) return '';
const total = Math.max(0, Math.floor(seconds));
const hrs = Math.floor(total / 3600);
const mins = Math.floor((total % 3600) / 60);
const secs = total % 60;
const parts = [];
if (hrs > 0) parts.push(String(hrs).padStart(2, '0'));
parts.push(String(mins).padStart(2, '0'));
parts.push(String(secs).padStart(2, '0'));
return parts.join(':');
},
setLoading(w) {
this.loading.set(w);
},
@ -240,8 +311,14 @@ BlazeComponent.extendComponent({
'click button.js-start-migration'(event) {
event.preventDefault();
this.setLoading(true);
cronIsMigrating.set(true);
cronMigrationStatus.set(TAPi18n.__('migration-starting'));
cronMigrationCurrentAction.set('');
cronMigrationJobProgress.set(0);
cronMigrationJobStepNum.set(0);
cronMigrationJobTotalSteps.set(0);
const selectedIndex = parseInt($('.js-migration-select').val() || '0', 10);
if (selectedIndex === 0) {
// Run all migrations
Meteor.call('cron.startAllMigrations', (error, result) => {
@ -258,6 +335,10 @@ BlazeComponent.extendComponent({
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
} else if (result && result.skipped) {
cronIsMigrating.set(false);
cronMigrationStatus.set(TAPi18n.__('migration-not-needed'));
alert(TAPi18n.__('migration-not-needed'));
} else {
alert(TAPi18n.__('migration-started'));
}
@ -265,9 +346,52 @@ BlazeComponent.extendComponent({
}
},
'click button.js-start-all-migrations'(event) {
event.preventDefault();
this.setLoading(true);
Meteor.call('cron.startAllMigrations', (error) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('migration-started'));
}
});
},
'click button.js-pause-all-migrations'(event) {
event.preventDefault();
this.setLoading(true);
Meteor.call('cron.pauseAllMigrations', (error) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-pause-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('migration-paused'));
}
});
},
'click button.js-stop-all-migrations'(event) {
event.preventDefault();
if (confirm(TAPi18n.__('migration-stop-confirm'))) {
this.setLoading(true);
Meteor.call('cron.stopAllMigrations', (error) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-stop-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('migration-stopped'));
}
});
}
},
'click button.js-pause-migration'(event) {
event.preventDefault();
this.setLoading(true);
cronIsMigrating.set(false);
cronMigrationStatus.set(TAPi18n.__('migration-pausing'));
Meteor.call('cron.pauseAllMigrations', (error, result) => {
this.setLoading(false);
if (error) {
@ -282,6 +406,12 @@ BlazeComponent.extendComponent({
event.preventDefault();
if (confirm(TAPi18n.__('migration-stop-confirm'))) {
this.setLoading(true);
cronIsMigrating.set(false);
cronMigrationStatus.set(TAPi18n.__('migration-stopping'));
cronMigrationCurrentAction.set('');
cronMigrationJobProgress.set(0);
cronMigrationJobStepNum.set(0);
cronMigrationJobTotalSteps.set(0);
Meteor.call('cron.stopAllMigrations', (error, result) => {
this.setLoading(false);
if (error) {
@ -293,29 +423,25 @@ BlazeComponent.extendComponent({
}
},
'click button.js-schedule-board-cleanup'(event) {
'click button.js-start-job'(event) {
event.preventDefault();
// Placeholder - board cleanup scheduling
alert(TAPi18n.__('board-cleanup-scheduled'));
},
'click button.js-schedule-board-archive'(event) {
event.preventDefault();
// Placeholder - board archive scheduling
alert(TAPi18n.__('board-archive-scheduled'));
},
'click button.js-schedule-board-backup'(event) {
event.preventDefault();
// Placeholder - board backup scheduling
alert(TAPi18n.__('board-backup-scheduled'));
const jobName = $(event.target).data('job-name');
this.setLoading(true);
Meteor.call('cron.startJob', jobName, (error) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('cron-job-start-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('cron-job-started'));
}
});
},
'click button.js-pause-job'(event) {
event.preventDefault();
const jobId = $(event.target).data('job-id');
const jobName = $(event.target).data('job-name');
this.setLoading(true);
Meteor.call('cron.pauseJob', jobId, (error, result) => {
Meteor.call('cron.pauseJob', jobName, (error) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('cron-job-pause-failed') + ': ' + error.reason);
@ -325,12 +451,26 @@ BlazeComponent.extendComponent({
});
},
'click button.js-resume-job'(event) {
event.preventDefault();
const jobName = $(event.target).data('job-name');
this.setLoading(true);
Meteor.call('cron.resumeJob', jobName, (error) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('cron-job-resume-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('cron-job-resumed'));
}
});
},
'click button.js-delete-job'(event) {
event.preventDefault();
const jobId = $(event.target).data('job-id');
const jobName = $(event.target).data('job-name');
if (confirm(TAPi18n.__('cron-job-delete-confirm'))) {
this.setLoading(true);
Meteor.call('cron.removeJob', jobId, (error, result) => {
Meteor.call('cron.removeJob', jobName, (error) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('cron-job-delete-failed') + ': ' + error.reason);
@ -429,7 +569,7 @@ BlazeComponent.extendComponent({
$('.side-menu li.active').removeClass('active');
target.parent().addClass('active');
const targetID = target.data('id');
// Reset all settings to false
this.forgotPasswordSetting.set(false);
this.generalSetting.set(false);
@ -442,7 +582,7 @@ BlazeComponent.extendComponent({
this.webhookSetting.set(false);
this.attachmentSettings.set(false);
this.cronSettings.set(false);
// Set the selected setting to true
if (targetID === 'registration-setting') {
this.generalSetting.set(true);
@ -847,7 +987,7 @@ BlazeComponent.extendComponent({
const content = $('#admin-accessibility-content')
.val()
.trim();
try {
AccessibilitySettings.update(AccessibilitySettings.findOne()._id, {
$set: {

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