diff --git a/client/components/cards/attachments.css b/client/components/cards/attachments.css index 4ca226d42..64a0c8735 100644 --- a/client/components/cards/attachments.css +++ b/client/components/cards/attachments.css @@ -106,6 +106,142 @@ color: white; cursor: pointer; font-size: 4em; +} + +/* Upload progress indicators for drag-and-drop uploads */ +.minicard-upload-progress, +.card-details-upload-progress { + background: #f8f9fa; + border: 1px solid #e9ecef; + 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; +} + +.upload-progress-item.upload-error { + border-color: #dc3545; + background: #f8d7da; +} + +.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; +} + +.upload-progress-item.upload-error .upload-progress-fill { + background: #dc3545; +} + +.upload-progress-error, +.upload-progress-success { + display: flex; + align-items: center; + font-size: 12px; + font-weight: 500; +} + +.upload-progress-error { + color: #dc3545; +} + +.upload-progress-success { + 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; + background: rgba(0, 123, 255, 0.1) !important; +} + +/* Drag over state for card details */ +.js-card-details.is-dragging-over { + border: 2px dashed #007bff !important; + background: rgba(0, 123, 255, 0.05) !important; +} top: 0; right: 8px; position: absolute; diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index 9eb91c543..e2ff2b722 100644 --- a/client/components/cards/attachments.js +++ b/client/components/cards/attachments.js @@ -1,6 +1,7 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { ObjectID } from 'bson'; import DOMPurify from 'dompurify'; +import uploadProgressManager from '/client/lib/uploadProgressManager'; const filesize = require('filesize'); const prettyMilliseconds = require('pretty-ms'); @@ -333,13 +334,17 @@ export function handleFileUpload(card, files) { // Check if board allows attachments const board = card.board(); if (!board || !board.allowsAttachments) { - console.warn('Attachments not allowed on this board'); + if (process.env.DEBUG === 'true') { + 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'); + if (process.env.DEBUG === 'true') { + console.warn('User does not have permission to modify this card'); + } return []; } @@ -348,7 +353,9 @@ export function handleFileUpload(card, files) { for (const file of files) { // Basic file validation if (!file || !file.name) { - console.warn('Invalid file object'); + if (process.env.DEBUG === 'true') { + console.warn('Invalid file object'); + } continue; } @@ -381,24 +388,36 @@ export function handleFileUpload(card, files) { false, ); + // Add to progress manager for tracking + const uploadId = uploadProgressManager.addUpload(card._id, uploader, file); + uploader.on('uploaded', (error, fileRef) => { if (!error) { if (fileRef.isImage) { card.setCover(fileRef._id); + if (process.env.DEBUG === 'true') { + console.log(`Set cover image for card ${card._id}: ${fileRef.name}`); + } } } else { - console.error('Upload error:', error); + if (process.env.DEBUG === 'true') { + console.error('Upload error:', error); + } } }); uploader.on('error', (error) => { - console.error('Upload error:', error); + if (process.env.DEBUG === 'true') { + console.error('Upload error:', error); + } }); uploads.push(uploader); uploader.start(); } catch (error) { - console.error('Failed to create uploader:', error); + if (process.env.DEBUG === 'true') { + console.error('Failed to create uploader:', error); + } } } diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade index 6ca69c754..b00582807 100644 --- a/client/components/cards/cardDetails.jade +++ b/client/components/cards/cardDetails.jade @@ -65,6 +65,26 @@ template(name="cardDetails") else p.warning {{_ 'card-archived'}} + // Upload progress indicator for drag-and-drop uploads + if hasActiveUploads + .card-details-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-exclamation-triangle + span {{_ 'upload-failed'}} + else if $eq status 'completed' + .upload-progress-success + i.fa.fa-check + span {{_ 'upload-completed'}} + .card-details-left .card-details-items diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index b40f2aa83..8927352c6 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -13,6 +13,7 @@ import { ALLOWED_COLORS } from '/config/const'; import { UserAvatar } from '../users/userAvatar'; import { DialogWithBoardSwimlaneList } from '/client/lib/dialogWithBoardSwimlaneList'; import { handleFileUpload } from './attachments'; +import uploadProgressManager from '/client/lib/uploadProgressManager'; const subManager = new SubsManager(); const { calculateIndexData } = Utils; @@ -544,6 +545,16 @@ Template.cardDetails.helpers({ isPopup() { let ret = !!Utils.getPopupCardId(); return ret; + }, + // Upload progress helpers + hasActiveUploads() { + return uploadProgressManager.hasActiveUploads(this._id); + }, + uploads() { + return uploadProgressManager.getUploadsForCard(this._id); + }, + uploadCount() { + return uploadProgressManager.getUploadCountForCard(this._id); } }); Template.cardDetailsPopup.onDestroyed(() => { diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade index cfec7ae46..df262e3be 100644 --- a/client/components/cards/minicard.jade +++ b/client/components/cards/minicard.jade @@ -31,6 +31,27 @@ template(name="minicard") 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-exclamation-triangle + span {{_ 'upload-failed'}} + else if $eq status 'completed' + .upload-progress-success + i.fa.fa-check + span {{_ 'upload-completed'}} + .minicard-title if $eq 'prefix-with-full-path' currentBoard.presentParentTask .parent-prefix diff --git a/client/components/cards/minicard.js b/client/components/cards/minicard.js index ea2885120..86ea11f3c 100644 --- a/client/components/cards/minicard.js +++ b/client/components/cards/minicard.js @@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { TAPi18n } from '/imports/i18n'; import { CustomFieldStringTemplate } from '/client/lib/customFields'; import { handleFileUpload } from './attachments'; +import uploadProgressManager from '/client/lib/uploadProgressManager'; // Template.cards.events({ // 'click .member': Popup.open('cardMember') @@ -185,6 +186,16 @@ Template.minicard.helpers({ }, isWatching() { return this.findWatcher(Meteor.userId()); + }, + // Upload progress helpers + hasActiveUploads() { + return uploadProgressManager.hasActiveUploads(this._id); + }, + uploads() { + return uploadProgressManager.getUploadsForCard(this._id); + }, + uploadCount() { + return uploadProgressManager.getUploadCountForCard(this._id); } }); diff --git a/client/lib/uploadProgressManager.js b/client/lib/uploadProgressManager.js new file mode 100644 index 000000000..6d51eded6 --- /dev/null +++ b/client/lib/uploadProgressManager.js @@ -0,0 +1,177 @@ +import { ReactiveVar } from 'meteor/reactive-var'; +import { Tracker } from 'meteor/tracker'; + +/** + * Global upload progress manager for drag-and-drop file uploads + * Tracks upload progress across all cards and provides reactive data + */ +class UploadProgressManager { + constructor() { + // Map of cardId -> array of upload objects + this.cardUploads = new ReactiveVar(new Map()); + + // Map of uploadId -> upload object for easy lookup + this.uploadMap = new ReactiveVar(new Map()); + } + + /** + * Add a new upload to track + * @param {string} cardId - The card ID + * @param {Object} uploader - The uploader object from Attachments.insert + * @param {File} file - The file being uploaded + * @returns {string} uploadId - Unique identifier for this upload + */ + addUpload(cardId, uploader, file) { + const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const upload = { + id: uploadId, + cardId: cardId, + file: file, + uploader: uploader, + progress: new ReactiveVar(0), + status: new ReactiveVar('uploading'), // 'uploading', 'completed', 'error' + error: new ReactiveVar(null), + startTime: Date.now(), + endTime: null + }; + + // Update card uploads + const currentCardUploads = this.cardUploads.get(); + const cardUploads = currentCardUploads.get(cardId) || []; + cardUploads.push(upload); + currentCardUploads.set(cardId, cardUploads); + this.cardUploads.set(currentCardUploads); + + // Update upload map + const currentUploadMap = this.uploadMap.get(); + currentUploadMap.set(uploadId, upload); + this.uploadMap.set(currentUploadMap); + + // Set up uploader event listeners + uploader.on('progress', (progress) => { + upload.progress.set(progress); + }); + + uploader.on('uploaded', (error, fileRef) => { + upload.status.set(error ? 'error' : 'completed'); + upload.endTime = Date.now(); + upload.error.set(error); + + if (process.env.DEBUG === 'true') { + console.log(`Upload ${uploadId} completed:`, error ? 'error' : 'success'); + } + + // Remove from tracking after a delay to show completion + setTimeout(() => { + this.removeUpload(uploadId); + }, 2000); + }); + + uploader.on('error', (error) => { + upload.status.set('error'); + upload.endTime = Date.now(); + upload.error.set(error); + + if (process.env.DEBUG === 'true') { + console.log(`Upload ${uploadId} failed:`, error); + } + + // Remove from tracking after a delay to show error + setTimeout(() => { + this.removeUpload(uploadId); + }, 3000); + }); + + if (process.env.DEBUG === 'true') { + console.log(`Added upload ${uploadId} for card ${cardId}: ${file.name}`); + } + + return uploadId; + } + + /** + * Remove an upload from tracking + * @param {string} uploadId - The upload ID to remove + */ + removeUpload(uploadId) { + const upload = this.uploadMap.get().get(uploadId); + if (!upload) return; + + const cardId = upload.cardId; + + // Remove from card uploads + const currentCardUploads = this.cardUploads.get(); + const cardUploads = currentCardUploads.get(cardId) || []; + const filteredCardUploads = cardUploads.filter(u => u.id !== uploadId); + + if (filteredCardUploads.length === 0) { + currentCardUploads.delete(cardId); + } else { + currentCardUploads.set(cardId, filteredCardUploads); + } + this.cardUploads.set(currentCardUploads); + + // Remove from upload map + const currentUploadMap = this.uploadMap.get(); + currentUploadMap.delete(uploadId); + this.uploadMap.set(currentUploadMap); + + if (process.env.DEBUG === 'true') { + console.log(`Removed upload ${uploadId} from tracking`); + } + } + + /** + * Get all uploads for a specific card + * @param {string} cardId - The card ID + * @returns {Array} Array of upload objects + */ + getUploadsForCard(cardId) { + return this.cardUploads.get().get(cardId) || []; + } + + /** + * Get upload count for a specific card + * @param {string} cardId - The card ID + * @returns {number} Number of active uploads + */ + getUploadCountForCard(cardId) { + return this.getUploadsForCard(cardId).length; + } + + /** + * Check if a card has any active uploads + * @param {string} cardId - The card ID + * @returns {boolean} True if card has active uploads + */ + hasActiveUploads(cardId) { + return this.getUploadCountForCard(cardId) > 0; + } + + /** + * Get all uploads across all cards + * @returns {Array} Array of all upload objects + */ + getAllUploads() { + const allUploads = []; + this.cardUploads.get().forEach(cardUploads => { + allUploads.push(...cardUploads); + }); + return allUploads; + } + + /** + * Clear all uploads (useful for cleanup) + */ + clearAllUploads() { + this.cardUploads.set(new Map()); + this.uploadMap.set(new Map()); + } +} + +// Create global instance +const uploadProgressManager = new UploadProgressManager(); + +export default uploadProgressManager; + diff --git a/imports/i18n/data/en.i18n.json b/imports/i18n/data/en.i18n.json index 4f6dd193d..87918c182 100644 --- a/imports/i18n/data/en.i18n.json +++ b/imports/i18n/data/en.i18n.json @@ -631,6 +631,9 @@ "upload": "Upload", "upload-avatar": "Upload an avatar", "uploaded-avatar": "Uploaded an avatar", + "uploading-files": "Uploading files", + "upload-failed": "Upload failed", + "upload-completed": "Upload completed", "custom-top-left-corner-logo-image-url": "Custom Top Left Corner Logo Image URL", "custom-top-left-corner-logo-link-url": "Custom Top Left Corner Logo Link URL", "custom-top-left-corner-logo-height": "Custom Top Left Corner Logo Height. Default: 27",