Change list width by dragging between lists.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2025-10-14 09:36:11 +03:00
parent 0d9536e2f9
commit abad8cc4d5
7 changed files with 413 additions and 33 deletions

View file

@ -89,7 +89,9 @@ BlazeComponent.extendComponent({
// Check if board needs conversion (for old structure) // Check if board needs conversion (for old structure)
if (boardConverter.isBoardConverted(boardId)) { if (boardConverter.isBoardConverted(boardId)) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been converted, skipping conversion`); console.log(`Board ${boardId} has already been converted, skipping conversion`);
}
this.isBoardReady.set(true); this.isBoardReady.set(true);
} else { } else {
const needsConversion = boardConverter.needsConversion(boardId); const needsConversion = boardConverter.needsConversion(boardId);
@ -127,8 +129,10 @@ BlazeComponent.extendComponent({
if (error) { if (error) {
console.error('Failed to start background migration:', error); console.error('Failed to start background migration:', error);
} else { } else {
if (process.env.DEBUG === 'true') {
console.log('Background migration started for board:', boardId); console.log('Background migration started for board:', boardId);
} }
}
}); });
} catch (error) { } catch (error) {
console.error('Error starting background migration:', error); console.error('Error starting background migration:', error);
@ -139,7 +143,9 @@ BlazeComponent.extendComponent({
try { try {
// Check if board has already been migrated // Check if board has already been migrated
if (attachmentMigrationManager.isBoardMigrated(boardId)) { if (attachmentMigrationManager.isBoardMigrated(boardId)) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been migrated, skipping`); console.log(`Board ${boardId} has already been migrated, skipping`);
}
return; return;
} }
@ -147,13 +153,17 @@ BlazeComponent.extendComponent({
const unconvertedAttachments = attachmentMigrationManager.getUnconvertedAttachments(boardId); const unconvertedAttachments = attachmentMigrationManager.getUnconvertedAttachments(boardId);
if (unconvertedAttachments.length > 0) { if (unconvertedAttachments.length > 0) {
if (process.env.DEBUG === 'true') {
console.log(`Starting attachment migration for ${unconvertedAttachments.length} attachments in board ${boardId}`); console.log(`Starting attachment migration for ${unconvertedAttachments.length} attachments in board ${boardId}`);
}
await attachmentMigrationManager.startAttachmentMigration(boardId); await attachmentMigrationManager.startAttachmentMigration(boardId);
} else { } else {
// No attachments to migrate, mark board as migrated // No attachments to migrate, mark board as migrated
// This will be handled by the migration manager itself // This will be handled by the migration manager itself
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has no attachments to migrate`); console.log(`Board ${boardId} has no attachments to migrate`);
} }
}
} catch (error) { } catch (error) {
console.error('Error starting attachment migration:', error); console.error('Error starting attachment migration:', error);
} }
@ -622,14 +632,18 @@ BlazeComponent.extendComponent({
hasSwimlanes() { hasSwimlanes() {
const currentBoard = Utils.getCurrentBoard(); const currentBoard = Utils.getCurrentBoard();
if (!currentBoard) { if (!currentBoard) {
if (process.env.DEBUG === 'true') {
console.log('hasSwimlanes: No current board'); console.log('hasSwimlanes: No current board');
}
return false; return false;
} }
try { try {
const swimlanes = currentBoard.swimlanes(); const swimlanes = currentBoard.swimlanes();
const hasSwimlanes = swimlanes && swimlanes.length > 0; const hasSwimlanes = swimlanes && swimlanes.length > 0;
if (process.env.DEBUG === 'true') {
console.log('hasSwimlanes: Board has', swimlanes ? swimlanes.length : 0, 'swimlanes'); console.log('hasSwimlanes: Board has', swimlanes ? swimlanes.length : 0, 'swimlanes');
}
return hasSwimlanes; return hasSwimlanes;
} catch (error) { } catch (error) {
console.error('hasSwimlanes: Error getting swimlanes:', error); console.error('hasSwimlanes: Error getting swimlanes:', error);
@ -661,6 +675,7 @@ BlazeComponent.extendComponent({
const isMigrating = this.isMigrating.get(); const isMigrating = this.isMigrating.get();
const boardView = Utils.boardView(); const boardView = Utils.boardView();
if (process.env.DEBUG === 'true') {
console.log('=== BOARD DEBUG STATE ==='); console.log('=== BOARD DEBUG STATE ===');
console.log('currentBoardId:', currentBoardId); console.log('currentBoardId:', currentBoardId);
console.log('currentBoard:', !!currentBoard, currentBoard ? currentBoard.title : 'none'); console.log('currentBoard:', !!currentBoard, currentBoard ? currentBoard.title : 'none');
@ -669,6 +684,7 @@ BlazeComponent.extendComponent({
console.log('isMigrating:', isMigrating); console.log('isMigrating:', isMigrating);
console.log('boardView:', boardView); console.log('boardView:', boardView);
console.log('========================'); console.log('========================');
}
return { return {
currentBoardId, currentBoardId,

View file

@ -8,6 +8,219 @@
padding: 0; padding: 0;
float: left; float: left;
} }
/* List resize handle */
.list-resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 10;
background: transparent;
transition: background-color 0.2s ease;
border-radius: 2px;
}
.list-resize-handle:hover {
background: rgba(0, 123, 255, 0.4);
box-shadow: 0 0 4px rgba(0, 123, 255, 0.3);
}
.list-resize-handle:active {
background: rgba(0, 123, 255, 0.6);
box-shadow: 0 0 6px rgba(0, 123, 255, 0.4);
}
/* Show resize handle only on hover */
.list:hover .list-resize-handle {
background: rgba(0, 0, 0, 0.1);
}
.list:hover .list-resize-handle:hover {
background: rgba(0, 123, 255, 0.4);
box-shadow: 0 0 4px rgba(0, 123, 255, 0.3);
}
/* Add a subtle indicator line */
.list-resize-handle::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 20px;
background: rgba(0, 0, 0, 0.2);
border-radius: 1px;
opacity: 0;
transition: opacity 0.2s ease;
}
.list-resize-handle:hover::before {
opacity: 1;
}
/* Disable resize handle for collapsed lists and mobile view */
.list.list-collapsed .list-resize-handle,
.list.mobile-view .list-resize-handle {
display: none;
}
/* Disable resize handle for auto-width lists */
.list.list-auto-width .list-resize-handle {
display: none;
}
/* Visual feedback during resize */
.list.list-resizing {
transition: none !important;
box-shadow: 0 0 10px rgba(0, 123, 255, 0.3);
/* Ensure the list maintains its new width during resize */
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
/* Override any conflicting layout properties */
float: left !important;
display: block !important;
position: relative !important;
/* Force width to be respected */
width: var(--list-width, auto) !important;
min-width: var(--list-width, auto) !important;
max-width: var(--list-width, auto) !important;
}
body.list-resizing-active {
cursor: col-resize !important;
}
body.list-resizing-active * {
cursor: col-resize !important;
}
/* Ensure swimlane container doesn't interfere with list resizing */
.swimlane .list.list-resizing {
/* Override any swimlane flex properties */
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
/* Ensure width is respected */
width: var(--list-width, auto) !important;
min-width: var(--list-width, auto) !important;
max-width: var(--list-width, auto) !important;
}
/* More aggressive override for any container that might interfere */
.js-swimlane .list.list-resizing,
.dragscroll .list.list-resizing,
[id^="swimlane-"] .list.list-resizing {
/* Force the width to be applied */
width: var(--list-width, auto) !important;
min-width: var(--list-width, auto) !important;
max-width: var(--list-width, auto) !important;
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
float: left !important;
display: block !important;
}
/* Ensure the width persists after resize is complete */
.js-swimlane .list[style*="--list-width"],
.dragscroll .list[style*="--list-width"],
[id^="swimlane-"] .list[style*="--list-width"] {
/* Maintain the width after resize */
width: var(--list-width, auto) !important;
min-width: var(--list-width, auto) !important;
max-width: var(--list-width, auto) !important;
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
float: left !important;
display: block !important;
}
/* Ensure consistent header height for all lists */
.list-header {
/* Maintain consistent height and padding for all lists */
min-height: 2.5vh !important;
height: auto !important;
padding: 2.5vh 1.5vw 0.5vh !important;
/* Make sure the background covers the full height */
background-color: #e4e4e4 !important;
border-bottom: 0.8vh solid #e4e4e4 !important;
/* Use original display for consistent button positioning */
display: block !important;
position: relative !important;
/* Prevent vertical expansion but allow normal height */
overflow: hidden !important;
}
/* Ensure title text doesn't cause height changes for all lists */
.list-header .list-header-name {
/* Prevent text wrapping to maintain consistent height */
white-space: nowrap !important;
/* Truncate text with ellipsis if too long */
text-overflow: ellipsis !important;
/* Ensure proper line height */
line-height: 1.2 !important;
/* Ensure it doesn't overflow */
overflow: hidden !important;
/* Add margin to prevent overlap with buttons */
margin-right: 120px !important;
}
/* Position drag handle at top-right corner for ALL lists */
.list-header .list-header-handle {
/* Position at top-right corner, aligned with title text top */
position: absolute !important;
top: 2.5vh !important;
right: 1.5vw !important;
/* Ensure it's above other elements */
z-index: 15 !important;
/* Remove margin since it's absolutely positioned */
margin-right: 0 !important;
/* Ensure proper display */
display: inline-block !important;
}
/* Ensure buttons maintain original positioning */
.js-swimlane .list[style*="--list-width"] .list-header .list-header-plus-top,
.js-swimlane .list[style*="--list-width"] .list-header .js-collapse,
.js-swimlane .list[style*="--list-width"] .list-header .js-open-list-menu,
.dragscroll .list[style*="--list-width"] .list-header .list-header-plus-top,
.dragscroll .list[style*="--list-width"] .list-header .js-collapse,
.dragscroll .list[style*="--list-width"] .list-header .js-open-list-menu,
[id^="swimlane-"] .list[style*="--list-width"] .list-header .list-header-plus-top,
[id^="swimlane-"] .list[style*="--list-width"] .list-header .js-collapse,
[id^="swimlane-"] .list[style*="--list-width"] .list-header .js-open-list-menu {
/* Use original positioning to maintain layout */
position: relative !important;
/* Maintain original spacing */
margin-right: 15px !important;
/* Ensure proper display */
display: inline-block !important;
}
/* Ensure watch icon and card count maintain original positioning */
.js-swimlane .list[style*="--list-width"] .list-header .list-header-watch-icon,
.dragscroll .list[style*="--list-width"] .list-header .list-header-watch-icon,
[id^="swimlane-"] .list[style*="--list-width"] .list-header .list-header-watch-icon,
.js-swimlane .list[style*="--list-width"] .list-header .cardCount,
.dragscroll .list[style*="--list-width"] .list-header .cardCount,
[id^="swimlane-"] .list[style*="--list-width"] .list-header .cardCount {
/* Use original positioning to maintain layout */
position: relative !important;
/* Maintain original spacing */
margin-right: 15px !important;
/* Ensure proper display */
display: inline-block !important;
}
[id^="swimlane-"] .list:first-child { [id^="swimlane-"] .list:first-child {
min-width: 2.5vw; min-width: 2.5vw;
} }

View file

@ -4,6 +4,7 @@ template(name='list')
class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}") class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}")
+listHeader +listHeader
+listBody +listBody
.list-resize-handle.js-list-resize-handle
template(name='miniList') template(name='miniList')
a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}") a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")

View file

@ -24,6 +24,9 @@ BlazeComponent.extendComponent({
onRendered() { onRendered() {
const boardComponent = this.parentComponent().parentComponent(); const boardComponent = this.parentComponent().parentComponent();
// Initialize list resize functionality
this.initializeListResize();
const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)'; const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
const $cards = this.$('.js-minicards'); const $cards = this.$('.js-minicards');
@ -212,6 +215,146 @@ BlazeComponent.extendComponent({
const list = Template.currentData(); const list = Template.currentData();
return user.isAutoWidth(list.boardId); return user.isAutoWidth(list.boardId);
}, },
initializeListResize() {
const list = Template.currentData();
const $list = this.$('.js-list');
const $resizeHandle = this.$('.js-list-resize-handle');
// Only enable resize for non-collapsed, non-auto-width lists
if (list.collapsed || this.autoWidth()) {
$resizeHandle.hide();
return;
}
let isResizing = false;
let startX = 0;
let startWidth = 0;
let minWidth = 100; // Minimum width as defined in the existing code
let maxWidth = this.listConstraint() || 1000; // Use constraint as max width
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();
};
const doResize = (e) => {
if (!isResizing) return;
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
const deltaX = currentX - startX;
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + deltaX));
// Apply the new width immediately for real-time feedback using CSS custom properties
$list[0].style.setProperty('--list-width', `${newWidth}px`);
$list.css({
'width': `${newWidth}px`,
'min-width': `${newWidth}px`,
'max-width': `${newWidth}px`,
'flex': 'none',
'flex-basis': 'auto',
'flex-grow': '0',
'flex-shrink': '0'
});
// Debug: log the width change
if (process.env.DEBUG === 'true') {
console.log(`Resizing list to ${newWidth}px`);
}
e.preventDefault();
};
const stopResize = (e) => {
if (!isResizing) return;
isResizing = false;
// Calculate final width
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
const deltaX = currentX - startX;
const finalWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + deltaX));
// Ensure the final width is applied using CSS custom properties
$list[0].style.setProperty('--list-width', `${finalWidth}px`);
$list.css({
'width': `${finalWidth}px`,
'min-width': `${finalWidth}px`,
'max-width': `${finalWidth}px`,
'flex': 'none',
'flex-basis': 'auto',
'flex-grow': '0',
'flex-shrink': '0'
});
// Remove visual feedback but keep the width
$list.removeClass('list-resizing');
$('body').removeClass('list-resizing-active');
$('body').css('user-select', '');
// 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 boardId = list.boardId;
const listId = list._id;
// Use the same method as the hamburger menu
if (process.env.DEBUG === 'true') {
console.log(`Saving list width: ${finalWidth}px for list ${listId}`);
}
Meteor.call('applyListWidth', boardId, listId, finalWidth, listConstraint, (error, result) => {
if (error) {
console.error('Error saving list width:', error);
} else {
if (process.env.DEBUG === 'true') {
console.log('List width saved successfully:', result);
}
}
});
e.preventDefault();
};
// Mouse events
$resizeHandle.on('mousedown', startResize);
$(document).on('mousemove', doResize);
$(document).on('mouseup', stopResize);
// Touch events for mobile
$resizeHandle.on('touchstart', startResize);
$(document).on('touchmove', doResize);
$(document).on('touchend', stopResize);
// Reactively update resize handle visibility when auto-width changes
component.autorun(() => {
if (component.autoWidth()) {
$resizeHandle.hide();
} else {
$resizeHandle.show();
}
});
// Clean up on component destruction
component.onDestroyed(() => {
$(document).off('mousemove', doResize);
$(document).off('mouseup', stopResize);
$(document).off('touchmove', doResize);
$(document).off('touchend', stopResize);
});
},
}).register('list'); }).register('list');
Template.miniList.events({ Template.miniList.events({

View file

@ -103,14 +103,18 @@ class AttachmentMigrationManager {
// Check if this board has already been migrated (client-side check first) // Check if this board has already been migrated (client-side check first)
if (globalMigratedBoards.has(boardId)) { if (globalMigratedBoards.has(boardId)) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been migrated (client-side), skipping`); console.log(`Board ${boardId} has already been migrated (client-side), skipping`);
}
return; return;
} }
// Double-check with server-side check // Double-check with server-side check
const serverMigrated = await this.isBoardMigratedServer(boardId); const serverMigrated = await this.isBoardMigratedServer(boardId);
if (serverMigrated) { if (serverMigrated) {
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has already been migrated (server-side), skipping`); console.log(`Board ${boardId} has already been migrated (server-side), skipping`);
}
globalMigratedBoards.add(boardId); // Sync client-side tracking globalMigratedBoards.add(boardId); // Sync client-side tracking
return; return;
} }
@ -128,7 +132,9 @@ class AttachmentMigrationManager {
attachmentMigrationProgress.set(100); attachmentMigrationProgress.set(100);
isMigratingAttachments.set(false); isMigratingAttachments.set(false);
globalMigratedBoards.add(boardId); // Mark board as migrated globalMigratedBoards.add(boardId); // Mark board as migrated
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} has no attachments to migrate, marked as migrated`); console.log(`Board ${boardId} has no attachments to migrate, marked as migrated`);
}
return; return;
} }
@ -140,7 +146,9 @@ class AttachmentMigrationManager {
attachmentMigrationStatus.set(`Migration failed: ${errorMessage}`); attachmentMigrationStatus.set(`Migration failed: ${errorMessage}`);
isMigratingAttachments.set(false); isMigratingAttachments.set(false);
} else { } else {
if (process.env.DEBUG === 'true') {
console.log('Attachment migration started for board:', boardId); console.log('Attachment migration started for board:', boardId);
}
this.pollAttachmentMigrationProgress(boardId); this.pollAttachmentMigrationProgress(boardId);
} }
}); });
@ -177,9 +185,11 @@ class AttachmentMigrationManager {
isMigratingAttachments.set(false); isMigratingAttachments.set(false);
this.migrationCache.clear(); // Clear cache to refresh data this.migrationCache.clear(); // Clear cache to refresh data
globalMigratedBoards.add(boardId); // Mark board as migrated globalMigratedBoards.add(boardId); // Mark board as migrated
if (process.env.DEBUG === 'true') {
console.log(`Board ${boardId} migration completed and marked as migrated`); console.log(`Board ${boardId} migration completed and marked as migrated`);
} }
} }
}
}); });
}, 1000); }, 1000);
} }

