diff --git a/ghost/admin/app/components/gh-file-uploader.js b/ghost/admin/app/components/gh-file-uploader.js new file mode 100644 index 0000000000..8e82b7e97e --- /dev/null +++ b/ghost/admin/app/components/gh-file-uploader.js @@ -0,0 +1,150 @@ +import Ember from 'ember'; +import { invoke, invokeAction } from 'ember-invoke-action'; +import { + RequestEntityTooLargeError, + UnsupportedMediaTypeError +} from 'ghost/services/ajax'; + +const { + Component, + computed, + inject: {service}, + isBlank, + run +} = Ember; + +export default Component.extend({ + tagName: 'section', + classNames: ['gh-image-uploader'], + classNameBindings: ['dragClass'], + + labelText: 'Select or drag-and-drop a file', + url: null, + paramName: 'file', + + file: null, + response: null, + + dragClass: null, + failureMessage: null, + uploadPercentage: 0, + + ajax: service(), + + formData: computed('file', function () { + let paramName = this.get('paramName'); + let file = this.get('file'); + let formData = new FormData(); + + formData.append(paramName, file); + + return formData; + }), + + progressStyle: computed('uploadPercentage', function () { + let percentage = this.get('uploadPercentage'); + let width = ''; + + if (percentage > 0) { + width = `${percentage}%`; + } else { + width = '0'; + } + + return Ember.String.htmlSafe(`width: ${width}`); + }), + + dragOver(event) { + event.preventDefault(); + this.set('dragClass', '--drag-over'); + }, + + dragLeave(event) { + event.preventDefault(); + this.set('dragClass', null); + }, + + drop(event) { + event.preventDefault(); + this.set('dragClass', null); + if (event.dataTransfer.files) { + invoke(this, 'fileSelected', event.dataTransfer.files); + } + }, + + generateRequest() { + let ajax = this.get('ajax'); + let formData = this.get('formData'); + let url = this.get('url'); + + invokeAction(this, 'uploadStarted'); + + ajax.post(url, { + data: formData, + processData: false, + contentType: false, + dataType: 'text', + xhr: () => { + let xhr = new window.XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (event) => { + this._uploadProgress(event); + }, false); + + return xhr; + } + }).then((response) => { + this._uploadSuccess(JSON.parse(response)); + }).catch((error) => { + this._uploadFailed(error); + }).finally(() => { + invokeAction(this, 'uploadFinished'); + }); + }, + + _uploadProgress(event) { + if (event.lengthComputable) { + run(() => { + let percentage = Math.round((event.loaded / event.total) * 100); + this.set('uploadPercentage', percentage); + }); + } + }, + + _uploadSuccess(response) { + invokeAction(this, 'uploadSuccess', response); + invoke(this, 'reset'); + }, + + _uploadFailed(error) { + let message; + + if (error instanceof UnsupportedMediaTypeError) { + message = 'The file type you uploaded is not supported.'; + } else if (error instanceof RequestEntityTooLargeError) { + message = 'The file you uploaded was larger than the maximum file size your server allows.'; + } else if (error.errors && !isBlank(error.errors[0].message)) { + message = error.errors[0].message; + } else { + message = 'Something went wrong :('; + } + + this.set('failureMessage', message); + invokeAction(this, 'uploadFailed', error); + }, + + actions: { + fileSelected(fileList) { + this.set('file', fileList[0]); + run.schedule('actions', this, function () { + this.generateRequest(); + }); + }, + + reset() { + this.set('file', null); + this.set('uploadPercentage', 0); + this.set('failureMessage', null); + } + } +}); diff --git a/ghost/admin/app/components/gh-image-uploader.js b/ghost/admin/app/components/gh-image-uploader.js index 9988ff1d51..34d29a2153 100644 --- a/ghost/admin/app/components/gh-image-uploader.js +++ b/ghost/admin/app/components/gh-image-uploader.js @@ -28,7 +28,6 @@ export default Component.extend({ ajax: service(), config: service(), - session: service(), // TODO: this wouldn't be necessary if the server could accept direct // file uploads diff --git a/ghost/admin/app/components/gh-light-table.js b/ghost/admin/app/components/gh-light-table.js new file mode 100644 index 0000000000..010f311ae2 --- /dev/null +++ b/ghost/admin/app/components/gh-light-table.js @@ -0,0 +1,27 @@ +import Ember from 'ember'; +import LightTable from 'ember-light-table/components/light-table'; + +const {$, run} = Ember; + +export default LightTable.extend({ + + // HACK: infinite pagination was not triggering when scrolling very fast + // as the throttle triggers before scrolling into the buffer area but + // the scroll finishes before the throttle timeout. Adding a debounce that + // does the same thing means that we are guaranteed a final trigger when + // scrolling stops + // + // An issue has been opened upstream, this can be removed if it gets fixed + // https://github.com/offirgolan/ember-light-table/issues/15 + + _setupScrollEvents() { + $(this.get('touchMoveContainer')).on('touchmove.light-table', run.bind(this, this._scrollHandler, '_touchmoveTimer')); + $(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollTimer')); + $(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollDebounce')); + }, + + _scrollHandler(timer) { + this.set(timer, run.debounce(this, this._onScroll, 100)); + this.set(timer, run.throttle(this, this._onScroll, 100)); + } +}); diff --git a/ghost/admin/app/components/gh-nav-menu.js b/ghost/admin/app/components/gh-nav-menu.js index 2f50d069ac..b3098d167f 100644 --- a/ghost/admin/app/components/gh-nav-menu.js +++ b/ghost/admin/app/components/gh-nav-menu.js @@ -22,6 +22,7 @@ export default Component.extend({ config: service(), session: service(), ghostPaths: service(), + feature: service(), mouseEnter() { this.sendAction('onMouseEnter'); diff --git a/ghost/admin/app/components/gh-subscribers-table.js b/ghost/admin/app/components/gh-subscribers-table.js new file mode 100644 index 0000000000..cde011adff --- /dev/null +++ b/ghost/admin/app/components/gh-subscribers-table.js @@ -0,0 +1,17 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + classNames: ['subscribers-table'], + + table: null, + + actions: { + onScrolledToBottom() { + let loadNextPage = this.get('loadNextPage'); + + if (!this.get('isLoading')) { + loadNextPage(); + } + } + } +}); diff --git a/ghost/admin/app/components/modals/delete-subscriber.js b/ghost/admin/app/components/modals/delete-subscriber.js new file mode 100644 index 0000000000..ec8b58c765 --- /dev/null +++ b/ghost/admin/app/components/modals/delete-subscriber.js @@ -0,0 +1,23 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; +import {invokeAction} from 'ember-invoke-action'; + +const {computed} = Ember; +const {alias} = computed; + +export default ModalComponent.extend({ + + submitting: false, + + subscriber: alias('model'), + + actions: { + confirm() { + this.set('submitting', true); + + invokeAction(this, 'confirm').finally(() => { + this.set('submitting', false); + }); + } + } +}); diff --git a/ghost/admin/app/components/modals/import-subscribers.js b/ghost/admin/app/components/modals/import-subscribers.js new file mode 100644 index 0000000000..272c643be8 --- /dev/null +++ b/ghost/admin/app/components/modals/import-subscribers.js @@ -0,0 +1,43 @@ +import Ember from 'ember'; +import { invokeAction } from 'ember-invoke-action'; +import ModalComponent from 'ghost/components/modals/base'; +import ghostPaths from 'ghost/utils/ghost-paths'; + +const {computed} = Ember; + +export default ModalComponent.extend({ + labelText: 'Select or drag-and-drop a CSV File', + + response: null, + closeDisabled: false, + + uploadUrl: computed(function () { + return `${ghostPaths().apiRoot}/subscribers/csv/`; + }), + + actions: { + uploadStarted() { + this.set('closeDisabled', true); + }, + + uploadFinished() { + this.set('closeDisabled', false); + }, + + uploadSuccess(response) { + this.set('response', response.meta.stats); + // invoke the passed in confirm action + invokeAction(this, 'confirm'); + }, + + confirm() { + // noop - we don't want the enter key doing anything + }, + + closeModal() { + if (!this.get('closeDisabled')) { + this._super(...arguments); + } + } + } +}); diff --git a/ghost/admin/app/components/modals/new-subscriber.js b/ghost/admin/app/components/modals/new-subscriber.js new file mode 100644 index 0000000000..01e50958d7 --- /dev/null +++ b/ghost/admin/app/components/modals/new-subscriber.js @@ -0,0 +1,32 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; + +export default ModalComponent.extend({ + actions: { + updateEmail(newEmail) { + this.set('model.email', newEmail); + this.set('model.hasValidated', Ember.A()); + this.get('model.errors').clear(); + }, + + confirm() { + let confirmAction = this.get('confirm'); + + this.set('submitting', true); + + confirmAction().then(() => { + this.send('closeModal'); + }).catch((errors) => { + let [error] = errors; + if (error && error.match(/email/)) { + this.get('model.errors').add('email', error); + this.get('model.hasValidated').pushObject('email'); + } + }).finally(() => { + if (!this.get('isDestroying') && !this.get('isDestroyed')) { + this.set('submitting', false); + } + }); + } + } +}); diff --git a/ghost/admin/app/controllers/subscribers.js b/ghost/admin/app/controllers/subscribers.js new file mode 100644 index 0000000000..1c39bcf0f2 --- /dev/null +++ b/ghost/admin/app/controllers/subscribers.js @@ -0,0 +1,162 @@ +import Ember from 'ember'; +import Table from 'ember-light-table'; +import PaginationMixin from 'ghost/mixins/pagination'; +import ghostPaths from 'ghost/utils/ghost-paths'; + +const { + $, + assign, + computed, + inject: {service} +} = Ember; + +export default Ember.Controller.extend(PaginationMixin, { + + queryParams: ['order', 'direction'], + order: 'created_at', + direction: 'desc', + + paginationModel: 'subscriber', + + total: 0, + table: null, + subscriberToDelete: null, + + session: service(), + + // paginationSettings is replaced by the pagination mixin so we need a + // getter/setter CP here so that we don't lose the dynamic order param + paginationSettings: computed('order', 'direction', { + get() { + let order = this.get('order'); + let direction = this.get('direction'); + + let currentSettings = this._paginationSettings || { + limit: 30 + }; + + return assign({}, currentSettings, { + order: `${order} ${direction}` + }); + }, + set(key, value) { + this._paginationSettings = value; + return value; + } + }), + + columns: computed('order', 'direction', function () { + let order = this.get('order'); + let direction = this.get('direction'); + + return [{ + label: 'Subscriber', + valuePath: 'email', + sorted: order === 'email', + ascending: direction === 'asc' + }, { + label: 'Subscription Date', + valuePath: 'createdAt', + format(value) { + return value.format('MMMM DD, YYYY'); + }, + sorted: order === 'created_at', + ascending: direction === 'asc' + }, { + label: 'Status', + valuePath: 'status', + sorted: order === 'status', + ascending: direction === 'asc' + }, { + label: '', + sortable: false, + cellComponent: 'gh-subscribers-table-delete-cell', + align: 'right' + }]; + }), + + initializeTable() { + this.set('table', new Table(this.get('columns'), this.get('subscribers'))); + }, + + // capture the total from the server any time we fetch a new page + didReceivePaginationMeta(meta) { + if (meta && meta.pagination) { + this.set('total', meta.pagination.total); + } + }, + + actions: { + loadFirstPage() { + let table = this.get('table'); + + return this._super(...arguments).then((results) => { + table.addRows(results); + return results; + }); + }, + + loadNextPage() { + let table = this.get('table'); + + return this._super(...arguments).then((results) => { + table.addRows(results); + return results; + }); + }, + + sortByColumn(column) { + let table = this.get('table'); + + if (column.sorted) { + this.setProperties({ + order: column.get('valuePath').trim().underscore(), + direction: column.ascending ? 'asc' : 'desc' + }); + table.setRows([]); + this.send('loadFirstPage'); + } + }, + + addSubscriber(subscriber) { + this.get('table').insertRowAt(0, subscriber); + this.incrementProperty('total'); + }, + + deleteSubscriber(subscriber) { + this.set('subscriberToDelete', subscriber); + }, + + confirmDeleteSubscriber() { + let subscriber = this.get('subscriberToDelete'); + + return subscriber.destroyRecord().then(() => { + this.set('subscriberToDelete', null); + this.get('table').removeRow(subscriber); + this.decrementProperty('total'); + }); + }, + + cancelDeleteSubscriber() { + this.set('subscriberToDelete', null); + }, + + reset() { + this.get('table').setRows([]); + this.send('loadFirstPage'); + }, + + exportData() { + let exportUrl = ghostPaths().url.api('subscribers/csv'); + let accessToken = this.get('session.data.authenticated.access_token'); + let downloadURL = `${exportUrl}?access_token=${accessToken}`; + let iframe = $('#iframeDownload'); + + if (iframe.length === 0) { + iframe = $('