From 1e3191b81109cf071921bd69f030e325f8fa3503 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 2 Aug 2017 11:05:59 +0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Unsplash=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/TryGhost/Ghost/issues/8859, requires https://github.com/TryGhost/Ghost/pull/8895 - adds Unsplash app to app settings - enable/disable toggle - validation and testing of Unsplash App ID - Unsplash App ID field hidden if provided via Ghost config - adds `fetchPrivate` method to `config` service to pull config that requires authentication and updates authentication routines to fetch private config - adds Unsplash buttons to editor toolbar and `{{gh-image-uploader}}` - only present when Unsplash app is enabled - opens Unsplash image selector when clicked - `{{gh-image-uploader}}` has a new `allowUnsplash` attribute to control display of the unsplash button on a per-uploader basis - adds Unsplash image selector (`{{gh-unsplash}}`) - uses new `unsplash` service to handle API requests and maintain state - search - infinite scroll - zoom image - insert image - download image - adds `{{gh-scroll-trigger}}` that will fire an event when the component is rendered into or enters the visible screen area via scrolling - updates `ui` service - adds `isFullscreen` property and updates `gh-editor` so that it gets set/unset when toggling editor fullscreen mode - adds `hasSideNav` and `isSideNavHidden` properties - updates `media-queries` service so that it fires an event each time a breakpoint is entered/exited - removes the need for observers in certain circumstances --- .csscomb.json | 97 +++--- app/components/gh-editor.js | 3 + .../gh-image-uploader-with-preview.js | 3 + app/components/gh-image-uploader.js | 27 +- app/components/gh-markdown-editor.js | 111 +++++- app/components/gh-scroll-trigger.js | 26 ++ app/components/gh-simplemde.js | 8 + app/components/gh-unsplash-photo.js | 70 ++++ app/components/gh-unsplash.js | 86 +++++ app/controllers/settings/apps/unsplash.js | 101 ++++++ app/controllers/setup/two.js | 25 +- app/controllers/signin.js | 9 +- app/controllers/signup.js | 8 +- app/mixins/validation-engine.js | 4 +- app/models/setting.js | 3 +- app/models/unsplash-integration.js | 11 + app/router.js | 1 + app/routes/application.js | 2 + app/routes/settings/apps/unsplash.js | 39 +++ app/services/config.js | 17 +- app/services/media-queries.js | 10 +- app/services/ui.js | 7 + app/services/unsplash.js | 244 +++++++++++++ app/styles/app.css | 1 + app/styles/components/unsplash.css | 324 ++++++++++++++++++ app/styles/components/uploader.css | 62 +++- app/styles/patterns/forms.css | 4 + .../gh-image-uploader-with-preview.hbs | 1 + .../components/gh-image-uploader.hbs | 13 + .../components/gh-markdown-editor.hbs | 8 + .../components/gh-post-settings-menu.hbs | 5 +- .../components/gh-scroll-trigger.hbs | 1 + .../components/gh-tag-settings-form.hbs | 3 +- .../components/gh-unsplash-photo.hbs | 22 ++ app/templates/components/gh-unsplash.hbs | 73 ++++ app/templates/settings/apps/index.hbs | 24 ++ app/templates/settings/apps/unsplash.hbs | 66 ++++ app/transforms/unsplash-settings.js | 24 ++ app/transitions.js | 8 + app/validators/unsplash-integration.js | 23 ++ mirage/config.js | 2 +- mirage/config/configuration.js | 10 + mirage/fixtures/private.js | 3 + mirage/fixtures/settings.js | 10 + package.json | 2 + public/assets/icons/download.svg | 3 + public/assets/icons/unsplash-heart.svg | 1 + public/assets/icons/unsplash.svg | 1 + public/assets/img/unsplash-404.png | Bin 0 -> 2196 bytes public/assets/img/unsplashicon.png | Bin 0 -> 2786 bytes tests/acceptance/settings/apps-test.js | 11 + tests/acceptance/settings/unsplash-test.js | 180 ++++++++++ .../components/gh-image-uploader-test.js | 7 + .../components/gh-markdown-editor-test.js | 9 + .../components/gh-unsplash-photo-test.js | 119 +++++++ .../components/gh-unsplash-test.js | 44 +++ .../components/gh-infinite-scroll-test.js | 23 -- tests/unit/serializers/setting-test.js | 3 +- tests/unit/services/ui-test.js | 5 +- tests/unit/services/unsplash-test.js | 114 ++++++ yarn.lock | 11 + 61 files changed, 2001 insertions(+), 131 deletions(-) create mode 100644 app/components/gh-scroll-trigger.js create mode 100644 app/components/gh-unsplash-photo.js create mode 100644 app/components/gh-unsplash.js create mode 100644 app/controllers/settings/apps/unsplash.js create mode 100644 app/models/unsplash-integration.js create mode 100644 app/routes/settings/apps/unsplash.js create mode 100644 app/services/unsplash.js create mode 100644 app/styles/components/unsplash.css create mode 100644 app/templates/components/gh-scroll-trigger.hbs create mode 100644 app/templates/components/gh-unsplash-photo.hbs create mode 100644 app/templates/components/gh-unsplash.hbs create mode 100644 app/templates/settings/apps/unsplash.hbs create mode 100644 app/transforms/unsplash-settings.js create mode 100644 app/validators/unsplash-integration.js create mode 100644 mirage/fixtures/private.js create mode 100644 public/assets/icons/download.svg create mode 100644 public/assets/icons/unsplash-heart.svg create mode 100644 public/assets/icons/unsplash.svg create mode 100644 public/assets/img/unsplash-404.png create mode 100644 public/assets/img/unsplashicon.png create mode 100644 tests/acceptance/settings/unsplash-test.js create mode 100644 tests/integration/components/gh-unsplash-photo-test.js create mode 100644 tests/integration/components/gh-unsplash-test.js delete mode 100644 tests/unit/components/gh-infinite-scroll-test.js create mode 100644 tests/unit/services/unsplash-test.js diff --git a/.csscomb.json b/.csscomb.json index 624f4c19f..318b6614f 100644 --- a/.csscomb.json +++ b/.csscomb.json @@ -39,21 +39,26 @@ "display", "flex-flow", "flex-direction", - "flex-wrap", "justify-content", "align-items", "align-content", + "flex-wrap", "flex-order", "flex-pack", "flex-align", "float", "clear", + "box-sizing", + "width", + "height", + "min-width", + "min-height", + "max-width", + "max-height", "overflow", "overflow-x", "overflow-y", - "-webkit-overflow-scrolling", "clip", - "box-sizing", "margin", "margin-top", "margin-right", @@ -64,12 +69,6 @@ "padding-right", "padding-bottom", "padding-left", - "min-width", - "min-height", - "max-width", - "max-height", - "width", - "height", "outline", "outline-width", "outline-style", @@ -112,26 +111,6 @@ "border-top-right-image", "border-bottom-right-image", "border-bottom-left-image", - "background", - "filter:progid:DXImageTransform.Microsoft.AlphaImageLoader", - "background-color", - "background-image", - "background-attachment", - "background-position", - "background-position-x", - "background-position-y", - "background-clip", - "background-origin", - "background-size", - "background-repeat", - "border-radius", - "border-top-left-radius", - "border-top-right-radius", - "border-bottom-right-radius", - "border-bottom-left-radius", - "box-decoration-break", - "box-shadow", - "color", "table-layout", "caption-side", "empty-cells", @@ -143,6 +122,24 @@ "counter-increment", "counter-reset", "vertical-align", + "stroke", + "fill", + "stroke-width", + "stroke-opacity", + "color", + "font", + "font-family", + "font-size", + "line-height", + "font-weight", + "font-style", + "font-variant", + "font-size-adjust", + "font-stretch", + "text-rendering", + "font-feature-settings", + "letter-spacing", + "hyphens", "text-align", "text-align-last", "text-decoration", @@ -164,24 +161,8 @@ "word-wrap", "word-break", "tab-size", - "hyphens", - "letter-spacing", - "font", - "font-family", - "font-size", - "line-height", - "font-weight", - "font-style", - "font-variant", - "font-size-adjust", - "font-stretch", - "text-rendering", - "font-feature-settings", "user-select", "src", - "opacity", - "filter:progid:DXImageTransform.Microsoft.Alpha(Opacity", - "filter", "resize", "cursor", "nav-index", @@ -189,6 +170,28 @@ "nav-right", "nav-down", "nav-left", + "background", + "filter:progid:DXImageTransform.Microsoft.AlphaImageLoader", + "background-color", + "background-image", + "background-size", + "background-attachment", + "background-position", + "background-position-x", + "background-position-y", + "background-clip", + "background-origin", + "background-repeat", + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "box-decoration-break", + "box-shadow", + "opacity", + "filter:progid:DXImageTransform.Microsoft.Alpha(Opacity", + "filter", "transition", "transition-delay", "transition-timing-function", @@ -230,6 +233,8 @@ "max-zoom", "min-zoom", "user-zoom", - "orientation" + "orientation", + "-webkit-overflow-scrolling", + "-ms-overflow-scrolling" ] ] } diff --git a/app/components/gh-editor.js b/app/components/gh-editor.js index af8c780c7..1b2362b45 100644 --- a/app/components/gh-editor.js +++ b/app/components/gh-editor.js @@ -4,10 +4,12 @@ import { IMAGE_EXTENSIONS, IMAGE_MIME_TYPES } from 'ghost-admin/components/gh-image-uploader'; +import {inject as injectService} from '@ember/service'; const {debounce} = run; export default Component.extend({ + ui: injectService(), classNameBindings: [ 'isDraggedOver:-drag-over', @@ -140,6 +142,7 @@ export default Component.extend({ actions: { toggleFullScreen(isFullScreen) { this.set('isFullScreen', isFullScreen); + this.get('ui').set('isFullScreen', isFullScreen); run.scheduleOnce('afterRender', this, this._setHeaderClass); }, diff --git a/app/components/gh-image-uploader-with-preview.js b/app/components/gh-image-uploader-with-preview.js index af31b62e7..fc53f2c13 100644 --- a/app/components/gh-image-uploader-with-preview.js +++ b/app/components/gh-image-uploader-with-preview.js @@ -2,6 +2,9 @@ import Component from 'ember-component'; import {invokeAction} from 'ember-invoke-action'; export default Component.extend({ + + allowUnsplash: false, + actions: { update() { if (typeof this.attrs.update === 'function') { diff --git a/app/components/gh-image-uploader.js b/app/components/gh-image-uploader.js index 411aa3313..2645bba64 100644 --- a/app/components/gh-image-uploader.js +++ b/app/components/gh-image-uploader.js @@ -9,6 +9,7 @@ import { isUnsupportedMediaTypeError, isVersionMismatchError } from 'ghost-admin/services/ajax'; +import {assign} from '@ember/polyfills'; import {htmlSafe} from 'ember-string'; import {invokeAction} from 'ember-invoke-action'; import {isBlank} from 'ember-utils'; @@ -18,6 +19,10 @@ export const IMAGE_MIME_TYPES = 'image/gif,image/jpg,image/jpeg,image/png,image/ export const IMAGE_EXTENSIONS = ['gif', 'jpg', 'jpeg', 'png', 'svg']; export default Component.extend({ + ajax: injectService(), + notifications: injectService(), + settings: injectService(), + tagName: 'section', classNames: ['gh-image-uploader'], classNameBindings: ['dragClass'], @@ -30,6 +35,7 @@ export default Component.extend({ extensions: null, uploadUrl: null, validate: null, + allowUnsplash: false, dragClass: null, failureMessage: null, @@ -37,12 +43,10 @@ export default Component.extend({ url: null, uploadPercentage: 0, - ajax: injectService(), - notifications: injectService(), - _defaultAccept: IMAGE_MIME_TYPES, _defaultExtensions: IMAGE_EXTENSIONS, _defaultUploadUrl: '/uploads/', + _showUnsplash: false, // TODO: this wouldn't be necessary if the server could accept direct // file uploads @@ -74,6 +78,18 @@ export default Component.extend({ return htmlSafe(`width: ${width}`); }), + // HACK: this settings/config dance is needed because the "override" only + // happens when visiting the unsplash app settings route + // TODO: move the override logic to the server, client knows too much + // about which values should override others + unsplash: computed('config.unsplashAPI', 'settings.unsplash', function () { + let unsplashConfig = this.get('config.unsplashAPI'); + let unsplashSettings = this.get('settings.unsplash'); + let unsplash = assign({}, unsplashConfig, unsplashSettings); + + return unsplash; + }), + didReceiveAttrs() { let image = this.get('image'); this.set('url', image); @@ -244,6 +260,11 @@ export default Component.extend({ } }, + addUnsplashPhoto(photo) { + this.set('url', photo.urls.regular); + this.send('saveUrl'); + }, + reset() { this.set('file', null); this.set('uploadPercentage', 0); diff --git a/app/components/gh-markdown-editor.js b/app/components/gh-markdown-editor.js index 7d9a97f5b..f6144123b 100644 --- a/app/components/gh-markdown-editor.js +++ b/app/components/gh-markdown-editor.js @@ -8,7 +8,7 @@ import run from 'ember-runloop'; import {assign} from 'ember-platform'; import {copy} from 'ember-metal/utils'; import {htmlSafe} from 'ember-string'; -import {isEmpty} from 'ember-utils'; +import {isEmpty, typeOf} from 'ember-utils'; const MOBILEDOC_VERSION = '0.3.1'; @@ -27,7 +27,9 @@ export const BLANK_DOC = { export default Component.extend(ShortcutsMixin, { + config: injectService(), notifications: injectService(), + settings: injectService(), classNames: ['gh-markdown-editor'], classNameBindings: [ @@ -57,10 +59,12 @@ export default Component.extend(ShortcutsMixin, { // Private _editor: null, + _editorFocused: false, _isFullScreen: false, _isSplitScreen: false, _isHemmingwayMode: false, _isUploading: false, + _showUnsplash: false, _statusbar: null, _toolbar: null, _uploadedImageUrls: null, @@ -145,6 +149,28 @@ export default Component.extend(ShortcutsMixin, { status: ['words'] }; + // if unsplash is active insert the toolbar button after the image button + // HACK: this settings/config dance is needed because the "override" only + // happens when visiting the unsplash app settings route + // TODO: move the override logic to the server, client knows too much + // about which values should override others + let unsplashConfig = this.get('config.unsplashAPI'); + let unsplashSettings = this.get('settings.unsplash'); + let unsplash = assign({}, unsplashConfig, unsplashSettings); + if (unsplash.isActive) { + let image = defaultOptions.toolbar.findBy('name', 'image'); + let index = defaultOptions.toolbar.indexOf(image) + 1; + + defaultOptions.toolbar.splice(index, 0, { + name: 'unsplash', + action: () => { + this.send('toggleUnsplash'); + }, + className: 'fa fa-camera', + title: 'Add Image from Unsplash' + }); + } + return assign(defaultOptions, options); }), @@ -154,7 +180,7 @@ export default Component.extend(ShortcutsMixin, { this._super(...arguments); let shortcuts = this.get('shortcuts'); - shortcuts[`${ctrlOrCmd}+shift+i`] = {action: 'insertImage'}; + shortcuts[`${ctrlOrCmd}+shift+i`] = {action: 'openImageFileDialog'}; shortcuts['ctrl+alt+h'] = {action: 'toggleHemmingway'}; }, @@ -201,17 +227,30 @@ export default Component.extend(ShortcutsMixin, { // loop through urls and generate image markdown let images = urls.map((url) => { - let filename = url.split('/').pop(); - let alt = filename; + // plain url string, so extract filename from path + if (typeOf(url) === 'string') { + let filename = url.split('/').pop(); + let alt = filename; - // if we have a normal filename.ext, set alt to filename -ext - if (filename.lastIndexOf('.') > 0) { - alt = filename.slice(0, filename.lastIndexOf('.')); + // if we have a normal filename.ext, set alt to filename -ext + if (filename.lastIndexOf('.') > 0) { + alt = filename.slice(0, filename.lastIndexOf('.')); + } + + return `![${alt}](${url})`; + + // full url object, use attrs we're given + } else { + let image = `![${url.alt}](${url.url})`; + + if (url.credit) { + image += `\n${url.credit}`; + } + + return image; } - - return `![${alt}](${url})`; }); - let text = images.join('\n'); + let text = images.join('\n\n'); // clicking the image toolbar button will lose the selection so we use // the captured selection to re-select here @@ -231,7 +270,7 @@ export default Component.extend(ShortcutsMixin, { // focus editor and place cursor at end if not already focused if (!cm.hasFocus()) { this.send('focusEditor'); - text = `\n\n${text}`; + text = `\n\n${text}\n\n`; } // insert at cursor or replace selection then position cursor at end @@ -452,11 +491,59 @@ export default Component.extend(ShortcutsMixin, { return false; }, - insertImage() { + // HACK FIXME (PLEASE): + // - clicking toolbar buttons will cause the editor to lose focus + // - this is painful because we often want to know if the editor has focus + // so that we can insert images and so on in the correct place + // - the blur event will always fire before the button action is triggered 😞 + // - to work around this we track focus state manually and set it to false + // after an arbitrary period that's long enough to allow the button action + // to trigger first + // - this _may_ well have unknown issues due to browser differences, + // variations in performance, moon cycles, sun spots, or cosmic rays + // - here be 🐲 + // - (please let it work πŸ™) + updateFocusState(focused) { + if (focused) { + this._editorFocused = true; + } else { + run.later(this, function () { + this._editorFocused = false; + }, 100); + } + }, + + openImageFileDialog() { let captureSelection = this._editor.codemirror.hasFocus(); this._openImageFileDialog({captureSelection}); }, + toggleUnsplash() { + if (this.get('_showUnsplash')) { + return this.toggleProperty('_showUnsplash'); + } + + // capture current selection before it's lost by clicking toolbar btn + if (this._editorFocused) { + this._imageInsertSelection = { + anchor: this._editor.codemirror.getCursor('anchor'), + head: this._editor.codemirror.getCursor('head') + }; + } + + this.toggleProperty('_showUnsplash'); + }, + + insertUnsplashPhoto(photo) { + let image = { + alt: photo.description || '', + url: photo.urls.regular, + credit: `Photo by [${photo.user.name}](${photo.user.links.html}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit) / [Unsplash](https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit)` + }; + + this._insertImages([image]); + }, + toggleFullScreen() { let isFullScreen = !this.get('_isFullScreen'); diff --git a/app/components/gh-scroll-trigger.js b/app/components/gh-scroll-trigger.js new file mode 100644 index 000000000..928ae6e37 --- /dev/null +++ b/app/components/gh-scroll-trigger.js @@ -0,0 +1,26 @@ +import Component from 'ember-component'; +import InViewportMixin from 'ember-in-viewport'; + +export default Component.extend(InViewportMixin, { + + onEnterViewport() {}, + + didInsertElement() { + let offset = this.get('triggerOffset'); + + this.set('viewportSpy', true); + this.set('viewportTolerance', { + top: offset, + bottom: offset, + left: offset, + right: offset + }); + + this._super(...arguments); + }, + + didEnterViewport() { + return this.onEnterViewport(); + } + +}); diff --git a/app/components/gh-simplemde.js b/app/components/gh-simplemde.js index 182e9d1c3..3a76d5ce4 100644 --- a/app/components/gh-simplemde.js +++ b/app/components/gh-simplemde.js @@ -66,6 +66,14 @@ export default TextArea.extend({ this.onChange(this._editor.value()); }); + this._editor.codemirror.on('focus', () => { + this.onFocus(); + }); + + this._editor.codemirror.on('blur', () => { + this.onBlur(); + }); + if (this.get('autofocus')) { this._editor.codemirror.execCommand('goDocEnd'); } diff --git a/app/components/gh-unsplash-photo.js b/app/components/gh-unsplash-photo.js new file mode 100644 index 000000000..9f65af4c4 --- /dev/null +++ b/app/components/gh-unsplash-photo.js @@ -0,0 +1,70 @@ +import $ from 'jquery'; +import Component from 'ember-component'; +import {computed} from '@ember/object'; +import {htmlSafe} from '@ember/string'; + +export default Component.extend({ + + height: 0, + photo: null, + tagName: '', + width: 1200, + zoomed: false, + + // closure actions + insert() {}, + zoom() {}, + + // avoid "binding style attributes" warnings + style: computed('photo.color', 'zoomed', function() { + let styles = []; + let ratio = this.get('photo.ratio'); + let zoomed = this.get('zoomed'); + + styles.push(`background-color: ${this.get('photo.color')}`); + + if (!zoomed) { + styles.push(`padding-bottom: ${ratio * 100}%`); + } + + return htmlSafe(styles.join('; ')); + }), + + imageUrl: computed('photo.urls.regular', function () { + let url = this.get('photo.urls.regular'); + + url = url.replace(/&w=1080/, '&w=1200'); + + return url; + }), + + didReceiveAttrs() { + this._super(...arguments); + + let height = this.get('width') * this.get('photo.ratio'); + + this.set('height', height); + }, + + actions: { + insert(event) { + event.preventDefault(); + event.stopPropagation(); + this.insert(this.get('photo')); + }, + + zoom(event) { + let $target = $(event.target); + + // only zoom when it wasn't one of the child links clicked + if (!$target.is('a') && $target.closest('a').hasClass('gh-unsplash-photo')) { + event.preventDefault(); + this.zoom(this.get('photo')); + } + + // don't propagate otherwise we can trigger the closeZoom action on the overlay + event.stopPropagation(); + } + } + +}); diff --git a/app/components/gh-unsplash.js b/app/components/gh-unsplash.js new file mode 100644 index 000000000..482b269f2 --- /dev/null +++ b/app/components/gh-unsplash.js @@ -0,0 +1,86 @@ +import Component from '@ember/component'; +import ShortcutsMixin from 'ghost-admin/mixins/shortcuts'; +import {bind} from '@ember/runloop'; +import {inject as injectService} from '@ember/service'; +import {or} from '@ember/object/computed'; + +const ONE_COLUMN_WIDTH = 540; +const TWO_COLUMN_WIDTH = 940; + +export default Component.extend(ShortcutsMixin, { + resizeDetector: injectService(), + unsplash: injectService(), + ui: injectService(), + + tagName: '', + zoomedPhoto: null, + + shortcuts: { + escape: 'handleEscape' + }, + + // closure actions + close() {}, + insert() {}, + + sideNavHidden: or('ui.{autoNav,isFullScreen,showMobileMenu}'), + + didInsertElement() { + this._super(...arguments); + this._resizeCallback = bind(this, this._handleResize); + this.get('resizeDetector').setup('.gh-unsplash', this._resizeCallback); + this.registerShortcuts(); + }, + + willDestroyElement() { + this._super(...arguments); + this.get('resizeDetector').teardown('.gh-unsplash', this._resizeCallback); + this.removeShortcuts(); + }, + + actions: { + loadNextPage() { + this.get('unsplash').loadNextPage(); + }, + + zoomPhoto(photo) { + this.set('zoomedPhoto', photo); + }, + + closeZoom() { + this.set('zoomedPhoto', null); + }, + + insert(photo) { + this.insert(photo); + this.close(); + }, + + close() { + this.close(); + }, + + retry() { + this.get('unsplash').retryLastRequest(); + }, + + handleEscape() { + if (!this.get('zoomedPhoto')) { + this.close(); + } + } + }, + + _handleResize(element) { + let width = element.clientWidth; + let columns = 3; + + if (width <= ONE_COLUMN_WIDTH) { + columns = 1; + } else if (width <= TWO_COLUMN_WIDTH) { + columns = 2; + } + + this.get('unsplash').changeColumnCount(columns); + } +}); diff --git a/app/controllers/settings/apps/unsplash.js b/app/controllers/settings/apps/unsplash.js new file mode 100644 index 000000000..8202f0fa7 --- /dev/null +++ b/app/controllers/settings/apps/unsplash.js @@ -0,0 +1,101 @@ +import Controller from 'ember-controller'; +import injectService from 'ember-service/inject'; +import {alias, empty} from 'ember-computed'; +import {task} from 'ember-concurrency'; + +export default Controller.extend({ + notifications: injectService(), + settings: injectService(), + config: injectService(), + unsplash: injectService(), + + model: alias('settings.unsplash'), + testRequestDisabled: empty('model.applicationId'), + + _triggerValidations() { + let isActive = this.get('model.isActive'); + let applicationId = this.get('model.applicationId'); + + this.get('model.hasValidated').clear(); + + // api key field is hidden if set via config so don't validate in that case + if (!this.get('config.unsplashAPI.applicationId')) { + // CASE: application id is empty but unsplash is enabled + if (isActive && !applicationId) { + this.get('model.errors').add( + 'isActive', + 'You need to enter an Application ID before enabling it' + ); + + this.get('model.hasValidated').pushObject('isActive'); + } else { + // run the validation for application id + this.get('model').validate(); + } + } + + this.get('model.hasValidated').pushObject('isActive'); + }, + + save: task(function* () { + let unsplash = this.get('model'); + let settings = this.get('settings'); + + // Don't save when we have errors and properties are not validated + if ((this.get('model.errors.isActive') || this.get('model.errors.applicationId'))) { + return; + } + + try { + settings.set('unsplash', unsplash); + return yield settings.save(); + } catch (error) { + if (error) { + this.get('notifications').showAPIError(error); + throw error; + } + } + }).drop(), + + sendTestRequest: task(function* () { + let notifications = this.get('notifications'); + let applicationId = this.get('model.applicationId'); + + try { + yield this.get('unsplash').sendTestRequest(applicationId); + } catch (error) { + notifications.showAPIError(error, {key: 'unsplash-test:send'}); + return false; + } + + // save the application id when it's valid + yield this.get('save').perform(); + return true; + }).drop(), + + actions: { + save() { + this.get('save').perform(); + }, + + update(value) { + if (this.get('model.errors.isActive')) { + this.get('model.errors.isActive').clear(); + } + + this.set('model.isActive', value); + this._triggerValidations(); + }, + + updateId(value) { + value = value ? value.toString().trim() : ''; + + if (this.get('model.errors.applicationId')) { + this.get('model.errors.applicationId').clear(); + } + + this.set('model.applicationId', value); + this._triggerValidations(); + } + } +}); diff --git a/app/controllers/setup/two.js b/app/controllers/setup/two.js index 3c0d4c249..a370d43f7 100644 --- a/app/controllers/setup/two.js +++ b/app/controllers/setup/two.js @@ -216,23 +216,28 @@ export default Controller.extend(ValidationEngine, { }, _afterAuthentication(result) { + let promises = []; + + promises.pushObject(this.get('settings').fetch()); + promises.pushObject(this.get('config').fetchPrivate()); + if (this.get('profileImage')) { return this._sendImage(result.users[0]) .then(() => { - - // fetch settings for synchronous access before transitioning - return this.get('settings').fetch().then(() => { - return this.transitionToRoute('setup.three'); - }); + // fetch settings and private config for synchronous access before transitioning + return RSVP.all(promises) + .then(() => { + return this.transitionToRoute('setup.three'); + }); }).catch((resp) => { this.get('notifications').showAPIError(resp, {key: 'setup.blog-details'}); }); } else { - - // fetch settings for synchronous access before transitioning - return this.get('settings').fetch().then(() => { - return this.transitionToRoute('setup.three'); - }); + // fetch settings and private config for synchronous access before transitioning + return RSVP.all(promises) + .then(() => { + return this.transitionToRoute('setup.three'); + }); } }, diff --git a/app/controllers/signin.js b/app/controllers/signin.js index 1a81d0513..f84af2a00 100644 --- a/app/controllers/signin.js +++ b/app/controllers/signin.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Controller from 'ember-controller'; +import RSVP from 'rsvp'; import ValidationEngine from 'ghost-admin/mixins/validation-engine'; import injectController from 'ember-controller/inject'; import injectService from 'ember-service/inject'; @@ -30,9 +31,13 @@ export default Controller.extend(ValidationEngine, { try { let authResult = yield this.get('session') .authenticate(authStrategy, ...authentication); + let promises = []; - // fetch settings for synchronous access - yield this.get('settings').fetch(); + promises.pushObject(this.get('settings').fetch()); + promises.pushObject(this.get('config').fetchPrivate()); + + // fetch settings and private config for synchronous access + yield RSVP.all(promises); return authResult; diff --git a/app/controllers/signup.js b/app/controllers/signup.js index b2b2f90e4..432031bdf 100644 --- a/app/controllers/signup.js +++ b/app/controllers/signup.js @@ -31,9 +31,13 @@ export default Controller.extend(ValidationEngine, { try { let authResult = yield this.get('session') .authenticate(authStrategy, ...authentication); + let promises = []; - // fetch settings for synchronous access - yield this.get('settings').fetch(); + promises.pushObject(this.get('settings').fetch()); + promises.pushObject(this.get('config').fetchPrivate()); + + // fetch settings and private config for synchronous access + yield RSVP.all(promises); return authResult; diff --git a/app/mixins/validation-engine.js b/app/mixins/validation-engine.js index caf3bf12b..54270b681 100644 --- a/app/mixins/validation-engine.js +++ b/app/mixins/validation-engine.js @@ -13,6 +13,7 @@ import SignupValidator from 'ghost-admin/validators/signup'; import SlackIntegrationValidator from 'ghost-admin/validators/slack-integration'; import SubscriberValidator from 'ghost-admin/validators/subscriber'; import TagSettingsValidator from 'ghost-admin/validators/tag-settings'; +import UnsplashIntegrationValidator from 'ghost-admin/validators/unsplash-integration'; import UserValidator from 'ghost-admin/validators/user'; import ValidatorExtensions from 'ghost-admin/utils/validator-extensions'; import {A as emberA, isEmberArray} from 'ember-array/utils'; @@ -46,7 +47,8 @@ export default Mixin.create({ slackIntegration: SlackIntegrationValidator, subscriber: SubscriberValidator, tag: TagSettingsValidator, - user: UserValidator + user: UserValidator, + unsplashIntegration: UnsplashIntegrationValidator }, // This adds the Errors object to the validation engine, and shouldn't affect diff --git a/app/models/setting.js b/app/models/setting.js index 8bc71e44b..804425a3d 100644 --- a/app/models/setting.js +++ b/app/models/setting.js @@ -24,5 +24,6 @@ export default Model.extend(ValidationEngine, { isPrivate: attr('boolean'), password: attr('string'), slack: attr('slack-settings'), - amp: attr('boolean') + amp: attr('boolean'), + unsplash: attr('unsplash-settings') }); diff --git a/app/models/unsplash-integration.js b/app/models/unsplash-integration.js new file mode 100644 index 000000000..61f48d1e6 --- /dev/null +++ b/app/models/unsplash-integration.js @@ -0,0 +1,11 @@ +import EmberObject from 'ember-object'; +import ValidationEngine from 'ghost-admin/mixins/validation-engine'; + +export default EmberObject.extend(ValidationEngine, { + // values entered here will act as defaults + applicationId: '', + + validationType: 'unsplashIntegration', + + isActive: false +}); diff --git a/app/router.js b/app/router.js index d946a0cda..3e13577cf 100644 --- a/app/router.js +++ b/app/router.js @@ -55,6 +55,7 @@ GhostRouter.map(function () { this.route('settings.apps', {path: '/settings/apps'}, function () { this.route('slack', {path: 'slack'}); this.route('amp', {path: 'amp'}); + this.route('unsplash', {path: 'unsplash'}); }); this.route('subscribers', function () { diff --git a/app/routes/application.js b/app/routes/application.js index fa176ae97..58d19d0ee 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -73,6 +73,7 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { }); let settingsPromise = this.get('settings').fetch(); + let privateConfigPromise = this.get('config').fetchPrivate(); let tourPromise = this.get('tour').fetchViewed(); // return the feature/settings load promises so that we block until @@ -80,6 +81,7 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { return RSVP.all([ featurePromise, settingsPromise, + privateConfigPromise, tourPromise ]); } diff --git a/app/routes/settings/apps/unsplash.js b/app/routes/settings/apps/unsplash.js new file mode 100644 index 000000000..734cae49b --- /dev/null +++ b/app/routes/settings/apps/unsplash.js @@ -0,0 +1,39 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import UnsplashObject from 'ghost-admin/models/unsplash-integration'; +import styleBody from 'ghost-admin/mixins/style-body'; +import {inject as injectService} from '@ember/service'; + +export default AuthenticatedRoute.extend(styleBody, { + config: injectService(), + settings: injectService(), + + titleToken: 'Settings - Apps - Unsplash', + classNames: ['settings-view-apps-unsplash'], + + beforeModel() { + let settings = this.get('settings'); + + if (settings.get('unsplash')) { + return; + } + + // server doesn't have any unsplash settings by default but it can provide + // overrides via config: + // - isActive: use as default but allow settings override + // - applicationId: total override, no field is shown if present + let unsplash = UnsplashObject.create({ + isActive: this.get('config.unsplashAPI.isActive') || false, + applicationId: '' + }); + + settings.set('unsplash', unsplash); + + return unsplash; + }, + + actions: { + save() { + this.get('controller').send('save'); + } + } +}); diff --git a/app/services/config.js b/app/services/config.js index b8cf4214d..fa3a0fbd0 100644 --- a/app/services/config.js +++ b/app/services/config.js @@ -2,6 +2,7 @@ import Ember from 'ember'; import Service from 'ember-service'; import computed from 'ember-computed'; import injectService from 'ember-service/inject'; +import {assign} from 'ember-platform'; import {isBlank} from 'ember-utils'; // ember-cli-shims doesn't export _ProxyMixin @@ -16,12 +17,20 @@ export default Service.extend(_ProxyMixin, { fetch() { let configUrl = this.get('ghostPaths.url').api('configuration'); - return this.get('ajax').request(configUrl).then((config) => { + return this.get('ajax').request(configUrl).then((publicConfig) => { // normalize blogUrl to non-trailing-slash - let [{blogUrl}] = config.configuration; - config.configuration[0].blogUrl = blogUrl.replace(/\/$/, ''); + let [{blogUrl}] = publicConfig.configuration; + publicConfig.configuration[0].blogUrl = blogUrl.replace(/\/$/, ''); - this.set('content', config.configuration[0]); + this.set('content', publicConfig.configuration[0]); + }); + }, + + fetchPrivate() { + let privateConfigUrl = this.get('ghostPaths.url').api('configuration', 'private'); + + return this.get('ajax').request(privateConfigUrl).then((privateConfig) => { + assign(this.get('content'), privateConfig.configuration[0]); }); }, diff --git a/app/services/media-queries.js b/app/services/media-queries.js index ab0a4fa31..b43367eea 100644 --- a/app/services/media-queries.js +++ b/app/services/media-queries.js @@ -1,5 +1,6 @@ -import Service from 'ember-service'; -import run from 'ember-runloop'; +import Evented from '@ember/object/evented'; +import Service from '@ember/service'; +import {run} from '@ember/runloop'; const MEDIA_QUERIES = { maxWidth600: '(max-width: 600px)', @@ -8,7 +9,7 @@ const MEDIA_QUERIES = { maxWidth1000: '(max-width: 1000px)' }; -export default Service.extend({ +export default Service.extend(Evented, { init() { this._super(...arguments); this._handlers = []; @@ -30,7 +31,8 @@ export default Service.extend({ let lastValue = this.get(key); let newValue = query.matches; if (lastValue !== newValue) { - this.set(key, query.matches); + this.set(key, newValue); + this.trigger('change', key, newValue); } }); query.addListener(handler); diff --git a/app/services/ui.js b/app/services/ui.js index 81887e84a..e1b85cedc 100644 --- a/app/services/ui.js +++ b/app/services/ui.js @@ -1,14 +1,21 @@ import Service from '@ember/service'; import injectService from 'ember-service/inject'; import {computed} from '@ember/object'; +import {not, or, reads} from '@ember/object/computed'; export default Service.extend({ dropdown: injectService(), + mediaQueries: injectService(), autoNav: false, + isFullScreen: false, showMobileMenu: false, showSettingsMenu: false, + hasSideNav: not('isSideNavHidden'), + isMobile: reads('mediaQueries.isMobile'), + isSideNavHidden: or('autoNav', 'isFullScreen', 'isMobile'), + autoNavOpen: computed('autoNav', { get() { return false; diff --git a/app/services/unsplash.js b/app/services/unsplash.js new file mode 100644 index 000000000..709db3c96 --- /dev/null +++ b/app/services/unsplash.js @@ -0,0 +1,244 @@ +import Service from '@ember/service'; +import fetch from 'fetch'; +import injectService from 'ember-service/inject'; +import {isEmpty} from '@ember/utils'; +import {or} from '@ember/object/computed'; +import {reject, resolve} from 'rsvp'; +import {task, taskGroup, timeout} from 'ember-concurrency'; + +const API_URL = 'https://api.unsplash.com'; +const API_VERSION = 'v1'; +const DEBOUNCE_MS = 600; + +export default Service.extend({ + config: injectService(), + settings: injectService(), + + columnCount: 3, + columns: null, + error: '', + photos: null, + searchTerm: '', + + _columnHeights: null, + _pagination: null, + + applicationId: or('config.unsplashAPI.applicationId', 'settings.unsplash.applicationId'), + isLoading: or('_search.isRunning', '_loadingTasks.isRunning'), + + init() { + this._super(...arguments); + this._reset(); + }, + + loadNew() { + this._reset(); + return this.get('_loadNew').perform(); + }, + + loadNextPage() { + // protect against scroll trigger firing when the photos are reset + if (this.get('_search.isRunning')) { + return; + } + + if (isEmpty(this.get('photos'))) { + return this.get('_loadNew').perform(); + } + + if (this._pagination.next) { + return this.get('_loadNextPage').perform(); + } + + // TODO: return error? + return reject(); + }, + + changeColumnCount(newColumnCount) { + if (newColumnCount !== this.get('columnCount')) { + this.set('columnCount', newColumnCount); + this._resetColumns(); + } + }, + + sendTestRequest(testApplicationId) { + let url = `${API_URL}/photos/random`; + let headers = {}; + + headers.Authorization = `Client-ID ${testApplicationId}`; + headers['Accept-Version'] = API_VERSION; + + return fetch(url, {headers}) + .then((response) => this._checkStatus(response)); + }, + + actions: { + updateSearch(term) { + if (term === this.get('searchTerm')) { + return; + } + + this.set('searchTerm', term); + this._reset(); + + if (term) { + return this.get('_search').perform(term); + } else { + return this.get('_loadNew').perform(); + } + } + }, + + _loadingTasks: taskGroup().drop(), + + _loadNew: task(function* () { + let url = `${API_URL}/photos?per_page=30`; + yield this._makeRequest(url); + }).group('_loadingTasks'), + + _loadNextPage: task(function* () { + yield this._makeRequest(this._pagination.next); + }).group('_loadingTasks'), + + _retryLastRequest: task(function* () { + yield this._makeRequest(this._lastRequestUrl); + }).group('_loadingTasks'), + + _search: task(function* (term) { + yield timeout(DEBOUNCE_MS); + + let url = `${API_URL}/search/photos?query=${term}&per_page=30`; + yield this._makeRequest(url); + }).restartable(), + + _addPhotosFromResponse(response) { + let photos = response.results || response; + + photos.forEach((photo) => this._addPhoto(photo)); + }, + + _addPhoto(photo) { + // pre-calculate ratio for later use + photo.ratio = photo.height / photo.width; + + // add to general photo list + this.get('photos').pushObject(photo); + + // add to least populated column + this._addPhotoToColumns(photo); + }, + + _addPhotoToColumns(photo) { + let min = Math.min(...this._columnHeights); + let columnIndex = this._columnHeights.indexOf(min); + + // use a fixed width when calculating height to compensate for different + // overall image sizes + this._columnHeights[columnIndex] += 300 * photo.ratio; + this.get('columns')[columnIndex].pushObject(photo); + }, + + _reset() { + this.set('photos', []); + this._pagination = {}; + this._resetColumns(); + }, + + _resetColumns() { + let columns = []; + let columnHeights = []; + + // pre-fill column arrays based on columnCount + for (let i = 0; i < this.get('columnCount'); i++) { + columns[i] = []; + columnHeights[i] = 0; + } + + this.set('columns', columns); + this._columnHeights = columnHeights; + + if (!isEmpty(this.get('photos'))) { + this.get('photos').forEach((photo) => { + this._addPhotoToColumns(photo); + }); + } + }, + + _makeRequest(url) { + let headers = {}; + + // clear any previous error + this.set('error', ''); + + // store the url so it can be retried if needed + this._lastRequestUrl = url; + + headers.Authorization = `Client-ID ${this.get('applicationId')}`; + headers['Accept-Version'] = API_VERSION; + + return fetch(url, {headers}) + .then((response) => this._checkStatus(response)) + .then((response) => this._extractPagination(response)) + .then((response) => response.json()) + .then((response) => this._addPhotosFromResponse(response)) + .catch(() => { + // if the error text isn't already set then we've get a connection error from `fetch` + if (!this.get('error')) { + this.set('error', 'Uh-oh! Trouble reaching the Unsplash API, please check your connection'); + } + }); + }, + + _checkStatus(response) { + // successful request + if (response.status >= 200 && response.status < 300) { + return resolve(response); + } + + let errorText = ''; + let responseTextPromise = resolve(); + + if (response.headers.map['content-type'] === 'application/json') { + responseTextPromise = response.json().then((json) => { + return json.errors[0]; + }); + } else if (response.headers.map['content-type'] === 'text/xml') { + responseTextPromise = response.text(); + } + + return responseTextPromise.then((responseText) => { + if (response.status === 403 && response.headers.map['x-ratelimit-remaining'] === '0') { + // we've hit the ratelimit on the API + errorText = 'Unsplash API rate limit reached, please try again later.'; + } + + errorText = errorText || responseText || `Error ${response.status}: Uh-oh! Trouble reaching the Unsplash API`; + + // set error text for display in UI + this.set('error', errorText); + + // throw error to prevent further processing + let error = new Error(errorText); + error.response = response; + throw error; + }); + }, + + _extractPagination(response) { + let pagination = {}; + let linkRegex = new RegExp('<(.*)>; rel="(.*)"'); + let {link} = response.headers.map; + + if (link) { + link.split(',').forEach((link) => { + let [, url, rel] = linkRegex.exec(link); + + pagination[rel] = url; + }); + } + + this._pagination = pagination; + + return response; + } +}); diff --git a/app/styles/app.css b/app/styles/app.css index 07f75614e..fd4d11275 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -34,6 +34,7 @@ @import "components/publishmenu.css"; @import "components/popovers.css"; @import "components/tour.css"; +@import "components/unsplash.css"; /* Layouts: Groups of Components diff --git a/app/styles/components/unsplash.css b/app/styles/components/unsplash.css new file mode 100644 index 000000000..9a85b6a77 --- /dev/null +++ b/app/styles/components/unsplash.css @@ -0,0 +1,324 @@ +/* Unsplash Integration +/* ---------------------------------------------------------- */ + + +/* The parent container + layout +/* ---------------------------------------------------------- */ + +.gh-unsplash { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 9000; + overflow: auto; +} + +.gh-unsplash--with-sidenav { + margin-left: 280px; +} + +.gh-unsplash-window { + padding: 25px; + background: #fff; +} + +.gh-unsplash-container { + display: flex; + flex-direction: column; + width: 100%; + min-height: calc(100vh - 200px); + max-width: 1200px; + margin: 100px auto; +} + +.gh-unsplash-logo { + position: absolute; + top: 23px; + left: 25px; + display: block; +} + +.gh-unsplash-logo svg { + width: 32px; +} + +.gh-unsplash-close { + position: absolute; + top: 25px; + right: 25px; + width: 25px; + height: 25px; +} + +.gh-unsplash-close:hover { + cursor: pointer; +} + +.gh-unsplash-close svg { + fill: color(var(--midgrey) l(+15%)); +} + +.gh-unsplash-header { + text-align: center; +} + +.gh-unsplash-header-desc { + font-size: 1.6rem; + line-height: 1.6em; +} + +.gh-unsplash-header .gh-input-icon svg { + left: 15px; + fill: #777; +} + +.gh-unsplash-header .gh-input-icon { + display: block; + max-width: 1000px; + margin: 50px auto; +} + +.gh-unsplash-search { + width: 100%; + height: 40px; + margin: 0; + padding: 0 30px 1px 50px; + outline: none; + border: 1px solid transparent; + color: #111; + font-size: 14px; + background-color: #f1f1f1; + border-radius: 20px; + transition: all 0.2s ease-in-out; + + appearance: none; +} + +.gh-unsplash-search:hover { + border-color: #d1d1d1; +} + +.gh-unsplash-search:focus { + border-color: #d1d1d1; + background: #fff; +} + + +/* Loading styles +/* ---------------------------------------------------------- */ + +.gh-unsplash-loading { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; +} + + +/* Error styles +/* ---------------------------------------------------------- */ + +.gh-unsplash-error { + max-width: 1200px; + text-align: center; +} + +.gh-unsplash-error-404 { + display: block; + min-height: 225px; + max-width: 300px; + margin: 0 auto; +} + + +/* Photo grid and global styles +/* ---------------------------------------------------------- */ + +.gh-unsplash .gh-loading-spinner { + display: block; + margin: 0 auto; +} + +.gh-unsplash-grid { + display: flex; + flex-direction: row; + justify-content: center; + align-content: stretch; + box-sizing: border-box; + width: 100%; +} + +.gh-unsplash-grid-column { + flex-grow: 1; + flex-basis: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-content: stretch; + margin-right: 24px; +} + +.gh-unsplash-grid-column:last-of-type { + margin-right: 0; +} + +.gh-unsplash-photo { + position: relative; + display: block; + width: 100%; + margin: 0 0 24px; + color: #fff; + cursor: zoom-in; +} + +.gh-unsplash-photo-container > img { + position: absolute; + display: block; + height: auto; +} + +/* Hover overlay */ +.gh-unsplash-photo-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 20px; + color: #fff; + background-image: linear-gradient(180deg,rgba(0,0,0,0.2) 0,transparent 40%,transparent 60%,rgba(0,0,0,0.3)); + opacity: 0; + transition: all 0.15s ease-in-out; +} + +.gh-unsplash-photo:hover .gh-unsplash-photo-overlay { + opacity: 1; +} + +/* Buttons used within photo cards */ +.gh-unsplash-button { + flex-shrink: 0; + display: flex; + align-items: center; + margin-left: 10px; + padding: 8px 12px; + color: #777; + font-size: 1.4rem; + line-height: 1.1em; + font-weight: 500; + background: #fff; + border-radius: 5px; + opacity: 0.9; + transition: all 0.15s ease-in-out; +} + +.gh-unsplash-button:hover { + opacity: 1; +} + + +/* Photo overlay content +/* ---------------------------------------------------------- */ + +.gh-unsplash-photo-header { + flex-grow: 0; + display: flex; + justify-content: flex-end; + align-items: center; +} + +.gh-unsplash-photo-author { + display: flex; + align-items: center; + min-width: 0; + font-size: 1.5rem; + line-height: 1.15em; +} + +.gh-unsplash-photo-author-img { + flex-shrink: 0; + display: block; + width: 30px; + height: 30px; + overflow: hidden; + margin-right: 10px; + border-radius: 100%; +} + +.gh-unsplash-photo-author-name { + display: block; + overflow: hidden; + color: #fff; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gh-unsplash-button-likes svg { + height: 15px; + margin-right: 5px; + fill: #ff3f49; +} + +.gh-unsplash-photo-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.gh-unsplash-button-download svg { + height: 13px; + margin: 2px 0 0 0; + stroke: #777; + stroke-width: 3px; +} + + +/* Photo Zoom Preview +/* ---------------------------------------------------------- */ + +.gh-unsplash-zoom { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 9500; + display: flex; + justify-content: center; + align-items: center; + overflow: auto; + padding: 25px; + background: rgba(255,255,255,0.8); + + backdrop-filter: blur(2px); +} + +.gh-unsplash-zoom .gh-unsplash-photo { + position: relative; + width: auto; + min-height: 400px; + max-width: 1200px; + max-height: calc(100vh - 50px); + margin: 0; + color: #fff; + cursor: zoom-out; + background: var(--darkgrey); + box-shadow: rgba(39,44,49,0.1) 8px 14px 38px, rgba(39, 44, 49, 0.08) 1px 3px 8px; +} + +.gh-unsplash-zoom .gh-unsplash-photo-container > img { + position: static; + display: block; + width: auto; + max-height: calc(100vh - 50px); +} + +.gh-unsplash-zoom .gh-unsplash-photo-overlay { + opacity: 1; +} diff --git a/app/styles/components/uploader.css b/app/styles/components/uploader.css index 6f314b807..396766930 100644 --- a/app/styles/components/uploader.css +++ b/app/styles/components/uploader.css @@ -6,14 +6,14 @@ display: flex; flex-direction: column; align-items: center; + width: 100%; + min-height: 130px; overflow: hidden; margin: 1.6em 0; - min-height: 130px; - width: 100%; - background: #fff; - border-radius: 4px; color: color(var(--midgrey) l(-18%)); text-align: center; + background: #fff; + border-radius: 4px; border-radius: 4px; } @@ -28,9 +28,9 @@ .gh-image-uploader img { display: block; - margin: 0 auto; - max-width: 100%; min-width: 200px; + max-width: 100%; + margin: 0 auto; line-height: 0; } @@ -40,28 +40,29 @@ right: 10px; z-index: 300; display: block; + display: flex; + align-items: center; padding: 8px; + color: #fff; + font-size: 13px; + line-height: 10px; + text-decoration: none; background: rgba(0, 0, 0, 0.6); border-radius: var(--border-radius); box-shadow: rgba(255, 255, 255, 0.2) 0 0 0 1px; - color: #fff; - text-decoration: none; - font-size: 13px; - line-height: 10px; - display: flex; - align-items: center; } .gh-image-uploader .image-cancel svg { - fill: #fff; - height: 13px; width: 13px; + height: 13px; + fill: #fff; } .gh-image-uploader .upload-form { flex-grow: 1; display: flex; flex-direction: row; + width: 100%; } .gh-image-uploader .x-file-input { @@ -72,21 +73,21 @@ .gh-image-uploader .x-file-input label { flex-grow: 1; display: flex; - align-items: center; justify-content: center; + align-items: center; outline: none; } .gh-image-uploader .description { width: 100%; - text-align: center; font-size: 1.6rem; + text-align: center; } .gh-image-uploader .image-cancel:hover { - background: var(--red); color: #fff; cursor: pointer; + background: var(--red); } .gh-image-uploader .failed { @@ -106,9 +107,9 @@ .gh-image-uploader .progress, .gh-progress-container-progress { + width: 60%; overflow: hidden; margin: 0 auto; - width: 60%; background: linear-gradient(to bottom, #f5f5f5, #f9f9f9); border-radius: 12px; box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px inset; @@ -131,3 +132,28 @@ margin-top: 1em; margin-bottom: 3em; } + + +/* Unsplash Button +/* ---------------------------------------------------------- */ + +.gh-image-uploader-unsplash { + position: absolute; + bottom: 0; + left: 0; + width: 36px; + height: 36px; + padding: 10px; + opacity: 0.17; + transition: opacity 0.5s ease; +} + +.gh-image-uploader-unsplash:hover { + cursor: pointer; + opacity: 0.8; + transition: opacity 0.3s ease; +} + +.gh-image-uploader-unsplash svg { + width: 16px; +} diff --git a/app/styles/patterns/forms.css b/app/styles/patterns/forms.css index 5d1aea48a..7bdc35405 100644 --- a/app/styles/patterns/forms.css +++ b/app/styles/patterns/forms.css @@ -62,6 +62,10 @@ input { user-select: text; } +.form-group.right { + text-align: right; +} + .form-group p { margin: 4px 0 0 0; color: var(--midgrey); diff --git a/app/templates/components/gh-image-uploader-with-preview.hbs b/app/templates/components/gh-image-uploader-with-preview.hbs index 51d182fed..3926d1da6 100644 --- a/app/templates/components/gh-image-uploader-with-preview.hbs +++ b/app/templates/components/gh-image-uploader-with-preview.hbs @@ -10,6 +10,7 @@ {{gh-image-uploader text=text altText=altText + allowUnsplash=allowUnsplash update=(action 'update') uploadStarted=(action 'uploadStarted') uploadFinished=(action 'uploadFinished') diff --git a/app/templates/components/gh-image-uploader.hbs b/app/templates/components/gh-image-uploader.hbs index 5a1e43966..e53d8bb18 100644 --- a/app/templates/components/gh-image-uploader.hbs +++ b/app/templates/components/gh-image-uploader.hbs @@ -17,5 +17,18 @@ {{#gh-file-input multiple=false alt=description action=(action "fileSelected") accept=accept}}
{{description}}
{{/gh-file-input}} + + {{#if (and allowUnsplash unsplash.isActive)}} +
+ {{inline-svg "unsplash"}} +
+ {{/if}} {{/if}} + +{{#if _showUnsplash}} + {{gh-unsplash + insert=(action "addUnsplashPhoto") + close=(action (toggle "_showUnsplash" this)) + }} +{{/if}} diff --git a/app/templates/components/gh-markdown-editor.hbs b/app/templates/components/gh-markdown-editor.hbs index 8ce299c2e..4ca5618d9 100644 --- a/app/templates/components/gh-markdown-editor.hbs +++ b/app/templates/components/gh-markdown-editor.hbs @@ -4,6 +4,8 @@ placeholder=placeholder autofocus=autofocus onChange=(action "updateMarkdown") + onFocus=(action "updateFocusState" true) + onBlur=(action "updateFocusState" false) onEditorInit=(action "setEditor") onEditorDestroy=(action "destroyEditor") options=simpleMDEOptions) @@ -15,3 +17,9 @@
{{gh-file-input multiple=true action=(action onImageFilesSelected) accept=imageMimeTypes}}
+ +{{#if _showUnsplash}} + {{gh-unsplash + insert=(action "insertUnsplashPhoto") + close=(action "toggleUnsplash")}} +{{/if}} diff --git a/app/templates/components/gh-post-settings-menu.hbs b/app/templates/components/gh-post-settings-menu.hbs index 0d7dd71d3..6e4836967 100644 --- a/app/templates/components/gh-post-settings-menu.hbs +++ b/app/templates/components/gh-post-settings-menu.hbs @@ -10,7 +10,8 @@
{{gh-image-uploader-with-preview image=model.featureImage - text="Add post image" + text="Upload post image" + allowUnsplash=true update=(action "setCoverImage") remove=(action "clearCoverImage") }} @@ -231,6 +232,7 @@ {{gh-image-uploader-with-preview image=model.twitterImage text="Add Twitter image" + allowUnsplash=true update=(action "setTwitterImage") remove=(action "clearTwitterImage") }} @@ -298,6 +300,7 @@ {{gh-image-uploader-with-preview image=model.ogImage text="Add Facebook image" + allowUnsplash=true update=(action "setOgImage") remove=(action "clearOgImage") }} diff --git a/app/templates/components/gh-scroll-trigger.hbs b/app/templates/components/gh-scroll-trigger.hbs new file mode 100644 index 000000000..889d9eead --- /dev/null +++ b/app/templates/components/gh-scroll-trigger.hbs @@ -0,0 +1 @@ +{{yield}} diff --git a/app/templates/components/gh-tag-settings-form.hbs b/app/templates/components/gh-tag-settings-form.hbs index 06b38d6f7..029486db9 100644 --- a/app/templates/components/gh-tag-settings-form.hbs +++ b/app/templates/components/gh-tag-settings-form.hbs @@ -11,7 +11,8 @@
{{gh-image-uploader-with-preview image=tag.featureImage - text="Add tag image" + text="Upload tag image" + allowUnsplash=true update=(action "setCoverImage") remove=(action "clearCoverImage")}}
diff --git a/app/templates/components/gh-unsplash-photo.hbs b/app/templates/components/gh-unsplash-photo.hbs new file mode 100644 index 000000000..891387e14 --- /dev/null +++ b/app/templates/components/gh-unsplash-photo.hbs @@ -0,0 +1,22 @@ + + + diff --git a/app/templates/components/gh-unsplash.hbs b/app/templates/components/gh-unsplash.hbs new file mode 100644 index 000000000..c7a39c741 --- /dev/null +++ b/app/templates/components/gh-unsplash.hbs @@ -0,0 +1,73 @@ +{{#liquid-wormhole class="unsplash"}} +
+
+
+ +
{{inline-svg "close"}}
+
+

Unsplash

+

Beautiful, free photos.
+ Gifted by the world’s most generous community of photographers. 🎁

+ + {{inline-svg "search"}} + {{gh-input unsplash.searchTerm + class="gh-unsplash-search" + type="text" + name="searchKeyword" + placeholder="Search free high-resolution photos" + tabindex="1" + autocorrect="off" + update=(action "updateSearch" target=unsplash) + }} + +
+ + {{#if unsplash.photos}} +
+ {{#each unsplash.columns as |photos|}} +
+ {{#each photos as |photo|}} + {{gh-unsplash-photo photo=photo zoom=(action "zoomPhoto") insert=(action "insert")}} + {{/each}} +
+ {{/each}} +
+ {{else if (and unsplash.searchTerm (not unsplash.error unsplash.isLoading))}} +
+ No photos found +

No photos found for '{{unsplash.searchTerm}}'

+
+ {{/if}} + + {{#if unsplash.error}} + {{!-- TODO: add better error styles? --}} +
+ Network error +

{{unsplash.error}} (retry)

+
+ {{/if}} + + {{#if unsplash.isLoading}} +
+
+
+ {{/if}} + + {{gh-scroll-trigger + onEnterViewport=(action "loadNextPage") + triggerOffset=1000}} +
+
+
+ + {{#if zoomedPhoto}} +
+ {{gh-unsplash-photo + photo=zoomedPhoto + zoomed=true + zoom=(action "closeZoom") + insert=(action "insert")}} +
+ {{/if}} + +{{/liquid-wormhole}} diff --git a/app/templates/settings/apps/index.hbs b/app/templates/settings/apps/index.hbs index 6c892e48a..fa7ff2087 100644 --- a/app/templates/settings/apps/index.hbs +++ b/app/templates/settings/apps/index.hbs @@ -53,6 +53,30 @@ {{/link-to}}
+ +
+ {{#link-to "settings.apps.unsplash" id="unsplash-link"}} +
+
+
+
+

Unsplash

+

Beautiful, free photos

+
+
+
+
+ {{#if unsplash.isActive}} + Active + {{else}} + Configure + {{/if}} + {{inline-svg "arrow-right"}} +
+
+
+ {{/link-to}} +

(More coming soon!)

diff --git a/app/templates/settings/apps/unsplash.hbs b/app/templates/settings/apps/unsplash.hbs new file mode 100644 index 000000000..7ad395463 --- /dev/null +++ b/app/templates/settings/apps/unsplash.hbs @@ -0,0 +1,66 @@ +
+
+

+ {{#link-to "settings.apps.index"}}Apps{{/link-to}} + {{inline-svg "arrow-right"}} + Unsplash +

+
+ {{gh-task-button task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-save-button=true}} +
+
+ +
+
+
+
+ +
+
+

Unsplash

+

Beautiful, free photos

+
+
+ +
Unsplash configuration
+
+
+
Enable Unsplash
+
Enable Unsplash image integration for your posts
+
+
+ {{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="isActive" class="right"}} +
+ +
+ {{gh-error-message errors=model.errors property="isActive"}} + {{/gh-form-group}} +
+
+ {{#unless config.unsplashAPI.applicationId}} + +
+
+ +
Unsplash integration
+
Access Unsplash images from within the editor.
+
+ {{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="applicationId"}} + {{gh-input model.applicationId name="unsplash" update=(action "updateId") onenter=(action "save") focusOut=(action "validate" "applicationId" target=model) placeholder="0f387e82271755665bd49683e914856b1e34..." data-test-input="unsplash"}} + {{#unless model.errors.applicationId}} +

Set up a new Unsplash application here, and grab the Application ID.

+ {{else}} + {{gh-error-message errors=model.errors property="applicationId"}} + {{/unless}} + {{/gh-form-group}} +
+
+
+ + {{gh-task-button "Test Application ID" task=sendTestRequest runningText="Validating" successText="Valid Application ID" failureText="Invalid Application Id" class="gh-btn gh-btn-green gh-btn-icon" disabled=testRequestDisabled data-test-button="send-request"}} + {{/unless}} +
+
diff --git a/app/transforms/unsplash-settings.js b/app/transforms/unsplash-settings.js new file mode 100644 index 000000000..7bcd9f630 --- /dev/null +++ b/app/transforms/unsplash-settings.js @@ -0,0 +1,24 @@ +/* eslint-disable camelcase */ +import Transform from 'ember-data/transform'; +import UnsplashObject from 'ghost-admin/models/unsplash-integration'; + +export default Transform.extend({ + deserialize(serialized) { + if (serialized) { + let settingsObject; + try { + settingsObject = JSON.parse(serialized) || {}; + } catch (e) { + settingsObject = {}; + } + + return UnsplashObject.create(settingsObject); + } + + return null; + }, + + serialize(deserialized) { + return deserialized ? JSON.stringify(deserialized) : {}; + } +}); diff --git a/app/transitions.js b/app/transitions.js index ebe6721f7..ecd1fcf6a 100644 --- a/app/transitions.js +++ b/app/transitions.js @@ -24,4 +24,12 @@ export default function () { this.use('fade', {duration: 300}), this.reverse('fade', {duration: 300}) ); + + // TODO: Maybe animate with explode. gh-unsplash-window should ideally slide in from bottom to top of screen + // this.transition( + // this.hasClass('gh-unsplash-window'), + // this.toValue(true), + // this.use('toUp', {duration: 500}), + // this.reverse('toDown', {duration: 500}) + // ); } diff --git a/app/validators/unsplash-integration.js b/app/validators/unsplash-integration.js new file mode 100644 index 000000000..da3c1ffc0 --- /dev/null +++ b/app/validators/unsplash-integration.js @@ -0,0 +1,23 @@ +import BaseValidator from './base'; + +export default BaseValidator.create({ + properties: ['applicationId'], + + applicationId(model) { + let applicationId = model.get('applicationId'); + let hasValidated = model.get('hasValidated'); + + let whiteSpaceRegex = new RegExp(/^\S*$/gi); + + if (!applicationId.match(whiteSpaceRegex)) { + model.get('errors').add( + 'applicationId', + 'Please enter a valid Application Id for Unsplash' + ); + + this.invalidate(); + } + + hasValidated.addObject('applicationId'); + } +}); diff --git a/mirage/config.js b/mirage/config.js index 14fa48e23..6af9e6db8 100644 --- a/mirage/config.js +++ b/mirage/config.js @@ -37,7 +37,7 @@ export function testConfig() { // this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server this.namespace = '/ghost/api/v0.1'; // make this `api`, for example, if your API is namespaced // this.timing = 400; // delay for each request, automatically set to 0 during testing - this.logging = true; + // this.logging = true; mockAuthentication(this); mockConfiguration(this); diff --git a/mirage/config/configuration.js b/mirage/config/configuration.js index ce69fb645..fd190c204 100644 --- a/mirage/config/configuration.js +++ b/mirage/config/configuration.js @@ -22,4 +22,14 @@ export default function mockConfiguration(server) { }] }; }); + + server.get('/configuration/private/', function ({db}) { + if (isEmpty(db.private)) { + server.loadFixtures('private'); + } + + return { + configuration: [db.private] + }; + }); } diff --git a/mirage/fixtures/private.js b/mirage/fixtures/private.js new file mode 100644 index 000000000..711c7ba69 --- /dev/null +++ b/mirage/fixtures/private.js @@ -0,0 +1,3 @@ +export default [{ + unsplashAPI: '' +}]; diff --git a/mirage/fixtures/settings.js b/mirage/fixtures/settings.js index 98acc64e2..6b1007605 100644 --- a/mirage/fixtures/settings.js +++ b/mirage/fixtures/settings.js @@ -192,5 +192,15 @@ export default [ created_by: 1, updated_at: '2015-10-27T17:39:58.276Z', updated_by: 1 + }, + { + id: 23, + created_at: '2017-08-11T06:38:10.000Z', + created_by: 1, + key: 'unsplash', + type: 'blog', + updated_at: '2017-08-11T08:00:14.000Z', + updated_by: 1, + value: '{"applicationId":"","isActive":false}' } ]; diff --git a/package.json b/package.json index 4d05305b3..bdfc06cce 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,8 @@ "ember-data-filter": "1.13.0", "ember-element-resize-detector": "0.1.5", "ember-export-application-global": "2.0.0", + "ember-fetch": "3.2.9", + "ember-in-viewport": "2.1.1", "ember-infinity": "0.2.8", "ember-inline-svg": "0.1.11", "ember-invoke-action": "1.4.0", diff --git a/public/assets/icons/download.svg b/public/assets/icons/download.svg new file mode 100644 index 000000000..1c03330d0 --- /dev/null +++ b/public/assets/icons/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icons/unsplash-heart.svg b/public/assets/icons/unsplash-heart.svg new file mode 100644 index 000000000..effec87de --- /dev/null +++ b/public/assets/icons/unsplash-heart.svg @@ -0,0 +1 @@ + diff --git a/public/assets/icons/unsplash.svg b/public/assets/icons/unsplash.svg new file mode 100644 index 000000000..b696da614 --- /dev/null +++ b/public/assets/icons/unsplash.svg @@ -0,0 +1 @@ + diff --git a/public/assets/img/unsplash-404.png b/public/assets/img/unsplash-404.png new file mode 100644 index 0000000000000000000000000000000000000000..5f06f63f16160d935cb07d139c3e5077fa571092 GIT binary patch literal 2196 zcmcIlYfw{16ix^afdoPEMa1CL2dF@i3`$z@(tw&Ej|mTHFbGxT0wS+8Dokhqg(@g8 zXz&$?B3Q+sEy1)X!BVS3qjKv5gp^VxKtLb}36aE*ZlMaX)0zJ1&fL4ZXTRO^?K$6> zJsK3S6=z{*fkL5hlx;pV6v_wz-_ki4V3AkEMT72O(Do4D?Qtmzg+i@Xt5hnfRNC3u z3C82&C->!9lrP-rd~|j*`h_;Mm$%;$XnRmPrc;Mp<7^h8kNMIZ#m7$AyZhatCXN z^~|>0D3qCq;^Q515Iy+#3L4{vMS0sSNKVpM=E?#O57owwG~sG2!6E-&i+VW5uzl2I zlFqJiD6DFY4)9)Dn$RIjcoyzNIcFg<%jC?@nwisIW9SgZna|U3gS%LsdrC1d zdK&QR?mxDLCI9O=q=QE3i*sA)VVCP#bE7{djy3OrP2bTppflqeT*H3z-`@U)kTQ+v z&^foFB^a{%dLm}(lGPQ4L$N7j;C5<{vJVm2XbN3ppmQZFw z1|S9@ydO9#G$5)o(GmuCr4C44@~{t?C58+-w;D1~o-v;exFN4QRqVu}n|;9@Ndke^ zn0Y=o8W!~2U~n}%Bt1`K^8|Gs*qjSz90jUh?OwL$?&VDSh|=Llfr&MF`=N3IQYns; z1n#R&S_73YLlW3e>}2f(E0|xZsY=NFqD@m}BCo}yS1W!Aye3Y-Xk;9h&GVRO(3fSd zkOMyKxj3b$PqZ{G^NZT9RFtl3GUWU16G#31-jZ-IL9V>y`+VSARC`jMoId2nX|bGxah-bK13CX(*iX>7;Fj zR<_GVYD`3;(|LVvUkOvncBLg5>e)aL!k1=34r?GHfGzDQVpvV-kj^un68U>Qt88Y~ zH?Th2`eD?I@wpf3xXFCqokY2RVGTwa}hPtF8)3e0Ahnj!&9#Lj|94(mVgF7*|1SdUVoG}r! z-i}#ZXR)@mfC~tB9QO~erarJCN+MQWX(IXFa+edjMog$X%X*x5V5={2hgK>c5EzFf zwQuudp4`~Ni^*Uw|61UxAB}IXzp;gt-&UUVe8cZ9!L6g~^!r4P4loDY-Q+D9vvQIg z$XXO-QB9QEb3BcYnQqdv6Js}^>piZu+dAW|FW+G4kk3u|cumk@vCS=uqI(H0D+W9} z7I8LaJUBurp~fzPR+B1&>SZ~eEORgJ?)-|9@eq%Ta|1erD=T}S-z>Q4xwt1hrR4Fv z@o%V?>q8HV^PS&7g*ff4=h2kXraEp89Dd5ttgcMkFy>sarqZu3Qj?`ySyW4ZCeih| z?n>Q!`YCRdyF@1NW}LIyb`uUwt$CIZKhYB3Q04!qxbOP-AK`z!u2Dsesz;QN|N77t Xpj!7Ah(bkF#0Q9#)Kp6fP z=gMi$f5CRJcea+6mfqgp-rU?YFfb@AER2qhzIyell9JN;`ughXDh`L6o12rDmme7! zQBzaX)zx)#bE8lwDk>_nva;X5fA8t(nVFg4@pv2#XJ}|BGc&WRtE;lIl1`_4dU}#b zBx`Hyj*bo!6O;M*`RwfMrluw$k;v!spFVw>l$2CoUvF-1&SJ63%F0qxQ$s>R{QdpW zX!O+7)Z4dj0|Ek2C{%xce|&s=Mn*7UW)V1*FF~@c+y-)cd8J7*FycgNuu`x-z)r zXP_aaZz0#~n^u(#F1{Yb#viR&>JvDM!Qxp*7jt?Z;-cfg2i7s&=2l#AkB>l!CK2@= zh1QUV0@e0qn8Q1535;3`^5&1#E@6lK>uzIuoz?_UY*YJRczPleeC;Pbc@9sHO$BK( z*nxjOgDi^!w-2Y2>=@_B-XiJt1n>xd?U3OCdB{O4bMLv}Ll(eX^c3niDR8^W-VB2m@koxZvXen^#7Njk_@ zEnV)AwiV{AKA`1gsH(DiR9_lL;N+o&G-qB}1R4vKD`O3Wl&tL-sL~ZU$J z`08HmBpuGOP>@)!g`J}Y2CxWz>q-IJ#N4m##vobl&9S-1*rO=4=-;H zdZVxEwqpcVfG3{GXcUxlSek4Us8+kpoeewj7JpwrXlN;Gaww53?0OCaA>(8$zZb7Is zNHCT&r1?&5Sg5&jv9LVLLWz&$TtqJv0>c$0t(Xtcep#`I+&?NUgGTs3Q8O9FBbk=B z2J8iSP7!pU{D3-)pcw)~nMF&hCkVO}TGk`&m^(bz6|I2reV7G}>Kmfi><>C@G^g2F zd=pj=f&&gz213IenoB?nEEP)Mp?!LKXF=V$Et|B@51nqyJwLN&v#pzcl`0q%UqgT{ zk7$`${p@VJI9(Uo&uuJzCTK;<0ls(Ilh=K1U)X)!eHcm2^OIqpk8Uq$sBdCvG#*5T zUn;U;E>7BoE?lpKJB zWc2R+Zp^<>z5H58ee#7xc>{67WZswqs@IDZF+#y0gjlxjY+o80Hoq8tQOcfscc0l-|BYRi zHQzi0!^4<+v&6%3S! zgm-wzDoz(w#-H<{nJ-H$uw!zZpE4adt|K5pkn-SUfSWP45$+BbXRZ9)!wLy04V3;G zCZR0_YeurhdtIZm9zyo_T#A`}Mf}5tAN1H(>j-Tu3!$O?UXM*x#Qw>FU(FQ&{$I2{u$xyUqa$&|tjXfd$=Nb)uQ^?V`fN8dYf_!cNZPh)fZ{N?cL z3XhJv`c>aA^=61_42R`*dpd`S2fq>hN5LOI@iZMeo7~>?SyKNrR}KR-0)KYO?VKzv z@&^7BKbB(NU2~(1TAx6aFmA!b?0zy`wDPkcON!~JlkqZ2qxu~$TX`I1L_7TEYPCXP zKJ-kF!l@+rkFE@XW^ukaeyY4pf077jI)nTzu!c?QO(Jt)X9vaH;dD)!r^Mi*DL;h^$6M>cfUQ4^pd`8N-doPbiRZXGZq zXwni~6d(4zLnjOR9_Q!NeIeXW>5`BcCtZqTsdB+zR5C2Ztb&KLKDnaoyoC@FW9 z-!oP-I{Q(qm{j&nfIb;N)tg|_5Z&b2);K~hiQBKLPGL}g>{F~rd^EPU_FvF)r0F#^ z=}q%jQ{R7gfLP;ORy|$}SAWIFYr;DC)sFZzCdy(59qdgjVUm+QX=$yelG@sFtG@i2)Huin^8f` zjD={y%r3qjhSZKhOs3WABw?FUjX?QfUd3ao5^SLe|(Il~Scm zB@{}|A~EOw;M&I7DfgmR;9bVu3;7o+o}3-#C?tIyr+DcV@3PGg`4$@;2Hk%9t1sws z%FUaDqkr-u+&Ag|jdaQSl~m+}=zL1^gc&^ebtHmwV?&&+x+2e^nuOv13MoU|Z~pW! k##W%s6f+9{zp)6xg`=h!8TUW6|CLriv?f_qT6)C%53p?o3;+NC literal 0 HcmV?d00001 diff --git a/tests/acceptance/settings/apps-test.js b/tests/acceptance/settings/apps-test.js index 8f467c0fb..e6fc46abe 100644 --- a/tests/acceptance/settings/apps-test.js +++ b/tests/acceptance/settings/apps-test.js @@ -78,5 +78,16 @@ describe('Acceptance: Settings - Apps', function () { // has correct url expect(currentURL(), 'currentURL').to.equal('/settings/apps/amp'); }); + it('it redirects to Unsplash when clicking on the grid', async function () { + await visit('/settings/apps'); + + // has correct url + expect(currentURL(), 'currentURL').to.equal('/settings/apps'); + + await click('#unsplash-link'); + + // has correct url + expect(currentURL(), 'currentURL').to.equal('/settings/apps/unsplash'); + }); }); }); diff --git a/tests/acceptance/settings/unsplash-test.js b/tests/acceptance/settings/unsplash-test.js new file mode 100644 index 000000000..a50f86ba5 --- /dev/null +++ b/tests/acceptance/settings/unsplash-test.js @@ -0,0 +1,180 @@ +/* jshint expr:true */ +import Mirage from 'ember-cli-mirage'; +import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; +import destroyApp from '../../helpers/destroy-app'; +import startApp from '../../helpers/start-app'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {authenticateSession, invalidateSession} from 'ghost-admin/tests/helpers/ember-simple-auth'; +import {expect} from 'chai'; + +describe('Acceptance: Settings - Apps - Unsplash', function () { + let application; + + beforeEach(function () { + application = startApp(); + }); + + afterEach(function () { + destroyApp(application); + }); + + it('redirects to signin when not authenticated', async function () { + invalidateSession(application); + await visit('/settings/apps/unsplash'); + + expect(currentURL(), 'currentURL').to.equal('/signin'); + }); + + it('redirects to team page when authenticated as author', async function () { + let role = server.create('role', {name: 'Author'}); + server.create('user', {roles: [role], slug: 'test-user'}); + + authenticateSession(application); + await visit('/settings/apps/unsplash'); + + expect(currentURL(), 'currentURL').to.equal('/team/test-user'); + }); + + it('redirects to team page when authenticated as editor', async function () { + let role = server.create('role', {name: 'Editor'}); + server.create('user', {roles: [role], slug: 'test-user'}); + + authenticateSession(application); + await visit('/settings/apps/unsplash'); + + expect(currentURL(), 'currentURL').to.equal('/team'); + }); + + describe('when logged in', function () { + beforeEach(function () { + let role = server.create('role', {name: 'Administrator'}); + server.create('user', {roles: [role]}); + + return authenticateSession(application); + }); + + it('it validates and saves an application id properly', async function () { + server.get('/configuration/private', function () { + return new Mirage.Response(200, {}, { + configuration: { + unsplashAPI: '' + } + }); + }); + + await visit('/settings/apps/unsplash'); + + // has correct url + expect(currentURL(), 'currentURL').to.equal('/settings/apps/unsplash'); + + // without application id provided via config + expect(find('[data-test-input="unsplash"]').text().trim(), 'default application id value') + .to.equal(''); + + expect(find('[data-test-checkbox="unsplash"]').prop('checked'), 'isActive checkbox').to.be.false; + + await click(find('[data-test-checkbox="unsplash"]'), 'enable without application id'); + + expect(find('[data-test-checkbox="unsplash"]').prop('checked'), 'isActive checkbox').to.be.true; + + expect(find('#unsplash-toggle .error .response').text().trim(), 'inline validation checkbox') + .to.equal('You need to enter an Application ID before enabling it'); + + await fillIn('[data-test-input="unsplash"]', '345 456 567 '); + + expect(find('#unsplash-toggle .error .response').text().trim(), 'inline validation checkbox') + .to.equal(''); + + await click('[data-test-save-button]'); + + // application id validation + expect(find('#unsplash-settings .error .response').text().trim(), 'inline validation response') + .to.equal('Please enter a valid Application Id for Unsplash'); + + // doesn't save when errors + expect(find('[data-test-save-button]').text().trim(), 'task button saved response') + .to.equal('Retry'); + + // CMD-S shortcut works + await fillIn('[data-test-input="unsplash"]', '123456789012345678901234567890'); + await triggerEvent('.gh-app', 'keydown', { + keyCode: 83, // s + metaKey: ctrlOrCmd === 'command', + ctrlKey: ctrlOrCmd === 'ctrl' + }); + + let [firstRequest] = server.pretender.handledRequests.slice(-1); + let params = JSON.parse(firstRequest.requestBody); + let result = JSON.parse(params.settings.findBy('key', 'unsplash').value); + + expect(result.applicationId).to.equal('123456789012345678901234567890'); + expect(find('#unsplash-settings .error .response').text().trim(), 'inline validation response') + .to.equal(''); + + server.get('https://api.unsplash.com/photos/random', function () { + return new Mirage.Response(401, {}, { + errors: ['OAuth error: The access token is invalid'] + }); + }); + + // Test invalid application id + await fillIn('[data-test-input="unsplash"]', '098765432109876543210987654321'); + await click('[data-test-button="send-request"]'); + + expect(find('[data-test-button="send-request"]').text().trim(), 'test request button validation response') + .to.equal('Invalid Application Id'); + + expect(find('.gh-alert-red .gh-alert-content').text().trim(), 'server response') + .to.equal('OAuth error: The access token is invalid'); + + let [secondRequest] = server.pretender.handledRequests.slice(-1); + + // Result shouldn't be saved + expect(secondRequest.requestBody).to.equal(null); + expect(secondRequest.url).to.equal('https://api.unsplash.com/photos/random'); + + server.get('https://api.unsplash.com/photos/random', function () { + return new Mirage.Response(200); + }); + + // Test valid application id + await fillIn('[data-test-input="unsplash"]', '098765432109876543210987654321'); + await click('[data-test-button="send-request"]'); + + expect(find('[data-test-button="send-request"]').text().trim(), 'test request button validation response') + .to.equal('Valid Application ID'); + + // saves settings when valid application id + expect(find('[data-test-save-button]').text().trim(), 'task button saved response') + .to.equal('Saved'); + + let [thirdRequest] = server.pretender.handledRequests.slice(-1); + params = JSON.parse(thirdRequest.requestBody); + result = JSON.parse(params.settings.findBy('key', 'unsplash').value); + + // Result should be saved + expect(result.applicationId).to.equal('098765432109876543210987654321'); + }); + + it('does not render application id input when config provides it', async function () { + server.get('/configuration/private', function () { + return new Mirage.Response(200, {}, { + configuration: [{ + unsplashAPI: { + applicationId: '12345678923456789' + } + }] + }); + }); + + await visit('/settings/apps/unsplash'); + + // has correct url + expect(currentURL(), 'currentURL').to.equal('/settings/apps/unsplash'); + + // without application id provided via config + expect(find('[data-test-input="unsplash"]').length, 'no application id input') + .to.equal(0); + }); + }); +}); diff --git a/tests/integration/components/gh-image-uploader-test.js b/tests/integration/components/gh-image-uploader-test.js index d688caab5..6622fd426 100644 --- a/tests/integration/components/gh-image-uploader-test.js +++ b/tests/integration/components/gh-image-uploader-test.js @@ -453,4 +453,11 @@ describe('Integration: Component: gh-image-uploader', function() { done(); }); }); + + describe('unsplash', function () { + it('has unsplash icon only when unsplash is active & allowed'); + it('opens unsplash modal when icon clicked'); + it('inserts unsplash image when selected'); + it('closes unsplash modal when close is triggered'); + }); }); diff --git a/tests/integration/components/gh-markdown-editor-test.js b/tests/integration/components/gh-markdown-editor-test.js index 7a840a85c..27613b94f 100644 --- a/tests/integration/components/gh-markdown-editor-test.js +++ b/tests/integration/components/gh-markdown-editor-test.js @@ -21,4 +21,13 @@ describe('Integration: Component: gh-markdown-editor', function() { this.render(hbs`{{gh-markdown-editor}}`); expect(this.$()).to.have.length(1); }); + + describe('unsplash', function () { + it('has unsplash icon in toolbar if unsplash is active'); + it('opens unsplash modal when clicked'); + it('closes unsplash modal when close triggered'); + it('inserts unsplash image & credit when selected'); + it('inserts at cursor when editor has focus'); + it('inserts at end when editor is blurred'); + }); }); diff --git a/tests/integration/components/gh-unsplash-photo-test.js b/tests/integration/components/gh-unsplash-photo-test.js new file mode 100644 index 000000000..dc3a15cc4 --- /dev/null +++ b/tests/integration/components/gh-unsplash-photo-test.js @@ -0,0 +1,119 @@ +import hbs from 'htmlbars-inline-precompile'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {find} from 'ember-native-dom-helpers'; +import {setupComponentTest} from 'ember-mocha'; + +describe('Integration: Component: gh-unsplash-photo', function() { + setupComponentTest('gh-unsplash-photo', { + integration: true + }); + + beforeEach(function() { + // NOTE: images.unsplash.com replaced with example.com to ensure we aren't + // loading lots of images during tests and we get an immediate 404 + this.set('photo', { + 'id': 'OYFHT4X5isg', + 'created_at': '2017-08-09T00:20:42-04:00', + 'updated_at': '2017-08-11T08:27:42-04:00', + 'width': 5184, + 'height': 3456, + 'color': '#A8A99B', + 'likes': 58, + 'liked_by_user': false, + 'description': null, + 'user': { + 'id': 'cEpP9pR9Q7E', + 'updated_at': '2017-08-11T08:27:42-04:00', + 'username': 'danotis', + 'name': 'Dan Otis', + 'first_name': 'Dan', + 'last_name': 'Otis', + 'twitter_username': 'danotis', + 'portfolio_url': 'http://dan.exposure.co', + 'bio': 'Senior Visual Designer at Huge ', + 'location': 'San Jose, CA', + 'total_likes': 0, + 'total_photos': 8, + 'total_collections': 0, + 'profile_image': { + 'small': 'https://example.com/profile-fb-1502251227-8fe7a0522137.jpg?ixlib=rb-0.3.5&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=32&w=32&s=37f67120fc464d7d920ff23c84963b38', + 'medium': 'https://example.com/profile-fb-1502251227-8fe7a0522137.jpg?ixlib=rb-0.3.5&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=64&w=64&s=0a4f8a583caec826ac6b1ca80161fa43', + 'large': 'https://example.com/profile-fb-1502251227-8fe7a0522137.jpg?ixlib=rb-0.3.5&q=80&fm=jpg&crop=faces&cs=tinysrgb&fit=crop&h=128&w=128&s=b3aa4206e5d87f3eaa7bbe9180ebcd2b' + }, + 'links': { + 'self': 'https://api.unsplash.com/users/danotis', + 'html': 'https://unsplash.com/@danotis', + 'photos': 'https://api.unsplash.com/users/danotis/photos', + 'likes': 'https://api.unsplash.com/users/danotis/likes', + 'portfolio': 'https://api.unsplash.com/users/danotis/portfolio', + 'following': 'https://api.unsplash.com/users/danotis/following', + 'followers': 'https://api.unsplash.com/users/danotis/followers' + } + }, + 'current_user_collections': [], + 'urls': { + 'raw': 'https://example.com/photo-1502252430442-aac78f397426', + 'full': 'https://example.com/photo-1502252430442-aac78f397426?ixlib=rb-0.3.5&q=85&fm=jpg&crop=entropy&cs=srgb&s=20f86c2f7bbb019122498a45d8260ee9', + 'regular': 'https://example.com/photo-1502252430442-aac78f397426?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&s=181760db8b7a61fa60a35277d7eb434e', + 'small': 'https://example.com/photo-1502252430442-aac78f397426?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=400&fit=max&s=1e2265597b59e874a1a002b4c3fd961c', + 'thumb': 'https://example.com/photo-1502252430442-aac78f397426?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&s=57c86b0692bea92a282b9ab0dbfdacf4' + }, + 'categories': [], + 'links': { + 'self': 'https://api.unsplash.com/photos/OYFHT4X5isg', + 'html': 'https://unsplash.com/photos/OYFHT4X5isg', + 'download': 'https://unsplash.com/photos/OYFHT4X5isg/download', + 'download_location': 'https://api.unsplash.com/photos/OYFHT4X5isg/download' + }, + 'ratio': 0.6666666666666666 + }); + }); + + it('sets background-color style', function () { + this.render(hbs`{{gh-unsplash-photo photo=photo}}`); + + expect( + find('[data-test-unsplash-photo-container]').attributes.style.value + ).to.have.string('background-color: #A8A99B'); + }); + + it('sets padding-bottom style', function () { + this.render(hbs`{{gh-unsplash-photo photo=photo}}`); + + // don't check full padding-bottom value as it will likely vary across + // browsers + expect( + find('[data-test-unsplash-photo-container]').attributes.style.value + ).to.have.string('padding-bottom: 66.66'); + }); + + it('uses correct image size url', function () { + this.render(hbs`{{gh-unsplash-photo photo=photo}}`); + + expect( + find('[data-test-unsplash-photo-image]').attributes.src.value + ).to.have.string('&w=1200'); + }); + + it('calculates image width/height', function () { + this.render(hbs`{{gh-unsplash-photo photo=photo}}`); + + expect( + find('[data-test-unsplash-photo-image]').attributes.width.value + ).to.equal('1200'); + + expect( + find('[data-test-unsplash-photo-image]').attributes.height.value + ).to.equal('800'); + }); + + it('triggers insert action'); + it('triggers zoom action'); + + describe('zoomed', function () { + it('omits padding-bottom style'); + it('triggers insert action'); + it('triggers zoom action'); + }); +}); diff --git a/tests/integration/components/gh-unsplash-test.js b/tests/integration/components/gh-unsplash-test.js new file mode 100644 index 000000000..8af33cb18 --- /dev/null +++ b/tests/integration/components/gh-unsplash-test.js @@ -0,0 +1,44 @@ +import hbs from 'htmlbars-inline-precompile'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupComponentTest} from 'ember-mocha'; + +describe('Integration: Component: gh-unsplash', function() { + setupComponentTest('gh-unsplash', { + integration: true + }); + + it('renders', function() { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.on('myAction', function(val) { ... }); + // Template block usage: + // this.render(hbs` + // {{#gh-unsplash}} + // template content + // {{/gh-unsplash}} + // `); + + this.render(hbs`{{gh-unsplash}}`); + expect(this.$()).to.have.length(1); + }); + + it('loads new photos by default'); + it('has responsive columns'); + it('can zoom'); + it('can close zoom by clicking on image'); + it('can close zoom by clicking outside image'); + it('triggers insert action'); + it('handles errors'); + + describe('searching', function () { + it('works'); + it('handles no results'); + it('handles error'); + }); + + describe('closing', function () { + it('triggers close action'); + it('can be triggerd by escape key'); + it('cannot be triggered by escape key when zoomed'); + }); +}); diff --git a/tests/unit/components/gh-infinite-scroll-test.js b/tests/unit/components/gh-infinite-scroll-test.js deleted file mode 100644 index 03a6af0f6..000000000 --- a/tests/unit/components/gh-infinite-scroll-test.js +++ /dev/null @@ -1,23 +0,0 @@ -/* jshint expr:true */ -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupComponentTest} from 'ember-mocha'; - -describe('Unit: Component: gh-infinite-scroll', function () { - setupComponentTest('gh-infinite-scroll', { - unit: true - // specify the other units that are required for this test - // needs: ['component:foo', 'helper:bar'] - }); - - it('renders', function () { - // creates the component instance - let component = this.subject(); - - expect(component._state).to.equal('preRender'); - - // renders the component on the page - this.render(); - expect(component._state).to.equal('inDOM'); - }); -}); diff --git a/tests/unit/serializers/setting-test.js b/tests/unit/serializers/setting-test.js index adfbb6598..4a763e543 100644 --- a/tests/unit/serializers/setting-test.js +++ b/tests/unit/serializers/setting-test.js @@ -11,7 +11,8 @@ describe('Unit:Serializer: setting', function() { 'transform:facebook-url-user', 'transform:twitter-url-user', 'transform:navigation-settings', - 'transform:slack-settings' + 'transform:slack-settings', + 'transform:unsplash-settings' ] }); diff --git a/tests/unit/services/ui-test.js b/tests/unit/services/ui-test.js index 512d99e2d..c5b20f958 100644 --- a/tests/unit/services/ui-test.js +++ b/tests/unit/services/ui-test.js @@ -4,7 +4,10 @@ import {setupTest} from 'ember-mocha'; describe('Unit: Service: ui', function() { setupTest('service:ui', { - needs: ['service:dropdown'] + needs: [ + 'service:dropdown', + 'service:mediaQueries' + ] }); // Replace this with your real tests. diff --git a/tests/unit/services/unsplash-test.js b/tests/unit/services/unsplash-test.js new file mode 100644 index 000000000..d150dd69f --- /dev/null +++ b/tests/unit/services/unsplash-test.js @@ -0,0 +1,114 @@ +import Pretender from 'pretender'; +import wait from 'ember-test-helpers/wait'; +import {describe, it} from 'mocha'; +import {errorOverride, errorReset} from '../../helpers/adapter-error'; +import {expect} from 'chai'; +import {run} from '@ember/runloop'; +import {setupTest} from 'ember-mocha'; + +describe('Unit: Service: unsplash', function() { + setupTest('service:unsplash', { + needs: [ + 'service:ajax', + 'service:config', + 'service:ghostPaths', + 'service:settings' + ] + }); + + let server; + + beforeEach(function () { + server = new Pretender(); + }); + + afterEach(function () { + server.shutdown(); + }); + + // Replace this with your real tests. + it('exists', function() { + let service = this.subject(); + expect(service).to.be.ok; + }); + + it('can send a test request'); + it('can load new'); + it('can load next page'); + + describe('search', function () { + it('sends search request'); + it('debounces query updates'); + it('can load next page of search results'); + it('clears photos when starting new search'); + it('loads new when query is cleared'); + }); + + describe('columns', function () { + it('sorts photos into columns based on column height'); + it('can change column count'); + }); + + describe('error handling', function () { + it('handles rate limit exceeded', async function () { + server.get('https://api.unsplash.com/photos', function () { + return [403, {'x-ratelimit-remaining': '0'}, 'Rate Limit Exceeded']; + }); + + let service = this.subject(); + + run(() => { + service.loadNextPage(); + }); + await wait(); + + errorOverride(); + expect(service.get('error')).to.have.string('Unsplash API rate limit reached'); + errorReset(); + }); + + it('handles json errors', async function () { + server.get('https://api.unsplash.com/photos', function () { + return [500, {'Content-Type': 'application/json'}, JSON.stringify({ + errors: ['Unsplash API Error'] + })]; + }); + + let service = this.subject(); + + run(() => { + service.loadNextPage(); + }); + await wait(); + + errorOverride(); + expect(service.get('error')).to.equal('Unsplash API Error'); + errorReset(); + }); + + it('handles text errors', async function () { + server.get('https://api.unsplash.com/photos', function () { + return [500, {'Content-Type': 'text/xml'}, 'Unsplash text error']; + }); + + let service = this.subject(); + + run(() => { + service.loadNextPage(); + }); + await wait(); + + errorOverride(); + expect(service.get('error')).to.equal('Unsplash text error'); + errorReset(); + }); + }); + + describe('isLoading', function () { + it('is false by default'); + it('is true when loading new'); + it('is true when loading next page'); + it('is true when searching'); + it('returns to false when finished'); + }); +}); diff --git a/yarn.lock b/yarn.lock index a67fa203d..00c5f43eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3355,6 +3355,17 @@ ember-factory-for-polyfill@^1.1.0: dependencies: ember-cli-version-checker "^1.2.0" +ember-fetch@3.2.9: + version "3.2.9" + resolved "https://registry.yarnpkg.com/ember-fetch/-/ember-fetch-3.2.9.tgz#91670b320acb5993128555ea70ce4d168cb1db26" + dependencies: + broccoli-funnel "^1.2.0" + broccoli-stew "^1.4.2" + broccoli-templater "^1.0.0" + ember-cli-babel "^6.0.0" + node-fetch "^2.0.0-alpha.3" + whatwg-fetch "^2.0.3" + ember-fetch@^1.4.2: version "1.6.0" resolved "https://registry.yarnpkg.com/ember-fetch/-/ember-fetch-1.6.0.tgz#cf93d3f7049c593f14d11b6f0924b746303b7812"