diff --git a/ghost/admin/app/components/gh-file-uploader.js b/ghost/admin/app/components/gh-file-uploader.js index f813e40652..75523c8c47 100644 --- a/ghost/admin/app/components/gh-file-uploader.js +++ b/ghost/admin/app/components/gh-file-uploader.js @@ -32,6 +32,7 @@ export default Component.extend({ uploadPercentage: 0, ajax: injectService(), + eventBus: injectService(), notifications: injectService(), formData: computed('file', function () { @@ -57,6 +58,32 @@ export default Component.extend({ return htmlSafe(`width: ${width}`); }), + // we can optionally listen to a named event bus channel so that the upload + // process can be triggered externally + init() { + this._super(...arguments); + let listenTo = this.get('listenTo'); + + if (listenTo) { + this.get('eventBus').subscribe(`${listenTo}:upload`, this, function (file) { + if (file) { + this.set('file', file); + } + this.send('upload'); + }); + } + }, + + willDestroyElement() { + let listenTo = this.get('listenTo'); + + this._super(...arguments); + + if (listenTo) { + this.get('eventBus').unsubscribe(`${listenTo}:upload`); + } + }, + dragOver(event) { if (!event.dataTransfer) { return; @@ -142,7 +169,7 @@ export default Component.extend({ } else if (isRequestEntityTooLargeError(error)) { 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; + message = htmlSafe(error.errors[0].message); } else { message = 'Something went wrong :('; } @@ -190,6 +217,12 @@ export default Component.extend({ } }, + upload() { + if (this.get('file')) { + this.generateRequest(); + } + }, + reset() { this.set('file', null); this.set('uploadPercentage', 0); diff --git a/ghost/admin/app/components/gh-theme-table.js b/ghost/admin/app/components/gh-theme-table.js new file mode 100644 index 0000000000..67eeb947bb --- /dev/null +++ b/ghost/admin/app/components/gh-theme-table.js @@ -0,0 +1,24 @@ +import Component from 'ember-component'; +import computed from 'ember-computed'; + +export default Component.extend({ + + availableThemes: null, + activeTheme: null, + + themes: computed('availableThemes', 'activeTheme', function () { + return this.get('availableThemes').map((t) => { + let theme = {}; + + theme.name = t.name; + theme.label = t.package ? `${t.package.name} - ${t.package.version}` : t.name; + theme.package = t.package; + theme.active = !!t.active; + theme.isDefault = t.name === 'casper'; + theme.isDeletable = !theme.active && !theme.isDefault; + + return theme; + }).sortBy('label'); + }).readOnly() + +}); diff --git a/ghost/admin/app/components/modals/delete-theme.js b/ghost/admin/app/components/modals/delete-theme.js new file mode 100644 index 0000000000..c152e983ca --- /dev/null +++ b/ghost/admin/app/components/modals/delete-theme.js @@ -0,0 +1,21 @@ +import ModalComponent from 'ghost-admin/components/modals/base'; +import {alias} from 'ember-computed'; +import {invokeAction} from 'ember-invoke-action'; + +export default ModalComponent.extend({ + + submitting: false, + + theme: alias('model.theme'), + download: alias('model.download'), + + actions: { + confirm() { + this.set('submitting', true); + + invokeAction(this, 'confirm').finally(() => { + this.send('closeModal'); + }); + } + } +}); diff --git a/ghost/admin/app/components/modals/upload-theme.js b/ghost/admin/app/components/modals/upload-theme.js new file mode 100644 index 0000000000..bb7a4d080f --- /dev/null +++ b/ghost/admin/app/components/modals/upload-theme.js @@ -0,0 +1,109 @@ +import ModalComponent from 'ghost-admin/components/modals/base'; +import computed, {mapBy, or} from 'ember-computed'; +import {invokeAction} from 'ember-invoke-action'; +import ghostPaths from 'ghost-admin/utils/ghost-paths'; +import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax'; +import {isBlank} from 'ember-utils'; +import run from 'ember-runloop'; +import injectService from 'ember-service/inject'; + +export default ModalComponent.extend({ + + accept: 'application/zip', + availableThemes: null, + closeDisabled: false, + file: null, + theme: false, + displayOverwriteWarning: false, + + eventBus: injectService(), + + hideUploader: or('theme', 'displayOverwriteWarning'), + + uploadUrl: computed(function () { + return `${ghostPaths().apiRoot}/themes/upload/`; + }), + + themeName: computed('theme.{name,package.name}', function () { + let t = this.get('theme'); + + return t.package ? `${t.package.name} - ${t.package.version}` : t.name; + }), + + availableThemeNames: mapBy('model.availableThemes', 'name'), + + fileThemeName: computed('file', function () { + let file = this.get('file'); + return file.name.replace(/\.zip$/, ''); + }), + + canActivateTheme: computed('theme', function () { + let theme = this.get('theme'); + return theme && !theme.active; + }), + + actions: { + validateTheme(file) { + let accept = this.get('accept'); + let themeName = file.name.replace(/\.zip$/, ''); + let availableThemeNames = this.get('availableThemeNames'); + + this.set('file', file); + + if (!isBlank(accept) && file && accept.indexOf(file.type) === -1) { + return new UnsupportedMediaTypeError(); + } + + if (file.name.match(/^casper\.zip$/i)) { + return {errors: [{message: 'Sorry, the default Casper theme cannot be overwritten.
Please rename your zip file and try again.'}]}; + } + + if (!this._allowOverwrite && availableThemeNames.includes(themeName)) { + this.set('displayOverwriteWarning', true); + return false; + } + + return true; + }, + + confirmOverwrite() { + this._allowOverwrite = true; + this.set('displayOverwriteWarning', false); + + // we need to schedule afterRender so that the upload component is + // displayed again in order to subscribe/respond to the event bus + run.schedule('afterRender', this, function () { + this.get('eventBus').publish('themeUploader:upload', this.get('file')); + }); + }, + + uploadStarted() { + this.set('closeDisabled', true); + }, + + uploadFinished() { + this.set('closeDisabled', false); + }, + + uploadSuccess(response) { + this.set('theme', response.themes[0]); + // invoke the passed in confirm action + invokeAction(this, 'model.uploadSuccess', this.get('theme')); + }, + + confirm() { + // noop - we don't want the enter key doing anything + }, + + activate() { + invokeAction(this, 'model.activate', this.get('theme')); + invokeAction(this, 'closeModal'); + }, + + closeModal() { + if (!this.get('closeDisabled')) { + this._super(...arguments); + } + } + } +}); diff --git a/ghost/admin/app/controllers/settings/general.js b/ghost/admin/app/controllers/settings/general.js index 449f10f855..d0917870b5 100644 --- a/ghost/admin/app/controllers/settings/general.js +++ b/ghost/admin/app/controllers/settings/general.js @@ -1,37 +1,29 @@ import Controller from 'ember-controller'; -import computed from 'ember-computed'; +import computed, {notEmpty} from 'ember-computed'; import injectService from 'ember-service/inject'; import observer from 'ember-metal/observer'; import run from 'ember-runloop'; import SettingsSaveMixin from 'ghost-admin/mixins/settings-save'; import randomPassword from 'ghost-admin/utils/random-password'; +import $ from 'jquery'; export default Controller.extend(SettingsSaveMixin, { + availableTimezones: null, + themeToDelete: null, + showUploadLogoModal: false, showUploadCoverModal: false, + showDeleteThemeModal: notEmpty('themeToDelete'), - availableTimezones: null, - - notifications: injectService(), + ajax: injectService(), config: injectService(), + ghostPaths: injectService(), + notifications: injectService(), + session: injectService(), _scratchFacebook: null, _scratchTwitter: null, - selectedTheme: computed('model.activeTheme', 'themes', function () { - let activeTheme = this.get('model.activeTheme'); - let themes = this.get('themes'); - let selectedTheme; - - themes.forEach((theme) => { - if (theme.name === activeTheme) { - selectedTheme = theme; - } - }); - - return selectedTheme; - }), - logoImageSource: computed('model.logo', function () { return this.get('model.logo') || ''; }), @@ -55,21 +47,6 @@ export default Controller.extend(SettingsSaveMixin, { } }), - themes: computed(function () { - return this.get('model.availableThemes').reduce(function (themes, t) { - let theme = {}; - - theme.name = t.name; - theme.label = t.package ? `${t.package.name} - ${t.package.version}` : t.name; - theme.package = t.package; - theme.active = !!t.active; - - themes.push(theme); - - return themes; - }, []); - }).readOnly(), - generatePassword: observer('model.isPrivate', function () { this.get('model.errors').remove('password'); if (this.get('model.isPrivate') && this.get('model.hasDirtyAttributes')) { @@ -77,6 +54,21 @@ export default Controller.extend(SettingsSaveMixin, { } }), + _deleteTheme() { + let theme = this.get('themeToDelete'); + let themeURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}/`; + + if (!theme) { + return; + } + + return this.get('ajax').del(themeURL).then(() => { + this.send('reloadSettings'); + }).catch((error) => { + this.get('notifications').showAPIError(error); + }); + }, + save() { let notifications = this.get('notifications'); let config = this.get('config'); @@ -107,10 +99,38 @@ export default Controller.extend(SettingsSaveMixin, { setTheme(theme) { this.set('model.activeTheme', theme.name); + this.send('save'); }, + + downloadTheme(theme) { + let themeURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}`; + let accessToken = this.get('session.data.authenticated.access_token'); + let downloadURL = `${themeURL}/download/?access_token=${accessToken}`; + let iframe = $('#iframeDownload'); + + if (iframe.length === 0) { + iframe = $('