diff --git a/core/client/app/components/gh-ed-editor.js b/core/client/app/components/gh-ed-editor.js index e0385f9105..2db284284e 100644 --- a/core/client/app/components/gh-ed-editor.js +++ b/core/client/app/components/gh-ed-editor.js @@ -57,5 +57,11 @@ export default TextArea.extend(EditorAPI, EditorShortcuts, EditorScroll, { enable() { let textarea = this.get('element'); textarea.removeAttribute('readonly'); + }, + + actions: { + toggleCopyHTMLModal(generatedHTML) { + this.attrs.toggleCopyHTMLModal(generatedHTML); + } } }); diff --git a/core/client/app/components/gh-editor.js b/core/client/app/components/gh-editor.js index d3824ac6d7..e56075a49c 100644 --- a/core/client/app/components/gh-editor.js +++ b/core/client/app/components/gh-editor.js @@ -7,6 +7,9 @@ export default Component.extend({ tagName: 'section', classNames: ['gh-view'], + showCopyHTMLModal: false, + copyHTMLModalContent: null, + // updated when gh-ed-editor component scrolls editorScrollInfo: null, // updated when markdown is rendered @@ -58,6 +61,11 @@ export default Component.extend({ actions: { selectTab(tab) { this.set('activeTab', tab); + }, + + toggleCopyHTMLModal(generatedHTML) { + this.set('copyHTMLModalContent', generatedHTML); + this.toggleProperty('showCopyHTMLModal'); } } }); diff --git a/core/client/app/components/gh-fullscreen-modal.js b/core/client/app/components/gh-fullscreen-modal.js new file mode 100644 index 0000000000..16ee24e8af --- /dev/null +++ b/core/client/app/components/gh-fullscreen-modal.js @@ -0,0 +1,76 @@ +import Ember from 'ember'; +import LiquidTether from 'liquid-tether/components/liquid-tether'; + +const {RSVP, isBlank, on, run} = Ember; +const emberA = Ember.A; + +const FullScreenModalComponent = LiquidTether.extend({ + to: 'fullscreen-modal', + target: 'document.body', + targetModifier: 'visible', + targetAttachment: 'top center', + attachment: 'top center', + tetherClass: 'fullscreen-modal', + overlayClass: 'fullscreen-modal-background', + modalPath: 'unknown', + + dropdown: Ember.inject.service(), + + init() { + this._super(...arguments); + this.modalPath = `modals/${this.get('modal')}`; + }, + + setTetherClass: on('init', function () { + let tetherClass = this.get('tetherClass'); + let modifiers = (this.get('modifier') || '').split(' '); + let tetherClasses = emberA([tetherClass]); + + modifiers.forEach((modifier) => { + if (!isBlank(modifier)) { + let className = `${tetherClass}-${modifier}`; + tetherClasses.push(className); + } + }); + + this.set('tetherClass', tetherClasses.join(' ')); + }), + + closeDropdowns: on('didInsertElement', function () { + run.schedule('afterRender', this, function () { + this.get('dropdown').closeDropdowns(); + }); + }), + + actions: { + close() { + if (this.attrs.close) { + return this.attrs.close(); + } + + return new RSVP.Promise((resolve) => { + resolve(); + }); + }, + + confirm() { + if (this.attrs.confirm) { + return this.attrs.confirm(); + } + + return new RSVP.Promise((resolve) => { + resolve(); + }); + }, + + clickOverlay() { + this.send('close'); + } + } +}); + +FullScreenModalComponent.reopenClass({ + positionalParams: ['modal'] +}); + +export default FullScreenModalComponent; diff --git a/core/client/app/components/gh-modal-dialog.js b/core/client/app/components/gh-modal-dialog.js deleted file mode 100644 index 14f4235385..0000000000 --- a/core/client/app/components/gh-modal-dialog.js +++ /dev/null @@ -1,67 +0,0 @@ -import Ember from 'ember'; - -const {Component, computed} = Ember; - -function K() { - return this; -} - -export default Component.extend({ - - confirmaccept: 'confirmAccept', - confirmreject: 'confirmReject', - - klass: computed('type', 'style', function () { - let classNames = []; - - classNames.push(this.get('type') ? `modal-${this.get('type')}` : 'modal'); - - if (this.get('style')) { - this.get('style').split(',').forEach((style) => { - classNames.push(`modal-style-${style}`); - }); - } - - return classNames.join(' '); - }), - - acceptButtonClass: computed('confirm.accept.buttonClass', function () { - return this.get('confirm.accept.buttonClass') ? this.get('confirm.accept.buttonClass') : 'btn btn-green'; - }), - - rejectButtonClass: computed('confirm.reject.buttonClass', function () { - return this.get('confirm.reject.buttonClass') ? this.get('confirm.reject.buttonClass') : 'btn btn-red'; - }), - - didInsertElement() { - this._super(...arguments); - this.$('.js-modal-container, .js-modal-background').addClass('fade-in open'); - this.$('.js-modal').addClass('open'); - }, - - close() { - this.$('.js-modal, .js-modal-background').removeClass('fade-in').addClass('fade-out'); - - // The background should always be the last thing to fade out, so check on that instead of the content - this.$('.js-modal-background').on('animationend webkitAnimationEnd oanimationend MSAnimationEnd', (event) => { - if (event.originalEvent.animationName === 'fade-out') { - this.$('.js-modal, .js-modal-background').removeClass('open'); - } - }); - - this.sendAction(); - }, - - actions: { - closeModal() { - this.close(); - }, - - confirm(type) { - this.sendAction(`confirm${type}`); - this.close(); - }, - - noBubble: K - } -}); diff --git a/core/client/app/components/gh-nav-menu.js b/core/client/app/components/gh-nav-menu.js index def7a79599..e5ce0eac32 100644 --- a/core/client/app/components/gh-nav-menu.js +++ b/core/client/app/components/gh-nav-menu.js @@ -21,8 +21,8 @@ export default Component.extend({ this.sendAction('toggleMaximise'); }, - openModal(modal) { - this.sendAction('openModal', modal); + showMarkdownHelp() { + this.sendAction('showMarkdownHelp'); }, closeMobileMenu() { diff --git a/core/client/app/components/gh-tag-settings-form.js b/core/client/app/components/gh-tag-settings-form.js index 0e16aae2e4..7e9aaef169 100644 --- a/core/client/app/components/gh-tag-settings-form.js +++ b/core/client/app/components/gh-tag-settings-form.js @@ -125,7 +125,7 @@ export default Component.extend({ }, deleteTag() { - this.sendAction('openModal', 'delete-tag', this.get('tag')); + this.attrs.showDeleteTagModal(); } } diff --git a/core/client/app/components/gh-upload-modal.js b/core/client/app/components/gh-upload-modal.js deleted file mode 100644 index 1f39601d8b..0000000000 --- a/core/client/app/components/gh-upload-modal.js +++ /dev/null @@ -1,82 +0,0 @@ -import Ember from 'ember'; -import ModalDialog from 'ghost/components/gh-modal-dialog'; -import upload from 'ghost/assets/lib/uploader'; -import cajaSanitizers from 'ghost/utils/caja-sanitizers'; - -const {inject, isEmpty} = Ember; - -export default ModalDialog.extend({ - layoutName: 'components/gh-modal-dialog', - - config: inject.service(), - - didInsertElement() { - this._super(...arguments); - upload.call(this.$('.js-drop-zone'), {fileStorage: this.get('config.fileStorage')}); - }, - - keyDown() { - this.setErrorState(false); - }, - - setErrorState(state) { - if (state) { - this.$('.js-upload-url').addClass('error'); - } else { - this.$('.js-upload-url').removeClass('error'); - } - }, - - confirm: { - reject: { - buttonClass: 'btn btn-default', - text: 'Cancel', // The reject button text - func() { // The function called on rejection - return true; - } - }, - - accept: { - buttonClass: 'btn btn-blue right', - text: 'Save', // The accept button text: 'Save' - func() { - let imageType = `model.${this.get('imageType')}`; - let value; - - if (this.$('.js-upload-url').val()) { - value = this.$('.js-upload-url').val(); - - if (!isEmpty(value) && !cajaSanitizers.url(value)) { - this.setErrorState(true); - return {message: 'Image URI is not valid'}; - } - } else { - value = this.$('.js-upload-target').attr('src'); - } - - this.set(imageType, value); - return true; - } - } - }, - - actions: { - closeModal() { - this.sendAction(); - }, - - confirm(type) { - let func = this.get(`confirm.${type}.func`); - let result; - - if (typeof func === 'function') { - result = func.apply(this); - } - - if (!result.message) { - this.sendAction(); - this.sendAction(`confirm${type}`); - } - } - } -}); diff --git a/core/client/app/components/gh-user-invited.js b/core/client/app/components/gh-user-invited.js index 192b9344c9..eaca086c53 100644 --- a/core/client/app/components/gh-user-invited.js +++ b/core/client/app/components/gh-user-invited.js @@ -31,8 +31,7 @@ export default Component.extend({ notifications.showAlert('Invitation email was not sent. Please try resending.', {type: 'error', key: 'invite.resend.not-sent'}); } else { user.set('status', result.users[0].status); - notifications.showNotification(notificationText); - notifications.closeAlerts('invite.resend'); + notifications.showNotification(notificationText, {key: 'invite.resend.success'}); } }).catch((error) => { notifications.showAPIError(error, {key: 'invite.resend'}); @@ -51,8 +50,7 @@ export default Component.extend({ if (user.get('invited')) { user.destroyRecord().then(() => { let notificationText = `Invitation revoked. (${email})`; - notifications.showNotification(notificationText); - notifications.closeAlerts('invite.revoke'); + notifications.showNotification(notificationText, {key: 'invite.revoke.success'}); }).catch((error) => { notifications.showAPIError(error, {key: 'invite.revoke'}); }); diff --git a/core/client/app/components/modals/base.js b/core/client/app/components/modals/base.js new file mode 100644 index 0000000000..40951062be --- /dev/null +++ b/core/client/app/components/modals/base.js @@ -0,0 +1,45 @@ +/* global key */ +import Ember from 'ember'; + +const {Component, on, run} = Ember; + +export default Component.extend({ + tagName: 'section', + classNames: 'modal-content', + + _previousKeymasterScope: null, + + setupShortcuts: on('didInsertElement', function () { + run(function () { + document.activeElement.blur(); + }); + this._previousKeymasterScope = key.getScope(); + + key('enter', 'modal', () => { + this.send('confirm'); + }); + + key('escape', 'modal', () => { + this.send('closeModal'); + }); + + key.setScope('modal'); + }), + + removeShortcuts: on('willDestroyElement', function () { + key.unbind('enter', 'modal'); + key.unbind('escape', 'modal'); + + key.setScope(this._previousKeymasterScope); + }), + + actions: { + confirm() { + throw new Error('You must override the "confirm" action in your modal component'); + }, + + closeModal() { + this.attrs.closeModal(); + } + } +}); diff --git a/core/client/app/components/modals/copy-html.js b/core/client/app/components/modals/copy-html.js new file mode 100644 index 0000000000..078b0b12be --- /dev/null +++ b/core/client/app/components/modals/copy-html.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; + +const {computed} = Ember; +const {alias} = computed; + +export default ModalComponent.extend({ + generatedHtml: alias('model') +}); diff --git a/core/client/app/components/modals/delete-all.js b/core/client/app/components/modals/delete-all.js new file mode 100644 index 0000000000..51d792ae6c --- /dev/null +++ b/core/client/app/components/modals/delete-all.js @@ -0,0 +1,48 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; +import {request as ajax} from 'ic-ajax'; + +const {inject} = Ember; + +export default ModalComponent.extend({ + + submitting: false, + + ghostPaths: inject.service('ghost-paths'), + notifications: inject.service(), + store: inject.service(), + + _deleteAll() { + return ajax(this.get('ghostPaths.url').api('db'), { + type: 'DELETE' + }); + }, + + _unloadData() { + this.get('store').unloadAll('post'); + this.get('store').unloadAll('tag'); + }, + + _showSuccess() { + this.get('notifications').showAlert('All content deleted from database.', {type: 'success', key: 'all-content.delete.success'}); + }, + + _showFailure(error) { + this.get('notifications').showAPIError(error, {key: 'all-content.delete'}); + }, + + actions: { + confirm() { + this.set('submitting', true); + + this._deleteAll().then(() => { + this._unloadData(); + this._showSuccess(); + }).catch((error) => { + this._showFailure(error); + }).finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/core/client/app/components/modals/delete-post.js b/core/client/app/components/modals/delete-post.js new file mode 100644 index 0000000000..3353b317e2 --- /dev/null +++ b/core/client/app/components/modals/delete-post.js @@ -0,0 +1,51 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; + +const {computed, inject} = Ember; +const {alias} = computed; + +export default ModalComponent.extend({ + + submitting: false, + + post: alias('model'), + + notifications: inject.service(), + routing: inject.service('-routing'), + + _deletePost() { + let post = this.get('post'); + + // definitely want to clear the data store and post of any unsaved, + // client-generated tags + post.updateTags(); + + return post.destroyRecord(); + }, + + _success() { + // clear any previous error messages + this.get('notifications').closeAlerts('post.delete'); + + // redirect to content screen + this.get('routing').transitionTo('posts'); + }, + + _failure() { + this.get('notifications').showAlert('Your post could not be deleted. Please try again.', {type: 'error', key: 'post.delete.failed'}); + }, + + actions: { + confirm() { + this.set('submitting', true); + + this._deletePost().then(() => { + this._success(); + }, () => { + this._failure(); + }).finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/core/client/app/components/modals/delete-tag.js b/core/client/app/components/modals/delete-tag.js new file mode 100644 index 0000000000..9ddd21dbd5 --- /dev/null +++ b/core/client/app/components/modals/delete-tag.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; + +const {computed} = Ember; +const {alias} = computed; + +export default ModalComponent.extend({ + + submitting: false, + + tag: alias('model'), + + postInflection: computed('tag.count.posts', function () { + return this.get('tag.count.posts') > 1 ? 'posts' : 'post'; + }), + + actions: { + confirm() { + this.set('submitting', true); + + this.attrs.confirm().finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/core/client/app/components/modals/delete-user.js b/core/client/app/components/modals/delete-user.js new file mode 100644 index 0000000000..7cccf0be55 --- /dev/null +++ b/core/client/app/components/modals/delete-user.js @@ -0,0 +1,18 @@ +import ModalComponent from 'ghost/components/modals/base'; + +export default ModalComponent.extend({ + + submitting: false, + + user: null, + + actions: { + confirm() { + this.set('submitting', true); + + this.attrs.confirm().finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/core/client/app/components/modals/invite-new-user.js b/core/client/app/components/modals/invite-new-user.js new file mode 100644 index 0000000000..f0fc52b93a --- /dev/null +++ b/core/client/app/components/modals/invite-new-user.js @@ -0,0 +1,121 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; +import ValidationEngine from 'ghost/mixins/validation-engine'; + +const {RSVP, inject, run} = Ember; +const emberA = Ember.A; + +export default ModalComponent.extend(ValidationEngine, { + classNames: 'modal-content invite-new-user', + + role: null, + roles: null, + authorRole: null, + submitting: false, + + validationType: 'inviteUser', + + notifications: inject.service(), + store: inject.service(), + + init() { + this._super(...arguments); + + // populate roles and set initial value for the dropdown + run.schedule('afterRender', this, function () { + this.get('store').query('role', {permissions: 'assign'}).then((roles) => { + let authorRole = roles.findBy('name', 'Author'); + + this.set('roles', roles); + this.set('authorRole', authorRole); + + if (!this.get('role')) { + this.set('role', authorRole); + } + }); + }); + }, + + willDestroyElement() { + this._super(...arguments); + // TODO: this should not be needed, ValidationEngine acts as a + // singleton and so it's errors and hasValidated state stick around + this.get('errors').clear(); + this.set('hasValidated', emberA()); + }, + + validate() { + let email = this.get('email'); + + // TODO: either the validator should check the email's existence or + // the API should return an appropriate error when attempting to save + return new RSVP.Promise((resolve, reject) => { + return this._super().then(() => { + this.get('store').findAll('user', {reload: true}).then((result) => { + let invitedUser = result.findBy('email', email); + + if (invitedUser) { + this.get('errors').clear('email'); + if (invitedUser.get('status') === 'invited' || invitedUser.get('status') === 'invited-pending') { + this.get('errors').add('email', 'A user with that email address was already invited.'); + } else { + this.get('errors').add('email', 'A user with that email address already exists.'); + } + + // TODO: this shouldn't be needed, ValidationEngine doesn't mark + // properties as validated when validating an entire object + this.get('hasValidated').addObject('email'); + reject(); + } else { + resolve(); + } + }); + }, () => { + // TODO: this shouldn't be needed, ValidationEngine doesn't mark + // properties as validated when validating an entire object + this.get('hasValidated').addObject('email'); + reject(); + }); + }); + }, + + actions: { + setRole(role) { + this.set('role', role); + }, + + confirm() { + let email = this.get('email'); + let role = this.get('role'); + let notifications = this.get('notifications'); + let newUser; + + this.validate().then(() => { + this.set('submitting', true); + + newUser = this.get('store').createRecord('user', { + email, + role, + status: 'invited' + }); + + newUser.save().then(() => { + let notificationText = `Invitation sent! (${email})`; + + // If sending the invitation email fails, the API will still return a status of 201 + // but the user's status in the response object will be 'invited-pending'. + if (newUser.get('status') === 'invited-pending') { + notifications.showAlert('Invitation email was not sent. Please try resending.', {type: 'error', key: 'invite.send.failed'}); + } else { + notifications.showNotification(notificationText, {key: 'invite.send.success'}); + } + }).catch((errors) => { + newUser.deleteRecord(); + notifications.showErrors(errors, {key: 'invite.send'}); + }).finally(() => { + this.send('closeModal'); + }); + }); + } + } +}); diff --git a/core/client/app/components/modals/leave-editor.js b/core/client/app/components/modals/leave-editor.js new file mode 100644 index 0000000000..498c2a37ea --- /dev/null +++ b/core/client/app/components/modals/leave-editor.js @@ -0,0 +1,11 @@ +import ModalComponent from 'ghost/components/modals/base'; + +export default ModalComponent.extend({ + actions: { + confirm() { + this.attrs.confirm().finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/core/client/app/components/modals/markdown-help.js b/core/client/app/components/modals/markdown-help.js new file mode 100644 index 0000000000..7827870b6b --- /dev/null +++ b/core/client/app/components/modals/markdown-help.js @@ -0,0 +1,4 @@ +import ModalComponent from 'ghost/components/modals/base'; + +export default ModalComponent.extend({ +}); diff --git a/core/client/app/components/modals/re-authenticate.js b/core/client/app/components/modals/re-authenticate.js new file mode 100644 index 0000000000..91c18c91f7 --- /dev/null +++ b/core/client/app/components/modals/re-authenticate.js @@ -0,0 +1,64 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; +import ValidationEngine from 'ghost/mixins/validation-engine'; + +const {$, computed, inject} = Ember; + +export default ModalComponent.extend(ValidationEngine, { + validationType: 'signin', + + submitting: false, + authenticationError: null, + + notifications: inject.service(), + session: inject.service(), + + identification: computed('session.user.email', function () { + return this.get('session.user.email'); + }), + + _authenticate() { + let session = this.get('session'); + let authStrategy = 'authenticator:oauth2'; + let identification = this.get('identification'); + let password = this.get('password'); + + session.set('skipAuthSuccessHandler', true); + + this.toggleProperty('submitting'); + + return session.authenticate(authStrategy, identification, password).finally(() => { + this.toggleProperty('submitting'); + session.set('skipAuthSuccessHandler', undefined); + }); + }, + + actions: { + confirm() { + // Manually trigger events for input fields, ensuring legacy compatibility with + // browsers and password managers that don't send proper events on autofill + $('#login').find('input').trigger('change'); + + this.set('authenticationError', null); + + this.validate({property: 'signin'}).then(() => { + this._authenticate().then(() => { + this.get('notifications').closeAlerts('post.save'); + this.send('closeModal'); + }).catch((error) => { + if (error && error.errors) { + error.errors.forEach((err) => { + err.message = Ember.String.htmlSafe(err.message); + }); + + this.get('errors').add('password', 'Incorrect password'); + this.get('hasValidated').pushObject('password'); + this.set('authenticationError', error.errors[0].message); + } + }); + }, () => { + this.get('hasValidated').pushObject('password'); + }); + } + } +}); diff --git a/core/client/app/components/modals/transfer-owner.js b/core/client/app/components/modals/transfer-owner.js new file mode 100644 index 0000000000..d4e432e8c6 --- /dev/null +++ b/core/client/app/components/modals/transfer-owner.js @@ -0,0 +1,17 @@ +import ModalComponent from 'ghost/components/modals/base'; + +export default ModalComponent.extend({ + + user: null, + submitting: false, + + actions: { + confirm() { + this.set('submitting', true); + + this.attrs.confirm().finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/core/client/app/components/modals/upload-image.js b/core/client/app/components/modals/upload-image.js new file mode 100644 index 0000000000..f14b975344 --- /dev/null +++ b/core/client/app/components/modals/upload-image.js @@ -0,0 +1,86 @@ +import Ember from 'ember'; +import ModalComponent from 'ghost/components/modals/base'; +import upload from 'ghost/assets/lib/uploader'; +import cajaSanitizers from 'ghost/utils/caja-sanitizers'; + +const {computed, inject, isEmpty} = Ember; + +export default ModalComponent.extend({ + + acceptEncoding: 'image/*', + model: null, + submitting: false, + + config: inject.service(), + notifications: inject.service(), + + imageUrl: computed('model.model', 'model.imageProperty', { + get() { + let imageProperty = this.get('model.imageProperty'); + + return this.get(`model.model.${imageProperty}`); + }, + + set(key, value) { + let model = this.get('model.model'); + let imageProperty = this.get('model.imageProperty'); + + return model.set(imageProperty, value); + } + }), + + didInsertElement() { + this._super(...arguments); + upload.call(this.$('.js-drop-zone'), { + fileStorage: this.get('config.fileStorage') + }); + }, + + keyDown() { + this._setErrorState(false); + }, + + _setErrorState(state) { + if (state) { + this.$('.js-upload-url').addClass('error'); + } else { + this.$('.js-upload-url').removeClass('error'); + } + }, + + _setImageProperty() { + let value; + + if (this.$('.js-upload-url').val()) { + value = this.$('.js-upload-url').val(); + + if (!isEmpty(value) && !cajaSanitizers.url(value)) { + this._setErrorState(true); + return {message: 'Image URI is not valid'}; + } + } else { + value = this.$('.js-upload-target').attr('src'); + } + + this.set('imageUrl', value); + return true; + }, + + actions: { + confirm() { + let model = this.get('model.model'); + let notifications = this.get('notifications'); + let result = this._setImageProperty(); + + if (!result.message) { + this.set('submitting', true); + + model.save().catch((err) => { + notifications.showAPIError(err, {key: 'image.upload'}); + }).finally(() => { + this.send('closeModal'); + }); + } + } + } +}); diff --git a/core/client/app/controllers/application.js b/core/client/app/controllers/application.js index 3b5bb2fb82..d155ffa2c4 100644 --- a/core/client/app/controllers/application.js +++ b/core/client/app/controllers/application.js @@ -10,6 +10,7 @@ export default Controller.extend({ topNotificationCount: 0, showMobileMenu: false, showSettingsMenu: false, + showMarkdownHelpModal: false, autoNav: false, autoNavOpen: computed('autoNav', { diff --git a/core/client/app/controllers/editor/edit.js b/core/client/app/controllers/editor/edit.js index 828e21f746..7cb7b57523 100644 --- a/core/client/app/controllers/editor/edit.js +++ b/core/client/app/controllers/editor/edit.js @@ -4,9 +4,11 @@ import EditorControllerMixin from 'ghost/mixins/editor-base-controller'; const {Controller} = Ember; export default Controller.extend(EditorControllerMixin, { + showDeletePostModal: false, + actions: { - openDeleteModal() { - this.send('openModal', 'delete-post', this.get('model')); + toggleDeletePostModal() { + this.toggleProperty('showDeletePostModal'); } } }); diff --git a/core/client/app/controllers/modals/copy-html.js b/core/client/app/controllers/modals/copy-html.js deleted file mode 100644 index 7905d43949..0000000000 --- a/core/client/app/controllers/modals/copy-html.js +++ /dev/null @@ -1,8 +0,0 @@ -import Ember from 'ember'; - -const {Controller, computed} = Ember; -const {alias} = computed; - -export default Controller.extend({ - generatedHTML: alias('model.generatedHTML') -}); diff --git a/core/client/app/controllers/modals/delete-all.js b/core/client/app/controllers/modals/delete-all.js deleted file mode 100644 index 3980a3e25a..0000000000 --- a/core/client/app/controllers/modals/delete-all.js +++ /dev/null @@ -1,38 +0,0 @@ -import Ember from 'ember'; -import {request as ajax} from 'ic-ajax'; - -const {Controller, inject} = Ember; - -export default Controller.extend({ - ghostPaths: inject.service('ghost-paths'), - notifications: inject.service(), - - confirm: { - accept: { - text: 'Delete', - buttonClass: 'btn btn-red' - }, - reject: { - text: 'Cancel', - buttonClass: 'btn btn-default btn-minor' - } - }, - - actions: { - confirmAccept() { - ajax(this.get('ghostPaths.url').api('db'), { - type: 'DELETE' - }).then(() => { - this.get('notifications').showAlert('All content deleted from database.', {type: 'success', key: 'all-content.delete.success'}); - this.store.unloadAll('post'); - this.store.unloadAll('tag'); - }).catch((response) => { - this.get('notifications').showAPIError(response, {key: 'all-content.delete'}); - }); - }, - - confirmReject() { - return false; - } - } -}); diff --git a/core/client/app/controllers/modals/delete-post.js b/core/client/app/controllers/modals/delete-post.js deleted file mode 100644 index 560f1e4ff7..0000000000 --- a/core/client/app/controllers/modals/delete-post.js +++ /dev/null @@ -1,40 +0,0 @@ -import Ember from 'ember'; - -const {Controller, inject} = Ember; - -export default Controller.extend({ - dropdown: inject.service(), - notifications: inject.service(), - - confirm: { - accept: { - text: 'Delete', - buttonClass: 'btn btn-red' - }, - reject: { - text: 'Cancel', - buttonClass: 'btn btn-default btn-minor' - } - }, - - actions: { - confirmAccept() { - let model = this.get('model'); - - // definitely want to clear the data store and post of any unsaved, client-generated tags - model.updateTags(); - - model.destroyRecord().then(() => { - this.get('dropdown').closeDropdowns(); - this.get('notifications').closeAlerts('post.delete'); - this.transitionToRoute('posts.index'); - }, () => { - this.get('notifications').showAlert('Your post could not be deleted. Please try again.', {type: 'error', key: 'post.delete.failed'}); - }); - }, - - confirmReject() { - return false; - } - } -}); diff --git a/core/client/app/controllers/modals/delete-tag.js b/core/client/app/controllers/modals/delete-tag.js deleted file mode 100644 index 08c1dfa5d2..0000000000 --- a/core/client/app/controllers/modals/delete-tag.js +++ /dev/null @@ -1,45 +0,0 @@ -import Ember from 'ember'; - -const {Controller, computed, inject} = Ember; - -export default Controller.extend({ - application: inject.controller(), - notifications: inject.service(), - - postInflection: computed('model.count.posts', function () { - return this.get('model.count.posts') > 1 ? 'posts' : 'post'; - }), - - actions: { - confirmAccept() { - let tag = this.get('model'); - - this.send('closeMenus'); - - tag.destroyRecord().then(() => { - let currentRoute = this.get('application.currentRouteName') || ''; - - if (currentRoute.match(/^settings\.tags/)) { - this.transitionToRoute('settings.tags.index'); - } - }).catch((error) => { - this.get('notifications').showAPIError(error, {key: 'tag.delete'}); - }); - }, - - confirmReject() { - return false; - } - }, - - confirm: { - accept: { - text: 'Delete', - buttonClass: 'btn btn-red' - }, - reject: { - text: 'Cancel', - buttonClass: 'btn btn-default btn-minor' - } - } -}); diff --git a/core/client/app/controllers/modals/delete-user.js b/core/client/app/controllers/modals/delete-user.js deleted file mode 100644 index 8844b37cd9..0000000000 --- a/core/client/app/controllers/modals/delete-user.js +++ /dev/null @@ -1,56 +0,0 @@ -import Ember from 'ember'; - -const {Controller, PromiseProxyMixin, computed, inject} = Ember; -const {alias} = computed; - -export default Controller.extend({ - notifications: inject.service(), - - userPostCount: computed('model.id', function () { - let query = { - filter: `author:${this.get('model.slug')}`, - status: 'all' - }; - - let promise = this.store.query('post', query).then((results) => { - return results.meta.pagination.total; - }); - - return Ember.Object.extend(PromiseProxyMixin, { - count: alias('content'), - - inflection: computed('count', function () { - return this.get('count') > 1 ? 'posts' : 'post'; - }) - }).create({promise}); - }), - - confirm: { - accept: { - text: 'Delete User', - buttonClass: 'btn btn-red' - }, - reject: { - text: 'Cancel', - buttonClass: 'btn btn-default btn-minor' - } - }, - - actions: { - confirmAccept() { - let user = this.get('model'); - - user.destroyRecord().then(() => { - this.get('notifications').closeAlerts('user.delete'); - this.store.unloadAll('post'); - this.transitionToRoute('team'); - }, () => { - this.get('notifications').showAlert('The user could not be deleted. Please try again.', {type: 'error', key: 'user.delete.failed'}); - }); - }, - - confirmReject() { - return false; - } - } -}); diff --git a/core/client/app/controllers/modals/invite-new-user.js b/core/client/app/controllers/modals/invite-new-user.js deleted file mode 100644 index 8a8b8b9592..0000000000 --- a/core/client/app/controllers/modals/invite-new-user.js +++ /dev/null @@ -1,104 +0,0 @@ -import Ember from 'ember'; -import ValidationEngine from 'ghost/mixins/validation-engine'; - -const {Controller, computed, inject, observer} = Ember; - -export default Controller.extend(ValidationEngine, { - notifications: inject.service(), - - validationType: 'signup', - - role: null, - authorRole: null, - - roles: computed(function () { - return this.store.query('role', {permissions: 'assign'}); - }), - - // Used to set the initial value for the dropdown - authorRoleObserver: observer('roles.@each.role', function () { - this.get('roles').then((roles) => { - let authorRole = roles.findBy('name', 'Author'); - - this.set('authorRole', authorRole); - - if (!this.get('role')) { - this.set('role', authorRole); - } - }); - }), - - confirm: { - accept: { - text: 'send invitation now' - }, - reject: { - buttonClass: 'hidden' - } - }, - - confirmReject() { - return false; - }, - - actions: { - setRole(role) { - this.set('role', role); - }, - - confirmAccept() { - let email = this.get('email'); - let role = this.get('role'); - let validationErrors = this.get('errors.messages'); - let newUser; - - // reset the form and close the modal - this.set('email', ''); - this.set('role', this.get('authorRole')); - - this.store.findAll('user', {reload: true}).then((result) => { - let invitedUser = result.findBy('email', email); - - if (invitedUser) { - if (invitedUser.get('status') === 'invited' || invitedUser.get('status') === 'invited-pending') { - this.get('notifications').showAlert('A user with that email address was already invited.', {type: 'warn', key: 'invite.send.already-invited'}); - } else { - this.get('notifications').showAlert('A user with that email address already exists.', {type: 'warn', key: 'invite.send.user-exists'}); - } - } else { - newUser = this.store.createRecord('user', { - email, - role, - status: 'invited' - }); - - newUser.save().then(() => { - let notificationText = `Invitation sent! (${email})`; - - // If sending the invitation email fails, the API will still return a status of 201 - // but the user's status in the response object will be 'invited-pending'. - if (newUser.get('status') === 'invited-pending') { - this.get('notifications').showAlert('Invitation email was not sent. Please try resending.', {type: 'error', key: 'invite.send.failed'}); - } else { - this.get('notifications').closeAlerts('invite.send'); - this.get('notifications').showNotification(notificationText); - } - }).catch((errors) => { - newUser.deleteRecord(); - // TODO: user model includes ValidationEngine mixin so - // save is overridden in order to validate, we probably - // want to use inline-validations here and only show an - // alert if we have an actual error - if (errors) { - this.get('notifications').showErrors(errors, {key: 'invite.send'}); - } else if (validationErrors) { - this.get('notifications').showAlert(validationErrors.toString(), {type: 'error', key: 'invite.send.validation-error'}); - } - }).finally(() => { - this.get('errors').clear(); - }); - } - }); - } - } -}); diff --git a/core/client/app/controllers/modals/leave-editor.js b/core/client/app/controllers/modals/leave-editor.js deleted file mode 100644 index 5d0ff8b840..0000000000 --- a/core/client/app/controllers/modals/leave-editor.js +++ /dev/null @@ -1,64 +0,0 @@ -import Ember from 'ember'; - -const {Controller, computed, inject, isArray} = Ember; -const {alias} = computed; - -export default Controller.extend({ - notifications: inject.service(), - - args: alias('model'), - - confirm: { - accept: { - text: 'Leave', - buttonClass: 'btn btn-red' - }, - reject: { - text: 'Stay', - buttonClass: 'btn btn-default btn-minor' - } - }, - - actions: { - confirmAccept() { - let args = this.get('args'); - let editorController, - model, - transition; - - if (isArray(args)) { - editorController = args[0]; - transition = args[1]; - model = editorController.get('model'); - } - - if (!transition || !editorController) { - this.get('notifications').showNotification('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); - - return true; - } - - // definitely want to clear the data store and post of any unsaved, client-generated tags - model.updateTags(); - - if (model.get('isNew')) { - // the user doesn't want to save the new, unsaved post, so delete it. - model.deleteRecord(); - } else { - // roll back changes on model props - model.rollbackAttributes(); - } - - // setting hasDirtyAttributes to false here allows willTransition on the editor route to succeed - editorController.set('hasDirtyAttributes', false); - - // since the transition is now certain to complete, we can unset window.onbeforeunload here - window.onbeforeunload = null; - - transition.retry(); - }, - - confirmReject() { - } - } -}); diff --git a/core/client/app/controllers/modals/signin.js b/core/client/app/controllers/modals/signin.js deleted file mode 100644 index 8a4dd62522..0000000000 --- a/core/client/app/controllers/modals/signin.js +++ /dev/null @@ -1,57 +0,0 @@ -import Ember from 'ember'; -import ValidationEngine from 'ghost/mixins/validation-engine'; - -const {$, Controller, computed, inject} = Ember; - -export default Controller.extend(ValidationEngine, { - validationType: 'signin', - submitting: false, - - application: inject.controller(), - notifications: inject.service(), - session: inject.service(), - - identification: computed('session.user.email', function () { - return this.get('session.user.email'); - }), - - actions: { - authenticate() { - let appController = this.get('application'); - let authStrategy = 'authenticator:oauth2'; - - appController.set('skipAuthSuccessHandler', true); - - this.get('session').authenticate(authStrategy, this.get('identification'), this.get('password')).then(() => { - this.send('closeModal'); - this.set('password', ''); - this.get('notifications').closeAlerts('post.save'); - }).catch(() => { - // if authentication fails a rejected promise will be returned. - // it needs to be caught so it doesn't generate an exception in the console, - // but it's actually "handled" by the sessionAuthenticationFailed action handler. - }).finally(() => { - this.toggleProperty('submitting'); - appController.set('skipAuthSuccessHandler', undefined); - }); - }, - - validateAndAuthenticate() { - this.toggleProperty('submitting'); - - // Manually trigger events for input fields, ensuring legacy compatibility with - // browsers and password managers that don't send proper events on autofill - $('#login').find('input').trigger('change'); - - this.validate({format: false}).then(() => { - this.send('authenticate'); - }).catch((errors) => { - this.get('notifications').showErrors(errors); - }); - }, - - confirmAccept() { - this.send('validateAndAuthenticate'); - } - } -}); diff --git a/core/client/app/controllers/modals/transfer-owner.js b/core/client/app/controllers/modals/transfer-owner.js deleted file mode 100644 index ada0bc1601..0000000000 --- a/core/client/app/controllers/modals/transfer-owner.js +++ /dev/null @@ -1,58 +0,0 @@ -import Ember from 'ember'; -import {request as ajax} from 'ic-ajax'; - -const {Controller, inject, isArray} = Ember; - -export default Controller.extend({ - dropdown: inject.service(), - ghostPaths: inject.service('ghost-paths'), - notifications: inject.service(), - - confirm: { - accept: { - text: 'Yep - I\'m sure', - buttonClass: 'btn btn-red' - }, - reject: { - text: 'Cancel', - buttonClass: 'btn btn-default btn-minor' - } - }, - - actions: { - confirmAccept() { - let user = this.get('model'); - let url = this.get('ghostPaths.url').api('users', 'owner'); - - this.get('dropdown').closeDropdowns(); - - ajax(url, { - type: 'PUT', - data: { - owner: [{ - id: user.get('id') - }] - } - }).then((response) => { - // manually update the roles for the users that just changed roles - // because store.pushPayload is not working with embedded relations - if (response && isArray(response.users)) { - response.users.forEach((userJSON) => { - let user = this.store.peekRecord('user', userJSON.id); - let role = this.store.peekRecord('role', userJSON.roles[0].id); - - user.set('role', role); - }); - } - - this.get('notifications').showAlert(`Ownership successfully transferred to ${user.get('name')}`, {type: 'success', key: 'owner.transfer.success'}); - }).catch((error) => { - this.get('notifications').showAPIError(error, {key: 'owner.transfer'}); - }); - }, - - confirmReject() { - return false; - } - } -}); diff --git a/core/client/app/controllers/modals/upload.js b/core/client/app/controllers/modals/upload.js deleted file mode 100644 index 880a86e8ec..0000000000 --- a/core/client/app/controllers/modals/upload.js +++ /dev/null @@ -1,25 +0,0 @@ -import Ember from 'ember'; - -const {Controller, inject} = Ember; - -export default Controller.extend({ - notifications: inject.service(), - - acceptEncoding: 'image/*', - - actions: { - confirmAccept() { - let notifications = this.get('notifications'); - - this.get('model').save().then((model) => { - return model; - }).catch((err) => { - notifications.showAPIError(err, {key: 'image.upload'}); - }); - }, - - confirmReject() { - return false; - } - } -}); diff --git a/core/client/app/controllers/posts.js b/core/client/app/controllers/posts.js index 852eeb5e69..5518567607 100644 --- a/core/client/app/controllers/posts.js +++ b/core/client/app/controllers/posts.js @@ -68,6 +68,8 @@ function publishedAtCompare(item1, item2) { export default Controller.extend({ + showDeletePostModal: false, + // See PostsRoute's shortcuts postListFocused: equal('keyboardFocus', 'postList'), postContentFocused: equal('keyboardFocus', 'postContent'), @@ -85,6 +87,10 @@ export default Controller.extend({ } this.transitionToRoute('posts.post', post); + }, + + toggleDeletePostModal() { + this.toggleProperty('showDeletePostModal'); } } }); diff --git a/core/client/app/controllers/settings/general.js b/core/client/app/controllers/settings/general.js index 304c76bdc9..ad6f71ccd0 100644 --- a/core/client/app/controllers/settings/general.js +++ b/core/client/app/controllers/settings/general.js @@ -5,6 +5,10 @@ import randomPassword from 'ghost/utils/random-password'; const {Controller, computed, inject, observer} = Ember; export default Controller.extend(SettingsSaveMixin, { + + showUploadLogoModal: false, + showUploadCoverModal: false, + notifications: inject.service(), config: inject.service(), @@ -97,6 +101,14 @@ export default Controller.extend(SettingsSaveMixin, { setTheme(theme) { this.set('model.activeTheme', theme.name); + }, + + toggleUploadCoverModal() { + this.toggleProperty('showUploadCoverModal'); + }, + + toggleUploadLogoModal() { + this.toggleProperty('showUploadLogoModal'); } } }); diff --git a/core/client/app/controllers/settings/labs.js b/core/client/app/controllers/settings/labs.js index cf63ed21ef..b1602bc381 100644 --- a/core/client/app/controllers/settings/labs.js +++ b/core/client/app/controllers/settings/labs.js @@ -7,6 +7,7 @@ export default Controller.extend({ uploadButtonText: 'Import', importErrors: '', submitting: false, + showDeleteAllModal: false, ghostPaths: inject.service('ghost-paths'), notifications: inject.service(), @@ -65,8 +66,7 @@ export default Controller.extend({ // Reload currentUser and set session this.set('session.user', this.store.findRecord('user', currentUserId)); // TODO: keep as notification, add link to view content - notifications.showNotification('Import successful.'); - notifications.closeAlerts('import.upload'); + notifications.showNotification('Import successful.', {key: 'import.upload.success'}); }).catch((response) => { if (response && response.jqXHR && response.jqXHR.responseJSON && response.jqXHR.responseJSON.errors) { this.set('importErrors', response.jqXHR.responseJSON.errors); @@ -109,6 +109,10 @@ export default Controller.extend({ } this.toggleProperty('submitting'); }); + }, + + toggleDeleteAllModal() { + this.toggleProperty('showDeleteAllModal'); } } }); diff --git a/core/client/app/controllers/settings/tags/tag.js b/core/client/app/controllers/settings/tags/tag.js index 1fb1881a51..101917a3a5 100644 --- a/core/client/app/controllers/settings/tags/tag.js +++ b/core/client/app/controllers/settings/tags/tag.js @@ -5,13 +5,16 @@ const {alias} = computed; export default Controller.extend({ + showDeleteTagModal: false, + tag: alias('model'), isMobile: alias('tagsController.isMobile'), + applicationController: inject.controller('application'), tagsController: inject.controller('settings.tags'), notifications: inject.service(), - saveTagProperty(propKey, newValue) { + _saveTagProperty(propKey, newValue) { let tag = this.get('tag'); let currentValue = tag.get(propKey); @@ -36,9 +39,39 @@ export default Controller.extend({ }); }, + _deleteTag() { + let tag = this.get('tag'); + + return tag.destroyRecord().then(() => { + this._deleteTagSuccess(); + }, (error) => { + this._deleteTagFailure(error); + }); + }, + + _deleteTagSuccess() { + let currentRoute = this.get('applicationController.currentRouteName') || ''; + + if (currentRoute.match(/^settings\.tags/)) { + this.transitionToRoute('settings.tags.index'); + } + }, + + _deleteTagFailure(error) { + this.get('notifications').showAPIError(error, {key: 'tag.delete'}); + }, + actions: { setProperty(propKey, value) { - this.saveTagProperty(propKey, value); + this._saveTagProperty(propKey, value); + }, + + toggleDeleteTagModal() { + this.toggleProperty('showDeleteTagModal'); + }, + + deleteTag() { + return this._deleteTag(); } } }); diff --git a/core/client/app/controllers/setup/two.js b/core/client/app/controllers/setup/two.js index 69873d02bb..a9cea253c0 100644 --- a/core/client/app/controllers/setup/two.js +++ b/core/client/app/controllers/setup/two.js @@ -123,12 +123,14 @@ export default Controller.extend(ValidationEngine, { } // Don't call the success handler, otherwise we will be redirected to admin - this.get('application').set('skipAuthSuccessHandler', true); + this.set('session.skipAuthSuccessHandler', true); this.get('session').authenticate('authenticator:oauth2', this.get('email'), this.get('password')).then(() => { this.set('blogCreated', true); return this.afterAuthentication(result); }).catch((error) => { this._handleAuthenticationError(error); + }).finally(() => { + this.set('session.skipAuthSuccessHandler', undefined); }); }).catch((error) => { this._handleSaveError(error); diff --git a/core/client/app/controllers/team/index.js b/core/client/app/controllers/team/index.js index b6ab30e298..b534ff47e8 100644 --- a/core/client/app/controllers/team/index.js +++ b/core/client/app/controllers/team/index.js @@ -5,10 +5,12 @@ const {alias, filter} = computed; export default Controller.extend({ - session: inject.service(), + showInviteUserModal: false, users: alias('model'), + session: inject.service(), + activeUsers: filter('users', function (user) { return /^active|warn-[1-4]|locked$/.test(user.get('status')); }), @@ -17,5 +19,11 @@ export default Controller.extend({ let status = user.get('status'); return status === 'invited' || status === 'invited-pending'; - }) + }), + + actions: { + toggleInviteUserModal() { + this.toggleProperty('showInviteUserModal'); + } + } }); diff --git a/core/client/app/controllers/team/user.js b/core/client/app/controllers/team/user.js index e320056804..6638555c1a 100644 --- a/core/client/app/controllers/team/user.js +++ b/core/client/app/controllers/team/user.js @@ -1,42 +1,45 @@ import Ember from 'ember'; +import {request as ajax} from 'ic-ajax'; import SlugGenerator from 'ghost/models/slug-generator'; import isNumber from 'ghost/utils/isNumber'; import boundOneWay from 'ghost/utils/bound-one-way'; import ValidationEngine from 'ghost/mixins/validation-engine'; -const {Controller, RSVP, computed, inject} = Ember; +const {Controller, RSVP, computed, inject, isArray} = Ember; const {alias, and, not, or, readOnly} = computed; export default Controller.extend(ValidationEngine, { // ValidationEngine settings validationType: 'user', submitting: false, + lastPromise: null, + showDeleteUserModal: false, + showTransferOwnerModal: false, + showUploadCoverModal: false, + showUplaodImageModal: false, + dropdown: inject.service(), ghostPaths: inject.service('ghost-paths'), notifications: inject.service(), session: inject.service(), - lastPromise: null, - - currentUser: alias('session.user'), user: alias('model'), - email: readOnly('user.email'), - slugValue: boundOneWay('user.slug'), + currentUser: alias('session.user'), + + email: readOnly('model.email'), + slugValue: boundOneWay('model.slug'), + + isNotOwnersProfile: not('user.isOwner'), + isAdminUserOnOwnerProfile: and('currentUser.isAdmin', 'user.isOwner'), + canAssignRoles: or('currentUser.isAdmin', 'currentUser.isOwner'), + canMakeOwner: and('currentUser.isOwner', 'isNotOwnProfile', 'user.isAdmin'), + rolesDropdownIsVisible: and('isNotOwnProfile', 'canAssignRoles', 'isNotOwnersProfile'), + userActionsAreVisible: or('deleteUserActionIsVisible', 'canMakeOwner'), isNotOwnProfile: computed('user.id', 'currentUser.id', function () { return this.get('user.id') !== this.get('currentUser.id'); }), - isNotOwnersProfile: not('user.isOwner'), - - isAdminUserOnOwnerProfile: and('currentUser.isAdmin', 'user.isOwner'), - - canAssignRoles: or('currentUser.isAdmin', 'currentUser.isOwner'), - - canMakeOwner: and('currentUser.isOwner', 'isNotOwnProfile', 'user.isAdmin'), - - rolesDropdownIsVisible: and('isNotOwnProfile', 'canAssignRoles', 'isNotOwnersProfile'), - deleteUserActionIsVisible: computed('currentUser', 'canAssignRoles', 'user', function () { if ((this.get('canAssignRoles') && this.get('isNotOwnProfile') && !this.get('user.isOwner')) || (this.get('currentUser.isEditor') && (this.get('isNotOwnProfile') || @@ -45,8 +48,6 @@ export default Controller.extend(ValidationEngine, { } }), - userActionsAreVisible: or('deleteUserActionIsVisible', 'canMakeOwner'), - // duplicated in gh-user-active -- find a better home and consolidate? userDefault: computed('ghostPaths', function () { return this.get('ghostPaths.url').asset('/shared/img/user-image.png'); @@ -85,6 +86,23 @@ export default Controller.extend(ValidationEngine, { return this.store.query('role', {permissions: 'assign'}); }), + _deleteUser() { + if (this.get('deleteUserActionIsVisible')) { + let user = this.get('user'); + return user.destroyRecord(); + } + }, + + _deleteUserSuccess() { + this.get('notifications').closeAlerts('user.delete'); + this.store.unloadAll('post'); + this.transitionToRoute('team'); + }, + + _deleteUserFailure() { + this.get('notifications').showAlert('The user could not be deleted. Please try again.', {type: 'error', key: 'user.delete.failed'}); + }, + actions: { changeRole(newRole) { this.set('model.role', newRole); @@ -137,6 +155,20 @@ export default Controller.extend(ValidationEngine, { this.set('lastPromise', promise); }, + deleteUser() { + return this._deleteUser().then(() => { + this._deleteUserSuccess(); + }, () => { + this._deleteUserFailure(); + }); + }, + + toggleDeleteUserModal() { + if (this.get('deleteUserActionIsVisible')) { + this.toggleProperty('showDeleteUserModal'); + } + }, + password() { let user = this.get('user'); @@ -210,6 +242,51 @@ export default Controller.extend(ValidationEngine, { }); this.set('lastPromise', promise); + }, + + transferOwnership() { + let user = this.get('user'); + let url = this.get('ghostPaths.url').api('users', 'owner'); + + this.get('dropdown').closeDropdowns(); + + return ajax(url, { + type: 'PUT', + data: { + owner: [{ + id: user.get('id') + }] + } + }).then((response) => { + // manually update the roles for the users that just changed roles + // because store.pushPayload is not working with embedded relations + if (response && isArray(response.users)) { + response.users.forEach((userJSON) => { + let user = this.store.peekRecord('user', userJSON.id); + let role = this.store.peekRecord('role', userJSON.roles[0].id); + + user.set('role', role); + }); + } + + this.get('notifications').showAlert(`Ownership successfully transferred to ${user.get('name')}`, {type: 'success', key: 'owner.transfer.success'}); + }).catch((error) => { + this.get('notifications').showAPIError(error, {key: 'owner.transfer'}); + }); + }, + + toggleTransferOwnerModal() { + if (this.get('canMakeOwner')) { + this.toggleProperty('showTransferOwnerModal'); + } + }, + + toggleUploadCoverModal() { + this.toggleProperty('showUploadCoverModal'); + }, + + toggleUploadImageModal() { + this.toggleProperty('showUploadImageModal'); } } }); diff --git a/core/client/app/mixins/ed-editor-shortcuts.js b/core/client/app/mixins/ed-editor-shortcuts.js index ba04dfe55d..e204f2870b 100644 --- a/core/client/app/mixins/ed-editor-shortcuts.js +++ b/core/client/app/mixins/ed-editor-shortcuts.js @@ -113,7 +113,7 @@ let shortcuts = { } // Talk to the editor - editor.sendAction('openModal', 'copy-html', {generatedHTML}); + editor.send('toggleCopyHTMLModal', generatedHTML); }, currentDate(replacement) { diff --git a/core/client/app/mixins/editor-base-controller.js b/core/client/app/mixins/editor-base-controller.js index e95a0acd6e..0ab628a604 100644 --- a/core/client/app/mixins/editor-base-controller.js +++ b/core/client/app/mixins/editor-base-controller.js @@ -20,6 +20,9 @@ export default Mixin.create({ editor: null, submitting: false, + showLeaveEditorModal: false, + showReAuthenticateModal: false, + postSettingsMenuController: inject.controller('post-settings-menu'), notifications: inject.service(), @@ -251,13 +254,12 @@ export default Mixin.create({ actions: { save(options) { - let status; let prevStatus = this.get('model.status'); let isNew = this.get('model.isNew'); let autoSaveId = this._autoSaveId; let timedSaveId = this._timedSaveId; let psmController = this.get('postSettingsMenuController'); - let promise; + let promise, status; options = options || {}; @@ -388,6 +390,44 @@ export default Mixin.create({ updateHeight(height) { this.set('height', height); + }, + + toggleLeaveEditorModal(transition) { + this.set('leaveEditorTransition', transition); + this.toggleProperty('showLeaveEditorModal'); + }, + + leaveEditor() { + let transition = this.get('leaveEditorTransition'); + let model = this.get('model'); + + if (!transition) { + this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); + return; + } + + // definitely want to clear the data store and post of any unsaved, client-generated tags + model.updateTags(); + + if (model.get('isNew')) { + // the user doesn't want to save the new, unsaved post, so delete it. + model.deleteRecord(); + } else { + // roll back changes on model props + model.rollbackAttributes(); + } + + // setting hasDirtyAttributes to false here allows willTransition on the editor route to succeed + this.set('hasDirtyAttributes', false); + + // since the transition is now certain to complete, we can unset window.onbeforeunload here + window.onbeforeunload = null; + + return transition.retry(); + }, + + toggleReAuthenticateModal() { + this.toggleProperty('showReAuthenticateModal'); } } }); diff --git a/core/client/app/mixins/editor-base-route.js b/core/client/app/mixins/editor-base-route.js index ef8f33e121..db843fa9e5 100644 --- a/core/client/app/mixins/editor-base-route.js +++ b/core/client/app/mixins/editor-base-route.js @@ -63,7 +63,7 @@ export default Mixin.create(styleBody, ShortcutsRoute, { if (!fromNewToEdit && !deletedWithoutChanges && controllerIsDirty) { transition.abort(); - this.send('openModal', 'leave-editor', [controller, transition]); + controller.send('toggleLeaveEditorModal', transition); return; } diff --git a/core/client/app/mixins/validation-engine.js b/core/client/app/mixins/validation-engine.js index a72a57d994..914e97e78b 100644 --- a/core/client/app/mixins/validation-engine.js +++ b/core/client/app/mixins/validation-engine.js @@ -12,6 +12,7 @@ import ResetValidator from 'ghost/validators/reset'; import UserValidator from 'ghost/validators/user'; import TagSettingsValidator from 'ghost/validators/tag-settings'; import NavItemValidator from 'ghost/validators/nav-item'; +import InviteUserValidator from 'ghost/validators/invite-user'; const {Mixin, RSVP, isArray} = Ember; const {Errors, Model} = DS; @@ -41,7 +42,8 @@ export default Mixin.create({ reset: ResetValidator, user: UserValidator, tag: TagSettingsValidator, - navItem: NavItemValidator + navItem: NavItemValidator, + inviteUser: InviteUserValidator }, // This adds the Errors object to the validation engine, and shouldn't affect diff --git a/core/client/app/routes/application.js b/core/client/app/routes/application.js index bda1d32ba2..d77ef19c1e 100644 --- a/core/client/app/routes/application.js +++ b/core/client/app/routes/application.js @@ -1,5 +1,3 @@ -/* global key */ - import Ember from 'ember'; import AuthConfiguration from 'ember-simple-auth/configuration'; import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin'; @@ -16,7 +14,6 @@ function K() { let shortcuts = {}; shortcuts.esc = {action: 'closeMenus', scope: 'all'}; -shortcuts.enter = {action: 'confirmModal', scope: 'modal'}; shortcuts[`${ctrlOrCmd}+s`] = {action: 'save', scope: 'all'}; export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { @@ -37,9 +34,7 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { }, sessionAuthenticated() { - let appController = this.controllerFor('application'); - - if (appController && appController.get('skipAuthSuccessHandler')) { + if (this.get('session.skipAuthSuccessHandler')) { return; } @@ -64,7 +59,6 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { closeMenus() { this.get('dropdown').closeDropdowns(); - this.send('closeModal'); this.controller.setProperties({ showSettingsMenu: false, showMobileMenu: false @@ -90,48 +84,6 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { windowProxy.replaceLocation(AuthConfiguration.baseURL); }, - openModal(modalName, model, type) { - this.get('dropdown').closeDropdowns(); - key.setScope('modal'); - modalName = `modals/${modalName}`; - this.set('modalName', modalName); - - // We don't always require a modal to have a controller - // so we're skipping asserting if one exists - if (this.controllerFor(modalName, true)) { - this.controllerFor(modalName).set('model', model); - - if (type) { - this.controllerFor(modalName).set('imageType', type); - this.controllerFor(modalName).set('src', model.get(type)); - } - } - - return this.render(modalName, { - into: 'application', - outlet: 'modal' - }); - }, - - confirmModal() { - let modalName = this.get('modalName'); - - this.send('closeModal'); - - if (this.controllerFor(modalName, true)) { - this.controllerFor(modalName).send('confirmAccept'); - } - }, - - closeModal() { - this.disconnectOutlet({ - outlet: 'modal', - parentView: 'application' - }); - - key.setScope('default'); - }, - loadServerNotifications(isDelayed) { if (this.get('session.isAuthenticated')) { this.get('session.user').then((user) => { @@ -146,6 +98,10 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { } }, + toggleMarkdownHelpModal() { + this.get('controller').toggleProperty('showMarkdownHelpModal'); + }, + // noop default for unhandled save (used from shortcuts) save: K } diff --git a/core/client/app/routes/editor/edit.js b/core/client/app/routes/editor/edit.js index 6e8e68ab0b..edb2b4971d 100644 --- a/core/client/app/routes/editor/edit.js +++ b/core/client/app/routes/editor/edit.js @@ -59,7 +59,7 @@ export default AuthenticatedRoute.extend(base, NotFoundHandler, { actions: { authorizationFailed() { - this.send('openModal', 'signin'); + this.get('controller').send('toggleReAuthenticateModal'); } } }); diff --git a/core/client/app/routes/posts/post.js b/core/client/app/routes/posts/post.js index 88b8ea95b0..ac69038b57 100644 --- a/core/client/app/routes/posts/post.js +++ b/core/client/app/routes/posts/post.js @@ -68,7 +68,7 @@ export default AuthenticatedRoute.extend(ShortcutsRoute, { }, deletePost() { - this.send('openModal', 'delete-post', this.get('controller.model')); + this.controllerFor('posts').send('toggleDeletePostModal'); } } }); diff --git a/core/client/app/routes/team/user.js b/core/client/app/routes/team/user.js index 41950a4491..a6f464ea69 100644 --- a/core/client/app/routes/team/user.js +++ b/core/client/app/routes/team/user.js @@ -10,7 +10,7 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, NotFoun classNames: ['team-view-user'], model(params) { - return this.store.queryRecord('user', {slug: params.user_slug}); + return this.store.queryRecord('user', {slug: params.user_slug, include: 'count.posts'}); }, serialize(model) { diff --git a/core/client/app/styles/components/modals.css b/core/client/app/styles/components/modals.css index dfe9000bdc..af5369264c 100644 --- a/core/client/app/styles/components/modals.css +++ b/core/client/app/styles/components/modals.css @@ -2,47 +2,25 @@ /* ---------------------------------------------------------- */ -/* Full screen container +/* Fullscreen Modal /* ---------------------------------------------------------- */ -.modal-container { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 1040; - display: none; - overflow-x: auto; - overflow-y: scroll; - padding-right: 10px; - padding-left: 10px; - transition: all 0.15s linear 0s; - transform: translateZ(0); +.fullscreen-modal-liquid-target { + overflow-y: auto; + height: 100vh; } -.modal-background { +.fullscreen-modal-background { position: fixed; top: 0; right: 0; bottom: 0; left: 0; - z-index: 1030; - display: none; + z-index: 0; background: rgba(0, 0, 0, 0.6); } - -/* The modal -/* ---------------------------------------------------------- */ - -.modal, -.modal-action { - right: auto; - left: 50%; - z-index: 1050; - margin-right: auto; - margin-left: auto; +.fullscreen-modal { padding-top: 30px; padding-bottom: 30px; max-width: 550px; @@ -51,35 +29,44 @@ } @media (max-width: 900px) { - .modal, - .modal-action { + .fullscreen-modal { padding: 10px; } } -.modal button, -.modal-action button { - min-width: 100px; +/* Modifiers +/* ---------------------------------------------------------- */ + +.fullscreen-modal-wide { + width: 550px; } -.modal .image-uploader, -.modal .pre-image-uploader, -.modal-action .image-uploader, -.modal-action .pre-image-uploader { - margin: 0; +@media (max-width: 900px) { + .fullscreen-modal-wide { + width: 100%; + } } -.modal-action { +.fullscreen-modal-action { padding: 60px 0 30px; } @media (max-width: 900px) { - .modal-action { + .fullscreen-modal-action { padding: 30px 0; } } +/* The modal +/* ---------------------------------------------------------- */ + +.fullscreen-modal .image-uploader, +.fullscreen-modal .pre-image-uploader { + margin: 0; +} + + /* Modal content /* ---------------------------------------------------------- */ @@ -149,6 +136,7 @@ .modal-footer button { margin-left: 8px; + min-width: 100px; text-align: center; } @@ -157,22 +145,9 @@ } -/* Modifiers +/* Content Modifiers /* ---------------------------------------------------------- */ -.modal-style-wide { - width: 550px; -} - -@media (max-width: 900px) { - .modal-style-wide { - width: 100%; - } -} - -.modal-style-centered { - text-align: center; -} /* Login styles */ .modal-body .login-form { @@ -207,29 +182,3 @@ flex: 1; } } - - -/* Open States -/* ---------------------------------------------------------- */ - -.modal-container.open, -.modal-container.open > .modal, -.modal-container.open > .modal-action { - display: block; -} - -.modal-background.open { - display: block; -} - - -/* Animations -/* ---------------------------------------------------------- */ - -.modal-container.fade-out { - animation-duration: 0.08s; -} - -.modal-background.fade-out { - animation-duration: 0.15s; -} diff --git a/core/client/app/styles/layouts/main.css b/core/client/app/styles/layouts/main.css index 7f0508992b..bc4feb78f8 100644 --- a/core/client/app/styles/layouts/main.css +++ b/core/client/app/styles/layouts/main.css @@ -5,8 +5,12 @@ Ember's app container, set height so that .gh-app and .gh-viewport don't need to use 100vh where bottom of screen gets covered by iOS menus http://nicolas-hoizey.com/2015/02/viewport-height-is-taller-than-the-visible-part-of-the-document-in-some-mobile-browsers.html + + TODO: Once we have routable components it should be possible to remove this + by moving the gh-app component functionality into the application component + which would remove the extra div that this targets. */ -body > .ember-view { +body > .ember-view:not(.liquid-target-container) { height: 100%; } diff --git a/core/client/app/templates/application.hbs b/core/client/app/templates/application.hbs index b1aa13582a..87e5f2c2f3 100644 --- a/core/client/app/templates/application.hbs +++ b/core/client/app/templates/application.hbs @@ -5,7 +5,7 @@
{{!gh-viewport}} {{/gh-app}} + +{{#if showMarkdownHelpModal}} + {{gh-fullscreen-modal "markdown-help" + close=(route-action "toggleMarkdownHelpModal") + modifier="wide"}} +{{/if}} diff --git a/core/client/app/templates/components/gh-editor.hbs b/core/client/app/templates/components/gh-editor.hbs index f4a0b59217..047bf32b36 100644 --- a/core/client/app/templates/components/gh-editor.hbs +++ b/core/client/app/templates/components/gh-editor.hbs @@ -1 +1,8 @@ -{{yield this}} +{{yield this (action 'toggleCopyHTMLModal')}} + +{{#if showCopyHTMLModal}} + {{gh-fullscreen-modal "copy-html" + model=copyHTMLModalContent + close=(action "toggleCopyHTMLModal") + modifier="action"}} +{{/if}} diff --git a/core/client/app/templates/components/gh-fullscreen-modal.hbs b/core/client/app/templates/components/gh-fullscreen-modal.hbs new file mode 100644 index 0000000000..06ecdf5f79 --- /dev/null +++ b/core/client/app/templates/components/gh-fullscreen-modal.hbs @@ -0,0 +1,11 @@ + +This is permanent! No backups, no restores, no magic undo button.
We warned you, ok?
+ You're about to delete "{{post.title}}".
+ This is permanent! No backups, no restores, no magic undo button.
+ We warned you, ok?
+
+ Hey there! It looks like you're in the middle of writing something and + you haven't saved all of your content. +
+ +Save before you go!
+{{authenticationError}}
+ {{/if}} ++ Are you sure you want to transfer the ownership of this blog? + You will not be able to undo this action. +
+This is permanent! No backups, no restores, no magic undo button.
We warned you, ok?
You're about to delete "{{model.title}}".
This is permanent! No backups, no restores, no magic undo button.
We warned you, ok?
Hey there! It looks like you're in the middle of writing something and you haven't saved all of your - content.
- -Save before you go!
- -{{/gh-modal-dialog}} diff --git a/core/client/app/templates/modals/signin.hbs b/core/client/app/templates/modals/signin.hbs deleted file mode 100644 index ad6938f397..0000000000 --- a/core/client/app/templates/modals/signin.hbs +++ /dev/null @@ -1,11 +0,0 @@ -{{#gh-modal-dialog action="closeModal" showClose=true type="action" style="wide" animation="fade" - title="Please re-authenticate" confirm=confirm}} - - - -{{/gh-modal-dialog}} diff --git a/core/client/app/templates/modals/transfer-owner.hbs b/core/client/app/templates/modals/transfer-owner.hbs deleted file mode 100644 index 7d2899b82a..0000000000 --- a/core/client/app/templates/modals/transfer-owner.hbs +++ /dev/null @@ -1,6 +0,0 @@ -{{#gh-modal-dialog action="closeModal" showClose=true type="action" style="wide" - title="Transfer Ownership" confirm=confirm}} - -Are you sure you want to transfer the ownership of this blog? You will not be able to undo this action.
- -{{/gh-modal-dialog}} diff --git a/core/client/app/templates/modals/upload.hbs b/core/client/app/templates/modals/upload.hbs deleted file mode 100644 index ea19fc871f..0000000000 --- a/core/client/app/templates/modals/upload.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{#gh-upload-modal action="closeModal" close=true type="action" style="wide" model=model imageType=imageType}} -Display a sexy logo for your publication
+ + {{#if showUploadLogoModal}} + {{gh-fullscreen-modal "upload-image" + model=(hash model=model imageProperty="logo") + close=(action "toggleUploadLogoModal") + modifier="action wide"}} + {{/if}}Display a cover image on your site
+ + {{#if showUploadCoverModal}} + {{gh-fullscreen-modal "upload-image" + model=(hash model=model imageProperty="cover") + close=(action "toggleUploadCoverModal") + modifier="action wide"}} + {{/if}}