2017-08-22 10:53:26 +03:00
|
|
|
import Component from '@ember/component';
|
|
|
|
import EmberObject from '@ember/object';
|
2017-05-29 21:50:03 +03:00
|
|
|
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
|
|
|
import {all, task} from 'ember-concurrency';
|
2017-08-22 10:53:26 +03:00
|
|
|
import {isArray as isEmberArray} from '@ember/array';
|
|
|
|
import {isEmpty} from '@ember/utils';
|
|
|
|
import {run} from '@ember/runloop';
|
2017-10-30 12:38:01 +03:00
|
|
|
import {inject as service} from '@ember/service';
|
2017-05-08 13:35:42 +03:00
|
|
|
|
|
|
|
// TODO: this is designed to be a more re-usable/composable upload component, it
|
|
|
|
// should be able to replace the duplicated upload logic in:
|
|
|
|
// - gh-image-uploader
|
|
|
|
// - gh-file-uploader
|
|
|
|
//
|
|
|
|
// In order to support the above components we'll need to introduce an
|
|
|
|
// "allowMultiple" attribute so that single-image uploads don't allow multiple
|
|
|
|
// simultaneous uploads
|
|
|
|
|
2017-10-09 14:21:57 +03:00
|
|
|
const MAX_SIMULTANEOUS_UPLOADS = 2;
|
|
|
|
|
2017-05-23 11:50:04 +03:00
|
|
|
/**
|
|
|
|
* Result from a file upload
|
|
|
|
* @typedef {Object} UploadResult
|
|
|
|
* @property {string} fileName - file name, eg "my-image.png"
|
|
|
|
* @property {string} url - url relative to Ghost root,eg "/content/images/2017/05/my-image.png"
|
|
|
|
*/
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
const UploadTracker = EmberObject.extend({
|
|
|
|
file: null,
|
|
|
|
total: 0,
|
|
|
|
loaded: 0,
|
|
|
|
|
|
|
|
init() {
|
2017-11-25 02:18:35 +03:00
|
|
|
this._super(...arguments);
|
2017-05-08 13:35:42 +03:00
|
|
|
this.total = this.file && this.file.size || 0;
|
|
|
|
},
|
|
|
|
|
|
|
|
update({loaded, total}) {
|
|
|
|
this.total = total;
|
|
|
|
this.loaded = loaded;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
export default Component.extend({
|
2017-10-30 12:38:01 +03:00
|
|
|
ajax: service(),
|
2017-05-08 13:35:42 +03:00
|
|
|
|
2018-01-11 20:43:23 +03:00
|
|
|
tagName: '',
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
// Public attributes
|
|
|
|
accept: '',
|
2017-05-23 11:18:03 +03:00
|
|
|
extensions: '',
|
2017-05-08 13:35:42 +03:00
|
|
|
files: null,
|
|
|
|
paramName: 'uploadimage', // TODO: is this the best default?
|
|
|
|
uploadUrl: null,
|
|
|
|
|
|
|
|
// Interal attributes
|
|
|
|
errors: null, // [{fileName: 'x', message: 'y'}, ...]
|
|
|
|
totalSize: 0,
|
|
|
|
uploadedSize: 0,
|
|
|
|
uploadPercentage: 0,
|
|
|
|
uploadUrls: null, // [{filename: 'x', url: 'y'}],
|
|
|
|
|
|
|
|
// Private
|
|
|
|
_defaultUploadUrl: '/uploads/',
|
|
|
|
_files: null,
|
|
|
|
_uploadTrackers: null,
|
|
|
|
|
|
|
|
// Closure actions
|
|
|
|
onCancel() {},
|
|
|
|
onComplete() {},
|
|
|
|
onFailed() {},
|
|
|
|
onStart() {},
|
2017-09-21 18:01:40 +03:00
|
|
|
onUploadFailure() {},
|
2017-05-08 13:35:42 +03:00
|
|
|
onUploadSuccess() {},
|
|
|
|
|
|
|
|
// Optional closure actions
|
|
|
|
// validate(file) {}
|
|
|
|
|
|
|
|
init() {
|
|
|
|
this._super(...arguments);
|
|
|
|
this.set('errors', []);
|
|
|
|
this.set('uploadUrls', []);
|
|
|
|
this._uploadTrackers = [];
|
|
|
|
},
|
|
|
|
|
|
|
|
didReceiveAttrs() {
|
|
|
|
this._super(...arguments);
|
|
|
|
|
|
|
|
// set up any defaults
|
|
|
|
if (!this.get('uploadUrl')) {
|
|
|
|
this.set('uploadUrl', this._defaultUploadUrl);
|
|
|
|
}
|
|
|
|
|
|
|
|
// if we have new files, validate and start an upload
|
|
|
|
let files = this.get('files');
|
2017-11-22 20:04:48 +03:00
|
|
|
this._setFiles(files);
|
|
|
|
},
|
|
|
|
|
2018-01-11 20:43:23 +03:00
|
|
|
actions: {
|
|
|
|
setFiles(files, resetInput) {
|
|
|
|
this._setFiles(files);
|
|
|
|
|
|
|
|
if (resetInput) {
|
|
|
|
resetInput();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
cancel() {
|
|
|
|
this._reset();
|
|
|
|
this.onCancel();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
2017-11-22 20:04:48 +03:00
|
|
|
_setFiles(files) {
|
|
|
|
this.set('files', files);
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
if (files && files !== this._files) {
|
2017-05-23 11:18:03 +03:00
|
|
|
if (this.get('_uploadFiles.isRunning')) {
|
|
|
|
// eslint-disable-next-line
|
|
|
|
console.error('Adding new files whilst an upload is in progress is not supported.');
|
2017-05-08 13:35:42 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
this._files = files;
|
|
|
|
|
|
|
|
// we cancel early if any file fails client-side validation
|
|
|
|
if (this._validate()) {
|
|
|
|
this.get('_uploadFiles').perform(files);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
_validate() {
|
|
|
|
let files = this.get('files');
|
|
|
|
let validate = this.get('validate') || this._defaultValidator.bind(this);
|
|
|
|
let ok = [];
|
|
|
|
let errors = [];
|
|
|
|
|
2017-05-12 11:02:33 +03:00
|
|
|
// NOTE: for...of loop results in a transpilation that errors in Edge,
|
|
|
|
// once we drop IE11 support we should be able to use native for...of
|
2018-01-05 18:38:23 +03:00
|
|
|
for (let i = 0; i < files.length; i += 1) {
|
2017-05-12 11:02:33 +03:00
|
|
|
let file = files[i];
|
2017-05-08 13:35:42 +03:00
|
|
|
let result = validate(file);
|
|
|
|
if (result === true) {
|
|
|
|
ok.push(file);
|
|
|
|
} else {
|
|
|
|
errors.push({fileName: file.name, message: result});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isEmpty(errors)) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.set('errors', errors);
|
|
|
|
this.onFailed(errors);
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
|
|
|
|
// we only check the file extension by default because IE doesn't always
|
|
|
|
// expose the mime-type, we'll rely on the API for final validation
|
|
|
|
_defaultValidator(file) {
|
|
|
|
let extensions = this.get('extensions');
|
|
|
|
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
|
|
|
|
|
2017-05-23 11:18:03 +03:00
|
|
|
// if extensions is falsy exit early and accept all files
|
|
|
|
if (!extensions) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
if (!isEmberArray(extensions)) {
|
|
|
|
extensions = extensions.split(',');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
|
|
|
|
let validExtensions = `.${extensions.join(', .').toUpperCase()}`;
|
|
|
|
return `The image type you uploaded is not supported. Please use ${validExtensions}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
|
|
|
|
_uploadFiles: task(function* (files) {
|
|
|
|
let uploads = [];
|
|
|
|
|
2017-05-23 11:50:04 +03:00
|
|
|
this._reset();
|
2018-02-22 23:41:40 +03:00
|
|
|
this.onStart(files);
|
2017-05-08 13:35:42 +03:00
|
|
|
|
2017-05-12 11:02:33 +03:00
|
|
|
// NOTE: for...of loop results in a transpilation that errors in Edge,
|
|
|
|
// once we drop IE11 support we should be able to use native for...of
|
2018-01-05 18:38:23 +03:00
|
|
|
for (let i = 0; i < files.length; i += 1) {
|
2017-10-09 14:21:57 +03:00
|
|
|
let file = files[i];
|
|
|
|
let tracker = new UploadTracker({file});
|
|
|
|
|
|
|
|
this.get('_uploadTrackers').pushObject(tracker);
|
|
|
|
uploads.push(this.get('_uploadFile').perform(tracker, file, i));
|
2017-05-08 13:35:42 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// populates this.errors and this.uploadUrls
|
|
|
|
yield all(uploads);
|
|
|
|
|
2017-05-23 11:18:03 +03:00
|
|
|
if (!isEmpty(this.get('errors'))) {
|
|
|
|
this.onFailed(this.get('errors'));
|
|
|
|
}
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
this.onComplete(this.get('uploadUrls'));
|
|
|
|
}).drop(),
|
|
|
|
|
2018-01-11 20:43:23 +03:00
|
|
|
// eslint-disable-next-line ghost/ember/order-in-components
|
2017-10-09 14:21:57 +03:00
|
|
|
_uploadFile: task(function* (tracker, file, index) {
|
2017-05-08 13:35:42 +03:00
|
|
|
let ajax = this.get('ajax');
|
|
|
|
let formData = this._getFormData(file);
|
|
|
|
let url = `${ghostPaths().apiRoot}${this.get('uploadUrl')}`;
|
|
|
|
|
|
|
|
try {
|
|
|
|
let response = yield ajax.post(url, {
|
|
|
|
data: formData,
|
|
|
|
processData: false,
|
|
|
|
contentType: false,
|
|
|
|
dataType: 'text',
|
|
|
|
xhr: () => {
|
|
|
|
let xhr = new window.XMLHttpRequest();
|
|
|
|
|
|
|
|
xhr.upload.addEventListener('progress', (event) => {
|
2017-05-23 11:18:03 +03:00
|
|
|
run(() => {
|
|
|
|
tracker.update(event);
|
|
|
|
this._updateProgress();
|
|
|
|
});
|
2017-05-08 13:35:42 +03:00
|
|
|
}, false);
|
|
|
|
|
|
|
|
return xhr;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2017-05-23 11:18:03 +03:00
|
|
|
// force tracker progress to 100% in case we didn't get a final event,
|
|
|
|
// eg. when using mirage
|
|
|
|
tracker.update({loaded: file.size, total: file.size});
|
|
|
|
this._updateProgress();
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
// TODO: is it safe to assume we'll only get a url back?
|
|
|
|
let uploadUrl = JSON.parse(response);
|
2017-05-23 11:18:03 +03:00
|
|
|
let result = {
|
2017-05-08 13:35:42 +03:00
|
|
|
fileName: file.name,
|
|
|
|
url: uploadUrl
|
2017-05-23 11:18:03 +03:00
|
|
|
};
|
|
|
|
|
2017-08-14 05:35:41 +03:00
|
|
|
this.get('uploadUrls')[index] = result;
|
2017-05-23 11:18:03 +03:00
|
|
|
this.onUploadSuccess(result);
|
2017-05-08 13:35:42 +03:00
|
|
|
|
|
|
|
return true;
|
|
|
|
} catch (error) {
|
2017-05-23 11:18:03 +03:00
|
|
|
// grab custom error message if present
|
2017-11-04 01:59:39 +03:00
|
|
|
let message = error.payload.errors && error.payload.errors[0].message;
|
2017-05-08 13:35:42 +03:00
|
|
|
|
2017-05-23 11:18:03 +03:00
|
|
|
// fall back to EmberData/ember-ajax default message for error type
|
|
|
|
if (!message) {
|
|
|
|
message = error.message;
|
|
|
|
}
|
|
|
|
|
|
|
|
let result = {
|
2017-05-08 13:35:42 +03:00
|
|
|
fileName: file.name,
|
2017-11-04 01:59:39 +03:00
|
|
|
message: error.payload.errors[0].message
|
2017-05-23 11:18:03 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
// TODO: check for or expose known error types?
|
|
|
|
this.get('errors').pushObject(result);
|
2017-09-21 18:01:40 +03:00
|
|
|
this.onUploadFailure(result);
|
2017-05-08 13:35:42 +03:00
|
|
|
}
|
2017-10-09 14:21:57 +03:00
|
|
|
}).maxConcurrency(MAX_SIMULTANEOUS_UPLOADS).enqueue(),
|
2017-05-08 13:35:42 +03:00
|
|
|
|
|
|
|
// NOTE: this is necessary because the API doesn't accept direct file uploads
|
|
|
|
_getFormData(file) {
|
|
|
|
let formData = new FormData();
|
2017-05-23 11:50:04 +03:00
|
|
|
formData.append(this.get('paramName'), file, file.name);
|
2017-05-08 13:35:42 +03:00
|
|
|
return formData;
|
|
|
|
},
|
|
|
|
|
|
|
|
// TODO: this was needed because using CPs directly resulted in infrequent updates
|
|
|
|
// - I think this was because updates were being wrapped up to save
|
|
|
|
// computation but that hypothesis needs testing
|
|
|
|
_updateProgress() {
|
|
|
|
let trackers = this._uploadTrackers;
|
2018-01-05 18:38:23 +03:00
|
|
|
let totalSize = trackers.reduce((total, tracker) => total + tracker.get('total'), 0);
|
|
|
|
let uploadedSize = trackers.reduce((total, tracker) => total + tracker.get('loaded'), 0);
|
2017-05-08 13:35:42 +03:00
|
|
|
|
|
|
|
this.set('totalSize', totalSize);
|
|
|
|
this.set('uploadedSize', uploadedSize);
|
|
|
|
|
|
|
|
if (totalSize === 0 || uploadedSize === 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let uploadPercentage = Math.round((uploadedSize / totalSize) * 100);
|
|
|
|
this.set('uploadPercentage', uploadPercentage);
|
|
|
|
},
|
|
|
|
|
|
|
|
_reset() {
|
2017-05-23 11:50:04 +03:00
|
|
|
this.set('errors', []);
|
2017-05-08 13:35:42 +03:00
|
|
|
this.set('totalSize', 0);
|
|
|
|
this.set('uploadedSize', 0);
|
|
|
|
this.set('uploadPercentage', 0);
|
2017-05-23 11:50:04 +03:00
|
|
|
this.set('uploadUrls', []);
|
2017-05-08 13:35:42 +03:00
|
|
|
this._uploadTrackers = [];
|
|
|
|
}
|
|
|
|
});
|