View file

@ -1764,11 +1764,11 @@ Cards.helpers({
}, },
setTitle(title) { setTitle(title) {
// Sanitize title on client side as well // Basic client-side validation - server will handle full sanitization
let sanitizedTitle = title; let sanitizedTitle = title;
if (typeof title === 'string') { if (typeof title === 'string') {
const { sanitizeTitle } = require('../server/lib/inputSanitizer'); // Basic length check to prevent abuse
sanitizedTitle = sanitizeTitle(title); sanitizedTitle = title.length > 1000 ? title.substring(0, 1000) : title;
if (process.env.DEBUG === 'true' && sanitizedTitle !== title) { if (process.env.DEBUG === 'true' && sanitizedTitle !== title) {
console.warn('Client-side sanitized card title:', title, '->', sanitizedTitle); console.warn('Client-side sanitized card title:', title, '->', sanitizedTitle);
} }
@ -3583,8 +3583,8 @@ JsonRoutes.add('GET', '/api/boards/:boardId/cards_count', function(
Authentication.checkBoardAccess(req.userId, paramBoardId); Authentication.checkBoardAccess(req.userId, paramBoardId);
if (req.body.title) { if (req.body.title) {
const { sanitizeTitle } = require('../server/lib/inputSanitizer'); // Basic client-side validation - server will handle full sanitization
const newTitle = sanitizeTitle(req.body.title); const newTitle = req.body.title.length > 1000 ? req.body.title.substring(0, 1000) : req.body.title;
if (process.env.DEBUG === 'true' && newTitle !== req.body.title) { if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
console.warn('Sanitized card title input:', req.body.title, '->', newTitle); console.warn('Sanitized card title input:', req.body.title, '->', newTitle);

View file

@ -314,13 +314,10 @@ Lists.helpers({
Lists.mutations({ Lists.mutations({
rename(title) { rename(title) {
// Sanitize title on client side as well // Basic client-side validation - server will handle full sanitization
if (typeof title === 'string') { if (typeof title === 'string') {
const { sanitizeTitle } = require('../server/lib/inputSanitizer'); // Basic length check to prevent abuse
const sanitizedTitle = sanitizeTitle(title); const sanitizedTitle = title.length > 1000 ? title.substring(0, 1000) : title;
if (process.env.DEBUG === 'true' && sanitizedTitle !== title) {
console.warn('Client-side sanitized list title:', title, '->', sanitizedTitle);
}
return { $set: { title: sanitizedTitle } }; return { $set: { title: sanitizedTitle } };
} }
return { $set: { title } }; return { $set: { title } };
@ -654,8 +651,8 @@ if (Meteor.isServer) {
// Update title if provided // Update title if provided
if (req.body.title) { if (req.body.title) {
const { sanitizeTitle } = require('../server/lib/inputSanitizer'); // Basic client-side validation - server will handle full sanitization
const newTitle = sanitizeTitle(req.body.title); const newTitle = req.body.title.length > 1000 ? req.body.title.substring(0, 1000) : req.body.title;
if (process.env.DEBUG === 'true' && newTitle !== req.body.title) { if (process.env.DEBUG === 'true' && newTitle !== req.body.title) {
console.warn('Sanitized list title input:', req.body.title, '->', newTitle); console.warn('Sanitized list title input:', req.body.title, '->', newTitle);