From abad8cc4d5dded0f5e1a80892a3b29aa71404a5c Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Tue, 14 Oct 2025 09:36:11 +0300 Subject: [PATCH] Change list width by dragging between lists. Thanks to xet7 ! --- client/components/boards/boardBody.js | 46 +++-- client/components/lists/list.css | 213 +++++++++++++++++++++++ client/components/lists/list.jade | 1 + client/components/lists/list.js | 143 +++++++++++++++ client/lib/attachmentMigrationManager.js | 20 ++- models/cards.js | 10 +- models/lists.js | 13 +- 7 files changed, 413 insertions(+), 33 deletions(-) diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index a128fba81..0e34522e9 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -89,7 +89,9 @@ BlazeComponent.extendComponent({ // Check if board needs conversion (for old structure) if (boardConverter.isBoardConverted(boardId)) { - console.log(`Board ${boardId} has already been converted, skipping conversion`); + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} has already been converted, skipping conversion`); + } this.isBoardReady.set(true); } else { const needsConversion = boardConverter.needsConversion(boardId); @@ -127,7 +129,9 @@ BlazeComponent.extendComponent({ if (error) { console.error('Failed to start background migration:', error); } else { - console.log('Background migration started for board:', boardId); + if (process.env.DEBUG === 'true') { + console.log('Background migration started for board:', boardId); + } } }); } catch (error) { @@ -139,7 +143,9 @@ BlazeComponent.extendComponent({ try { // Check if board has already been migrated if (attachmentMigrationManager.isBoardMigrated(boardId)) { - console.log(`Board ${boardId} has already been migrated, skipping`); + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} has already been migrated, skipping`); + } return; } @@ -147,12 +153,16 @@ BlazeComponent.extendComponent({ const unconvertedAttachments = attachmentMigrationManager.getUnconvertedAttachments(boardId); if (unconvertedAttachments.length > 0) { - console.log(`Starting attachment migration for ${unconvertedAttachments.length} attachments in board ${boardId}`); + if (process.env.DEBUG === 'true') { + console.log(`Starting attachment migration for ${unconvertedAttachments.length} attachments in board ${boardId}`); + } await attachmentMigrationManager.startAttachmentMigration(boardId); } else { // No attachments to migrate, mark board as migrated // This will be handled by the migration manager itself - console.log(`Board ${boardId} has no attachments to migrate`); + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} has no attachments to migrate`); + } } } catch (error) { console.error('Error starting attachment migration:', error); @@ -622,14 +632,18 @@ BlazeComponent.extendComponent({ hasSwimlanes() { const currentBoard = Utils.getCurrentBoard(); if (!currentBoard) { - console.log('hasSwimlanes: No current board'); + if (process.env.DEBUG === 'true') { + console.log('hasSwimlanes: No current board'); + } return false; } try { const swimlanes = currentBoard.swimlanes(); const hasSwimlanes = swimlanes && swimlanes.length > 0; - console.log('hasSwimlanes: Board has', swimlanes ? swimlanes.length : 0, 'swimlanes'); + if (process.env.DEBUG === 'true') { + console.log('hasSwimlanes: Board has', swimlanes ? swimlanes.length : 0, 'swimlanes'); + } return hasSwimlanes; } catch (error) { console.error('hasSwimlanes: Error getting swimlanes:', error); @@ -661,14 +675,16 @@ BlazeComponent.extendComponent({ const isMigrating = this.isMigrating.get(); const boardView = Utils.boardView(); - console.log('=== BOARD DEBUG STATE ==='); - console.log('currentBoardId:', currentBoardId); - console.log('currentBoard:', !!currentBoard, currentBoard ? currentBoard.title : 'none'); - console.log('isBoardReady:', isBoardReady); - console.log('isConverting:', isConverting); - console.log('isMigrating:', isMigrating); - console.log('boardView:', boardView); - console.log('========================'); + if (process.env.DEBUG === 'true') { + console.log('=== BOARD DEBUG STATE ==='); + console.log('currentBoardId:', currentBoardId); + console.log('currentBoard:', !!currentBoard, currentBoard ? currentBoard.title : 'none'); + console.log('isBoardReady:', isBoardReady); + console.log('isConverting:', isConverting); + console.log('isMigrating:', isMigrating); + console.log('boardView:', boardView); + console.log('========================'); + } return { currentBoardId, diff --git a/client/components/lists/list.css b/client/components/lists/list.css index c81551aaf..669707500 100644 --- a/client/components/lists/list.css +++ b/client/components/lists/list.css @@ -8,6 +8,219 @@ padding: 0; 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 { min-width: 2.5vw; } diff --git a/client/components/lists/list.jade b/client/components/lists/list.jade index 0c87fd117..7484d0a00 100644 --- a/client/components/lists/list.jade +++ b/client/components/lists/list.jade @@ -4,6 +4,7 @@ template(name='list') class="{{#if collapsed}}list-collapsed{{/if}} {{#if autoWidth}}list-auto-width{{/if}} {{#if isMiniScreen}}mobile-view{{/if}}") +listHeader +listBody + .list-resize-handle.js-list-resize-handle template(name='miniList') a.mini-list.js-select-list.js-list(id="js-list-{{_id}}" class="{{#if isMiniScreen}}mobile-view{{/if}}") diff --git a/client/components/lists/list.js b/client/components/lists/list.js index 90c23fa52..5d46d9127 100644 --- a/client/components/lists/list.js +++ b/client/components/lists/list.js @@ -24,6 +24,9 @@ BlazeComponent.extendComponent({ onRendered() { const boardComponent = this.parentComponent().parentComponent(); + // Initialize list resize functionality + this.initializeListResize(); + const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)'; const $cards = this.$('.js-minicards'); @@ -212,6 +215,146 @@ BlazeComponent.extendComponent({ const list = Template.currentData(); 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'); Template.miniList.events({ diff --git a/client/lib/attachmentMigrationManager.js b/client/lib/attachmentMigrationManager.js index 9dc6f1d2f..e84124612 100644 --- a/client/lib/attachmentMigrationManager.js +++ b/client/lib/attachmentMigrationManager.js @@ -103,14 +103,18 @@ class AttachmentMigrationManager { // Check if this board has already been migrated (client-side check first) if (globalMigratedBoards.has(boardId)) { - console.log(`Board ${boardId} has already been migrated (client-side), skipping`); + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} has already been migrated (client-side), skipping`); + } return; } // Double-check with server-side check const serverMigrated = await this.isBoardMigratedServer(boardId); if (serverMigrated) { - console.log(`Board ${boardId} has already been migrated (server-side), skipping`); + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} has already been migrated (server-side), skipping`); + } globalMigratedBoards.add(boardId); // Sync client-side tracking return; } @@ -128,7 +132,9 @@ class AttachmentMigrationManager { attachmentMigrationProgress.set(100); isMigratingAttachments.set(false); globalMigratedBoards.add(boardId); // Mark board as migrated - console.log(`Board ${boardId} has no attachments to migrate, marked as migrated`); + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} has no attachments to migrate, marked as migrated`); + } return; } @@ -140,7 +146,9 @@ class AttachmentMigrationManager { attachmentMigrationStatus.set(`Migration failed: ${errorMessage}`); isMigratingAttachments.set(false); } else { - console.log('Attachment migration started for board:', boardId); + if (process.env.DEBUG === 'true') { + console.log('Attachment migration started for board:', boardId); + } this.pollAttachmentMigrationProgress(boardId); } }); @@ -177,7 +185,9 @@ class AttachmentMigrationManager { isMigratingAttachments.set(false); this.migrationCache.clear(); // Clear cache to refresh data globalMigratedBoards.add(boardId); // Mark board as migrated - console.log(`Board ${boardId} migration completed and marked as migrated`); + if (process.env.DEBUG === 'true') { + console.log(`Board ${boardId} migration completed and marked as migrated`); + } } } }); diff --git a/models/cards.js b/models/cards.js index 7dca82b66..b70f60b79 100644 --- a/models/cards.js +++ b/models/cards.js @@ -1764,11 +1764,11 @@ Cards.helpers({ }, setTitle(title) { - // Sanitize title on client side as well + // Basic client-side validation - server will handle full sanitization let sanitizedTitle = title; if (typeof title === 'string') { - const { sanitizeTitle } = require('../server/lib/inputSanitizer'); - sanitizedTitle = sanitizeTitle(title); + // Basic length check to prevent abuse + sanitizedTitle = title.length > 1000 ? title.substring(0, 1000) : title; if (process.env.DEBUG === 'true' && sanitizedTitle !== title) { 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); if (req.body.title) { - const { sanitizeTitle } = require('../server/lib/inputSanitizer'); - const newTitle = sanitizeTitle(req.body.title); + // Basic client-side validation - server will handle full sanitization + 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) { console.warn('Sanitized card title input:', req.body.title, '->', newTitle); diff --git a/models/lists.js b/models/lists.js index 771ca1f63..9d7e7f000 100644 --- a/models/lists.js +++ b/models/lists.js @@ -314,13 +314,10 @@ Lists.helpers({ Lists.mutations({ rename(title) { - // Sanitize title on client side as well + // Basic client-side validation - server will handle full sanitization if (typeof title === 'string') { - const { sanitizeTitle } = require('../server/lib/inputSanitizer'); - const sanitizedTitle = sanitizeTitle(title); - if (process.env.DEBUG === 'true' && sanitizedTitle !== title) { - console.warn('Client-side sanitized list title:', title, '->', sanitizedTitle); - } + // Basic length check to prevent abuse + const sanitizedTitle = title.length > 1000 ? title.substring(0, 1000) : title; return { $set: { title: sanitizedTitle } }; } return { $set: { title } }; @@ -654,8 +651,8 @@ if (Meteor.isServer) { // Update title if provided if (req.body.title) { - const { sanitizeTitle } = require('../server/lib/inputSanitizer'); - const newTitle = sanitizeTitle(req.body.title); + // Basic client-side validation - server will handle full sanitization + 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) { console.warn('Sanitized list title input:', req.body.title, '->', newTitle);