From 3e9481c5bd2c02ba501bd0a6ef1d1e6ce82bb1d9 Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Fri, 10 Oct 2025 18:52:30 +0300 Subject: [PATCH] Drag any files from file manager to minicard or opened card. Thanks to xet7 ! Fixes #2936 --- client/components/cards/attachments.js | 119 +++++++++++++++++------- client/components/cards/cardDetails.css | 8 ++ client/components/cards/cardDetails.js | 54 +++++++++++ client/components/cards/minicard.css | 8 ++ client/components/cards/minicard.js | 56 ++++++++++- models/boards.js | 2 +- 6 files changed, 210 insertions(+), 37 deletions(-) diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index 391841602..9eb91c543 100644 --- a/client/components/cards/attachments.js +++ b/client/components/cards/attachments.js @@ -296,44 +296,13 @@ Template.cardAttachmentsPopup.events({ const files = event.currentTarget.files; if (files) { let uploads = []; - for (const file of files) { - const fileId = new ObjectID().toString(); - let fileName = DOMPurify.sanitize(file.name); + const uploaders = handleFileUpload(card, files); - // If sanitized filename is not same as original filename, - // it could be XSS that is already fixed with sanitize, - // or just normal mistake, so it is not a problem. - // That is why here is no warning. - if (fileName !== file.name) { - // If filename is empty, only in that case add some filename - if (fileName.length === 0) { - fileName = 'Empty-filename-after-sanitize.txt'; - } - } - - const config = { - file: file, - fileId: fileId, - fileName: fileName, - meta: Utils.getCommonAttachmentMetaFrom(card), - chunkSize: 'dynamic', - }; - config.meta.fileId = fileId; - const uploader = Attachments.insert( - config, - false, - ); + uploaders.forEach(uploader => { uploader.on('start', function() { uploads.push(this); templateInstance.uploads.set(uploads); }); - uploader.on('uploaded', (error, fileRef) => { - if (!error) { - if (fileRef.isImage) { - card.setCover(fileRef._id); - } - } - }); uploader.on('end', (error, fileRef) => { uploads = uploads.filter(_upload => _upload.config.fileId != fileRef._id); templateInstance.uploads.set(uploads); @@ -341,8 +310,7 @@ Template.cardAttachmentsPopup.events({ Popup.back(); } }); - uploader.start(); - } + }); } }, 'click .js-computer-upload'(event, templateInstance) { @@ -356,6 +324,87 @@ const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL; const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO; let pastedResults = null; +// Shared upload logic for drag-and-drop functionality +export function handleFileUpload(card, files) { + if (!files || files.length === 0) { + return []; + } + + // Check if board allows attachments + const board = card.board(); + if (!board || !board.allowsAttachments) { + console.warn('Attachments not allowed on this board'); + return []; + } + + // Check if user can modify the card + if (!card.canModifyCard()) { + console.warn('User does not have permission to modify this card'); + return []; + } + + const uploads = []; + + for (const file of files) { + // Basic file validation + if (!file || !file.name) { + console.warn('Invalid file object'); + continue; + } + + const fileId = new ObjectID().toString(); + let fileName = DOMPurify.sanitize(file.name); + + // If sanitized filename is not same as original filename, + // it could be XSS that is already fixed with sanitize, + // or just normal mistake, so it is not a problem. + // That is why here is no warning. + if (fileName !== file.name) { + // If filename is empty, only in that case add some filename + if (fileName.length === 0) { + fileName = 'Empty-filename-after-sanitize.txt'; + } + } + + const config = { + file: file, + fileId: fileId, + fileName: fileName, + meta: Utils.getCommonAttachmentMetaFrom(card), + chunkSize: 'dynamic', + }; + config.meta.fileId = fileId; + + try { + const uploader = Attachments.insert( + config, + false, + ); + + uploader.on('uploaded', (error, fileRef) => { + if (!error) { + if (fileRef.isImage) { + card.setCover(fileRef._id); + } + } else { + console.error('Upload error:', error); + } + }); + + uploader.on('error', (error) => { + console.error('Upload error:', error); + }); + + uploads.push(uploader); + uploader.start(); + } catch (error) { + console.error('Failed to create uploader:', error); + } + } + + return uploads; +} + Template.previewClipboardImagePopup.onRendered(() => { // we can paste image from clipboard const handle = results => { diff --git a/client/components/cards/cardDetails.css b/client/components/cards/cardDetails.css index 93266504f..2f2522f5c 100644 --- a/client/components/cards/cardDetails.css +++ b/client/components/cards/cardDetails.css @@ -596,3 +596,11 @@ input[type="submit"].attachment-add-link-submit { overflow: hidden; background-color: #cecece; } + +/* Drag and drop file upload visual feedback */ +.js-card-details.is-dragging-over { + border: 2px dashed #0079bf; + background-color: #e3f2fd !important; + transform: scale(1.01); + transition: all 0.2s ease; +} diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index 9d022b2cd..b40f2aa83 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -12,6 +12,7 @@ import CardComments from '/models/cardComments'; import { ALLOWED_COLORS } from '/config/const'; import { UserAvatar } from '../users/userAvatar'; import { DialogWithBoardSwimlaneList } from '/client/lib/dialogWithBoardSwimlaneList'; +import { handleFileUpload } from './attachments'; const subManager = new SubsManager(); const { calculateIndexData } = Utils; @@ -481,6 +482,59 @@ BlazeComponent.extendComponent({ } } }, + // Drag and drop file upload handlers + 'dragover .js-card-details'(event) { + event.preventDefault(); + event.stopPropagation(); + }, + 'dragenter .js-card-details'(event) { + event.preventDefault(); + event.stopPropagation(); + const card = this.data(); + const board = card.board(); + // Only allow drag-and-drop if user can modify card and board allows attachments + if (card.canModifyCard() && board && board.allowsAttachments) { + // Check if the drag contains files + const dataTransfer = event.originalEvent.dataTransfer; + if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) { + $(event.currentTarget).addClass('is-dragging-over'); + } + } + }, + 'dragleave .js-card-details'(event) { + event.preventDefault(); + event.stopPropagation(); + $(event.currentTarget).removeClass('is-dragging-over'); + }, + 'drop .js-card-details'(event) { + event.preventDefault(); + event.stopPropagation(); + $(event.currentTarget).removeClass('is-dragging-over'); + + const card = this.data(); + const board = card.board(); + + // Check permissions + if (!card.canModifyCard() || !board || !board.allowsAttachments) { + return; + } + + // Check if this is a file drop (not a checklist item reorder) + const dataTransfer = event.originalEvent.dataTransfer; + if (!dataTransfer || !dataTransfer.files || dataTransfer.files.length === 0) { + return; + } + + // Check if the drop contains files (not just text/HTML) + if (!dataTransfer.types.includes('Files')) { + return; + } + + const files = dataTransfer.files; + if (files && files.length > 0) { + handleFileUpload(card, files); + } + }, }, ]; }, diff --git a/client/components/cards/minicard.css b/client/components/cards/minicard.css index 8690e5ee0..cedc26527 100644 --- a/client/components/cards/minicard.css +++ b/client/components/cards/minicard.css @@ -580,3 +580,11 @@ .text-green { color: #008000; } + +/* Drag and drop file upload visual feedback */ +.minicard.is-dragging-over { + border: 2px dashed #0079bf; + background-color: #e3f2fd !important; + transform: scale(1.02); + transition: all 0.2s ease; +} diff --git a/client/components/cards/minicard.js b/client/components/cards/minicard.js index 959092dd3..ea2885120 100644 --- a/client/components/cards/minicard.js +++ b/client/components/cards/minicard.js @@ -1,6 +1,7 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { TAPi18n } from '/imports/i18n'; -import { CustomFieldStringTemplate } from '/client/lib/customFields' +import { CustomFieldStringTemplate } from '/client/lib/customFields'; +import { handleFileUpload } from './attachments'; // Template.cards.events({ // 'click .member': Popup.open('cardMember') @@ -107,6 +108,59 @@ BlazeComponent.extendComponent({ 'click span.badge-icon.fa.fa-sort, click span.badge-text.check-list-sort' : Popup.open("editCardSortOrder"), 'click .minicard-labels' : this.cardLabelsPopup, 'click .js-open-minicard-details-menu': Popup.open('minicardDetailsActions'), + // Drag and drop file upload handlers + 'dragover .minicard'(event) { + event.preventDefault(); + event.stopPropagation(); + }, + 'dragenter .minicard'(event) { + event.preventDefault(); + event.stopPropagation(); + const card = this.data(); + const board = card.board(); + // Only allow drag-and-drop if user can modify card and board allows attachments + if (card.canModifyCard() && board && board.allowsAttachments) { + // Check if the drag contains files + const dataTransfer = event.originalEvent.dataTransfer; + if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) { + $(event.currentTarget).addClass('is-dragging-over'); + } + } + }, + 'dragleave .minicard'(event) { + event.preventDefault(); + event.stopPropagation(); + $(event.currentTarget).removeClass('is-dragging-over'); + }, + 'drop .minicard'(event) { + event.preventDefault(); + event.stopPropagation(); + $(event.currentTarget).removeClass('is-dragging-over'); + + const card = this.data(); + const board = card.board(); + + // Check permissions + if (!card.canModifyCard() || !board || !board.allowsAttachments) { + return; + } + + // Check if this is a file drop (not a card reorder) + const dataTransfer = event.originalEvent.dataTransfer; + if (!dataTransfer || !dataTransfer.files || dataTransfer.files.length === 0) { + return; + } + + // Check if the drop contains files (not just text/HTML) + if (!dataTransfer.types.includes('Files')) { + return; + } + + const files = dataTransfer.files; + if (files && files.length > 0) { + handleFileUpload(card, files); + } + }, } ]; }, diff --git a/models/boards.js b/models/boards.js index f8ed0fb40..835b73c95 100644 --- a/models/boards.js +++ b/models/boards.js @@ -413,7 +413,7 @@ Boards.attachSchema( * Does the board allows cover attachment on minicard? */ type: Boolean, - defaultValue: false, + defaultValue: true, }, allowsBadgeAttachmentOnMinicard: {