Drag any files from file manager to minicard or opened card. Part 2.

Thanks to xet7 !

Fixes #2936
This commit is contained in:
Lauri Ojansivu 2025-10-10 21:46:07 +03:00
parent c1cbcdcc72
commit cdd7d69c66
8 changed files with 404 additions and 6 deletions

View file

@ -106,6 +106,142 @@
color: white; color: white;
cursor: pointer; cursor: pointer;
font-size: 4em; 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; top: 0;
right: 8px; right: 8px;
position: absolute; position: absolute;

View file

@ -1,6 +1,7 @@
import { ReactiveCache } from '/imports/reactiveCache'; import { ReactiveCache } from '/imports/reactiveCache';
import { ObjectID } from 'bson'; import { ObjectID } from 'bson';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import uploadProgressManager from '/client/lib/uploadProgressManager';
const filesize = require('filesize'); const filesize = require('filesize');
const prettyMilliseconds = require('pretty-ms'); const prettyMilliseconds = require('pretty-ms');
@ -333,13 +334,17 @@ export function handleFileUpload(card, files) {
// Check if board allows attachments // Check if board allows attachments
const board = card.board(); const board = card.board();
if (!board || !board.allowsAttachments) { 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 []; return [];
} }
// Check if user can modify the card // Check if user can modify the card
if (!card.canModifyCard()) { 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 []; return [];
} }
@ -348,7 +353,9 @@ export function handleFileUpload(card, files) {
for (const file of files) { for (const file of files) {
// Basic file validation // Basic file validation
if (!file || !file.name) { if (!file || !file.name) {
console.warn('Invalid file object'); if (process.env.DEBUG === 'true') {
console.warn('Invalid file object');
}
continue; continue;
} }
@ -381,24 +388,36 @@ export function handleFileUpload(card, files) {
false, false,
); );
// Add to progress manager for tracking
const uploadId = uploadProgressManager.addUpload(card._id, uploader, file);
uploader.on('uploaded', (error, fileRef) => { uploader.on('uploaded', (error, fileRef) => {
if (!error) { if (!error) {
if (fileRef.isImage) { if (fileRef.isImage) {
card.setCover(fileRef._id); card.setCover(fileRef._id);
if (process.env.DEBUG === 'true') {
console.log(`Set cover image for card ${card._id}: ${fileRef.name}`);
}
} }
} else { } else {
console.error('Upload error:', error); if (process.env.DEBUG === 'true') {
console.error('Upload error:', error);
}
} }
}); });
uploader.on('error', (error) => { uploader.on('error', (error) => {
console.error('Upload error:', error); if (process.env.DEBUG === 'true') {
console.error('Upload error:', error);
}
}); });
uploads.push(uploader); uploads.push(uploader);
uploader.start(); uploader.start();
} catch (error) { } catch (error) {
console.error('Failed to create uploader:', error); if (process.env.DEBUG === 'true') {
console.error('Failed to create uploader:', error);
}
} }
} }

View file

@ -65,6 +65,26 @@ template(name="cardDetails")
else else
p.warning {{_ 'card-archived'}} 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-left
.card-details-items .card-details-items

View file

@ -13,6 +13,7 @@ import { ALLOWED_COLORS } from '/config/const';
import { UserAvatar } from '../users/userAvatar'; import { UserAvatar } from '../users/userAvatar';
import { DialogWithBoardSwimlaneList } from '/client/lib/dialogWithBoardSwimlaneList'; import { DialogWithBoardSwimlaneList } from '/client/lib/dialogWithBoardSwimlaneList';
import { handleFileUpload } from './attachments'; import { handleFileUpload } from './attachments';
import uploadProgressManager from '/client/lib/uploadProgressManager';
const subManager = new SubsManager(); const subManager = new SubsManager();
const { calculateIndexData } = Utils; const { calculateIndexData } = Utils;
@ -544,6 +545,16 @@ Template.cardDetails.helpers({
isPopup() { isPopup() {
let ret = !!Utils.getPopupCardId(); let ret = !!Utils.getPopupCardId();
return ret; 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(() => { Template.cardDetailsPopup.onDestroyed(() => {

View file

@ -31,6 +31,27 @@ template(name="minicard")
if cover if cover
if currentBoard.allowsCoverAttachmentOnMinicard if currentBoard.allowsCoverAttachmentOnMinicard
.minicard-cover(style="background-image: url('{{cover.link 'original'}}?dummyReloadAfterSessionEstablished={{sess}}');") .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 .minicard-title
if $eq 'prefix-with-full-path' currentBoard.presentParentTask if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix .parent-prefix

View file

@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n'; import { TAPi18n } from '/imports/i18n';
import { CustomFieldStringTemplate } from '/client/lib/customFields'; import { CustomFieldStringTemplate } from '/client/lib/customFields';
import { handleFileUpload } from './attachments'; import { handleFileUpload } from './attachments';
import uploadProgressManager from '/client/lib/uploadProgressManager';
// Template.cards.events({ // Template.cards.events({
// 'click .member': Popup.open('cardMember') // 'click .member': Popup.open('cardMember')
@ -185,6 +186,16 @@ Template.minicard.helpers({
}, },
isWatching() { isWatching() {
return this.findWatcher(Meteor.userId()); 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);
} }
}); });

View file

@ -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;

View file

@ -631,6 +631,9 @@
"upload": "Upload", "upload": "Upload",
"upload-avatar": "Upload an avatar", "upload-avatar": "Upload an avatar",
"uploaded-avatar": "Uploaded 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-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-link-url": "Custom Top Left Corner Logo Link URL",
"custom-top-left-corner-logo-height": "Custom Top Left Corner Logo Height. Default: 27", "custom-top-left-corner-logo-height": "Custom Top Left Corner Logo Height. Default: 27",