✨ 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:
parent
55b9054448
commit
1e3191b811
|
@ -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"
|
||||
] ]
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
});
|
||||
|
|
|
@ -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
|
||||
});
|
|
@ -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 () {
|
||||
|
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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]);
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
|
@ -34,6 +34,7 @@
|
|||
@import "components/publishmenu.css";
|
||||
@import "components/popovers.css";
|
||||
@import "components/tour.css";
|
||||
@import "components/unsplash.css";
|
||||
|
||||
|
||||
/* Layouts: Groups of Components
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
{{gh-image-uploader
|
||||
text=text
|
||||
altText=altText
|
||||
allowUnsplash=allowUnsplash
|
||||
update=(action 'update')
|
||||
uploadStarted=(action 'uploadStarted')
|
||||
uploadFinished=(action 'uploadFinished')
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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")
|
||||
}}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{{yield}}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 world’s 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}}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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) : {};
|
||||
}
|
||||
});
|
|
@ -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})
|
||||
// );
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export default [{
|
||||
unsplashAPI: ''
|
||||
}];
|
|
@ -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}'
|
||||
}
|
||||
];
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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'
|
||||
]
|
||||
});
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
11
yarn.lock
11
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"
|
||||
|
|
Loading…
Reference in New Issue