Unsplash integration

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
This commit is contained in:
Kevin Ansfield 2017-08-02 11:05:59 +04:00 committed by Hannah Wolfe
parent 55b9054448
commit 1e3191b811
61 changed files with 2001 additions and 131 deletions

View File

@ -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"
] ]
}

View File

@ -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);
},

View File

@ -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') {

View File

@ -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);

View File

@ -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: `<small>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)</small>`
};
this._insertImages([image]);
},
toggleFullScreen() {
let isFullScreen = !this.get('_isFullScreen');

View File

@ -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();
}
});

View File

@ -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');
}

View File

@ -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();
}
}
});

View File

@ -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);
}
});

View File

@ -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();
}
}
});

View File

@ -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');
});
}
},

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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')
});

View File

@ -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
});

View File

@ -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 () {

View File

@ -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
]);
}

View File

@ -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');
}
}
});

View File

@ -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]);
});
},

View File

@ -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);

View File

@ -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;

244
app/services/unsplash.js Normal file
View File

@ -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;
}
});

View File

@ -34,6 +34,7 @@
@import "components/publishmenu.css";
@import "components/popovers.css";
@import "components/tour.css";
@import "components/unsplash.css";
/* Layouts: Groups of Components

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);

View File

@ -10,6 +10,7 @@
{{gh-image-uploader
text=text
altText=altText
allowUnsplash=allowUnsplash
update=(action 'update')
uploadStarted=(action 'uploadStarted')
uploadFinished=(action 'uploadFinished')

View File

@ -17,5 +17,18 @@
{{#gh-file-input multiple=false alt=description action=(action "fileSelected") accept=accept}}
<div class="gh-btn gh-btn-outline" data-test-file-input-description><span>{{description}}</span></div>
{{/gh-file-input}}
{{#if (and allowUnsplash unsplash.isActive)}}
<div class="gh-image-uploader-unsplash" {{action (toggle "_showUnsplash" this)}}>
{{inline-svg "unsplash"}}
</div>
{{/if}}
</div>
{{/if}}
{{#if _showUnsplash}}
{{gh-unsplash
insert=(action "addUnsplashPhoto")
close=(action (toggle "_showUnsplash" this))
}}
{{/if}}

View File

@ -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 @@
<div style="display:none">
{{gh-file-input multiple=true action=(action onImageFilesSelected) accept=imageMimeTypes}}
</div>
{{#if _showUnsplash}}
{{gh-unsplash
insert=(action "insertUnsplashPhoto")
close=(action "toggleUnsplash")}}
{{/if}}

View File

@ -10,7 +10,8 @@
<div class="settings-menu-content">
{{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")
}}

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -11,7 +11,8 @@
<div class="settings-menu-content">
{{gh-image-uploader-with-preview
image=tag.featureImage
text="Add tag image"
text="Upload tag image"
allowUnsplash=true
update=(action "setCoverImage")
remove=(action "clearCoverImage")}}
<form>

View File

@ -0,0 +1,22 @@
<a class="gh-unsplash-photo" href="#" onclick={{action "zoom"}} data-test-unsplash-photo={{photo.id}}>
<div class="gh-unsplash-photo-container" style={{style}} data-test-unsplash-photo-container>
<img src={{imageUrl}} alt={{photo.description}} width={{width}} height={{height}} data-test-unsplash-photo-image />
<div class="gh-unsplash-photo-overlay">
<div class="gh-unsplash-photo-header">
<a class="gh-unsplash-button-likes gh-unsplash-button" href="{{photo.links.html}}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">{{inline-svg "unsplash-heart"}}{{photo.likes}}</a>
<a class="gh-unsplash-button-download gh-unsplash-button" href="{{photo.links.download}}/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit&force=true">{{inline-svg "download"}}</a>
</div>
<div class="gh-unsplash-photo-footer">
<div class="gh-unsplash-photo-author">
<a class="gh-unsplash-photo-author-img" href="{{photo.user.links.html}}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">
<img src="{{photo.user.profile_image.medium}}" />
</a>
<a class="gh-unsplash-photo-author-name" href="{{photo.user.links.html}}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">
{{photo.user.name}}
</a>
</div>
<a class="gh-unsplash-button" href="#" onclick={{action "insert"}}>Insert image</a>
</div>
</div>
</div>
</a>

View File

@ -0,0 +1,73 @@
{{#liquid-wormhole class="unsplash"}}
<div class="gh-unsplash {{if ui.hasSideNav "gh-unsplash--with-sidenav"}}">
<div class="gh-unsplash-window">
<div class="gh-unsplash-container">
<a class="gh-unsplash-logo" href="https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">{{inline-svg "unsplash"}}</a>
<div class="gh-unsplash-close" aria-role="button" {{action "close"}}>{{inline-svg "close"}}</div>
<header class="gh-unsplash-header">
<h1 class="gh-unsplash-header-title">Unsplash</h1>
<p class="gh-unsplash-header-desc">Beautiful, free photos.<br>
Gifted by the worlds most generous community of photographers. 🎁</p>
<span class="gh-input-icon">
{{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)
}}
</span>
</header>
{{#if unsplash.photos}}
<section class="gh-unsplash-grid">
{{#each unsplash.columns as |photos|}}
<div class="gh-unsplash-grid-column">
{{#each photos as |photo|}}
{{gh-unsplash-photo photo=photo zoom=(action "zoomPhoto") insert=(action "insert")}}
{{/each}}
</div>
{{/each}}
</section>
{{else if (and unsplash.searchTerm (not unsplash.error unsplash.isLoading))}}
<section class="gh-unsplash-error">
<img class="gh-unsplash-error-404" src="assets/img/unsplash-404.png" alt="No photos found" />
<h4>No photos found for '{{unsplash.searchTerm}}'</h4>
</section>
{{/if}}
{{#if unsplash.error}}
{{!-- TODO: add better error styles? --}}
<section class="gh-unsplash-error">
<img class="gh-unsplash-error-404" src="assets/img/unsplash-404.png" alt="Network error" />
<h4>{{unsplash.error}} (<a href="#" {{action "retry"}}>retry</a>)</h4>
</section>
{{/if}}
{{#if unsplash.isLoading}}
<div class="gh-unsplash-loading">
<div class="gh-loading-spinner"></div>
</div>
{{/if}}
{{gh-scroll-trigger
onEnterViewport=(action "loadNextPage")
triggerOffset=1000}}
</div>
</div>
</div>
{{#if zoomedPhoto}}
<div class="gh-unsplash-zoom" {{action "closeZoom"}}>
{{gh-unsplash-photo
photo=zoomedPhoto
zoomed=true
zoom=(action "closeZoom")
insert=(action "insert")}}
</div>
{{/if}}
{{/liquid-wormhole}}

View File

@ -53,6 +53,30 @@
</article>
{{/link-to}}
</div>
<div class="apps-grid-cell">
{{#link-to "settings.apps.unsplash" id="unsplash-link"}}
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon" style="background-image:url(assets/img/unsplashicon.png);background-size:45px;"></figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Unsplash</h3>
<p class="apps-card-app-desc">Beautiful, free photos</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
{{#if unsplash.isActive}}
<span class="green">Active</span>
{{else}}
<span>Configure</span>
{{/if}}
{{inline-svg "arrow-right"}}
</div>
</div>
</article>
{{/link-to}}
</div>
</div>
<p class="apps-grid-note">(More coming soon!)</p>
</section>

View File

@ -0,0 +1,66 @@
<section class="gh-canvas">
<header class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
{{#link-to "settings.apps.index"}}Apps{{/link-to}}
<span>{{inline-svg "arrow-right"}}</span>
Unsplash
</h2>
<section class="view-actions">
{{gh-task-button task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-save-button=true}}
</section>
</header>
<section class="view-container">
<br>
<section class="app-grid">
<div class="app-cell">
<img class="app-icon" src="assets/img/unsplashicon.png" />
</div>
<div class="app-cell">
<h3>Unsplash</h3>
<p>Beautiful, free photos</p>
</div>
</section>
<div class="gh-setting-header">Unsplash configuration</div>
<div class="gh-setting" id="unsplash-toggle">
<div class="gh-setting-content">
<div class="gh-setting-title">Enable Unsplash</div>
<div class="gh-setting-desc">Enable <a href="https://unsplash.com" target="_blank">Unsplash</a> image integration for your posts</div>
</div>
<div class="gh-setting-action">
{{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="isActive" class="right"}}
<div class="for-checkbox">
<label for="isActive" class="checkbox">
{{one-way-checkbox model.isActive id="isActive" name="isActive" type="checkbox" update=(action "update") data-test-checkbox="unsplash"}}
<span class="input-toggle-component"></span>
</label>
</div>
{{gh-error-message errors=model.errors property="isActive"}}
{{/gh-form-group}}
</div>
</div>
{{#unless config.unsplashAPI.applicationId}}
<form class="app-config-form" id="unsplash-settings" novalidate="novalidate" {{action "save" on="submit"}}>
<div class="gh-setting">
<div class="gh-setting-content">
<div class="gh-setting-title">Unsplash integration</div>
<div class="gh-setting-desc">Access Unsplash images from within the editor.</div>
<div class="gh-setting-content-extended">
{{#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}}
<p>Set up a new Unsplash application <a href="https://unsplash.com/documentation#registering-your-application" target="_blank">here</a>, and grab the Application ID.</p>
{{else}}
{{gh-error-message errors=model.errors property="applicationId"}}
{{/unless}}
{{/gh-form-group}}
</div>
</div>
</div>
</form>
{{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}}
</section>
</section>

View File

@ -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) : {};
}
});

View File

@ -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})
// );
}

View File

@ -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');
}
});

View File

@ -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);

View File

@ -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]
};
});
}

View File

@ -0,0 +1,3 @@
export default [{
unsplashAPI: ''
}];

View File

@ -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}'
}
];

View File

@ -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",

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M20 5.5l-8 8-8-8m-3.5 13h23" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View File

@ -0,0 +1 @@
<svg version="1.1" viewBox="0 0 32 32"><path d="M17.4 29c-.8.8-2 .8-2.8 0l-12.3-12.8c-3.1-3.1-3.1-8.2 0-11.4 3.1-3.1 8.2-3.1 11.3 0l2.4 2.8 2.3-2.8c3.1-3.1 8.2-3.1 11.3 0 3.1 3.1 3.1 8.2 0 11.4l-12.2 12.8z"></path></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 32 32"><path d="M20.8 18.1c0 2.7-2.2 4.8-4.8 4.8s-4.8-2.1-4.8-4.8c0-2.7 2.2-4.8 4.8-4.8 2.7.1 4.8 2.2 4.8 4.8zm11.2-7.4v14.9c0 2.3-1.9 4.3-4.3 4.3h-23.4c-2.4 0-4.3-1.9-4.3-4.3v-15c0-2.3 1.9-4.3 4.3-4.3h3.7l.8-2.3c.4-1.1 1.7-2 2.9-2h8.6c1.2 0 2.5.9 2.9 2l.8 2.4h3.7c2.4 0 4.3 1.9 4.3 4.3zm-8.6 7.5c0-4.1-3.3-7.5-7.5-7.5-4.1 0-7.5 3.4-7.5 7.5s3.3 7.5 7.5 7.5c4.2-.1 7.5-3.4 7.5-7.5z"></path></svg>

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -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');
});
});
});

View File

@ -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);
});
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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'
]
});

View File

@ -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.

View File

@ -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');
});
});

View File

@ -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"