0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Remove split Ghost-Admin code

This commit is contained in:
Kevin Ansfield 2016-05-18 10:29:59 +01:00
parent 72c6cf7f93
commit 1b85d67e0e
514 changed files with 0 additions and 33625 deletions

View file

@ -1,9 +0,0 @@
{
/**
Ember CLI sends analytics information by default. The data is completely
anonymous, but there are times when you might want to disable this behavior.
Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": true
}

View file

@ -1,20 +0,0 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
# dependencies
/node_modules
/bower_components
# misc
/connect.lock
/coverage/*
/libpeerconnection.log
npm-debug.log
testem.log
# built by grunt
public/assets/img/contributors/
app/templates/-contributors.hbs

View file

@ -1,14 +0,0 @@
{
"preset": "ember-suave",
"validateIndentation": 4,
"disallowSpacesInFunction": null,
"disallowSpacesInNamedFunctionExpression": {
"beforeOpeningRoundBrace": true
},
"disallowSpacesInFunctionDeclaration": {
"beforeOpeningRoundBrace": true
},
"disallowSpacesInsideObjectBrackets": "all",
"requireCommentsToIncludeAccess": null,
"requireSpacesInsideObjectBrackets": null
}

View file

@ -1,36 +0,0 @@
{
"predef": [
"server",
"document",
"window",
"-Promise",
"-Notification",
"validator",
"moment"
],
"browser": true,
"boss": true,
"curly": true,
"debug": false,
"devel": true,
"eqeqeq": true,
"evil": true,
"forin": false,
"immed": false,
"laxbreak": false,
"newcap": true,
"noarg": true,
"noempty": false,
"nonew": false,
"nomen": false,
"onevar": false,
"plusplus": false,
"regexp": false,
"undef": true,
"sub": true,
"strict": false,
"white": false,
"eqnull": true,
"esnext": true,
"unused": true
}

View file

@ -1,3 +0,0 @@
{
"ignore_dirs": ["tmp", "dist"]
}

View file

@ -1,30 +0,0 @@
# Ghost Admin Client
Ember.js application used as a client-side admin for the [Ghost](http://ghost.org) blogging platform. This readme is a work in progress guide aimed at explaining the specific nuances of the Ghost Ember app to contributors whose main focus is on this side of things.
## CSS
We use pure CSS, which is pre-processed for backwards compatibility by [Myth](http://myth.io). We do not follow any strict CSS framework, however our general style is pretty similar to BEM.
Styles are primarily broken up into 4 main categories:
* **Patterns** - are base level visual styles for HTML elements (eg. Buttons)
* **Components** - are groups of patterns used to create a UI component (eg. Modals)
* **Layouts** - are groups of components used to create application screens (eg. Settings)
All of these separate files are subsequently imported and compiled in `app.css`.
## Front End Standards
* 4 spaces for HTML & CSS indentation. Never tabs.
* Double quotes only, never single quotes.
* Use tags and elements appropriate for an HTML5 doctype (including self-closing tags)
* Adhere to the [Recess CSS](http://markdotto.com/2011/11/29/css-property-order/) property order.
* Always a space after a property's colon (.e.g, display: block; and not display:block;).
* End all lines with a semi-colon.
* For multiple, comma-separated selectors, place each selector on its own line.
* Use js- prefixed classes for JavaScript hooks into the DOM, and never use these in CSS as per [Slightly Obtrusive JavaSript](http://ozmm.org/posts/slightly_obtrusive_javascript.html)
* Avoid over-nesting CSS. Never nest more than 3 levels deep.
* Use comments to explain "why" not "what" (Good: This requires a z-index in order to appear above mobile navigation. Bad: This is a thing which is always on top!)

View file

@ -1,15 +0,0 @@
# Dependencies
markdown: kramdown
highlighter: highlighter
# Permalinks
permalink: pretty
# Server
source: docs
destination: _gh_pages
host: 0.0.0.0
port: 9001
baseurl:
url: http://localhost:9001
encoding: UTF-8

View file

@ -1,9 +0,0 @@
import EmbeddedRelationAdapter from 'ghost/adapters/embedded-relation-adapter';
export default EmbeddedRelationAdapter.extend({
shouldBackgroundReloadRecord() {
return false;
}
});

View file

@ -1,54 +0,0 @@
import Ember from 'ember';
import RESTAdapter from 'ember-data/adapters/rest';
import ghostPaths from 'ghost/utils/ghost-paths';
import DataAdapterMixin from 'ember-simple-auth/mixins/data-adapter-mixin';
const {
inject: {service}
} = Ember;
export default RESTAdapter.extend(DataAdapterMixin, {
authorizer: 'authorizer:oauth2',
host: window.location.origin,
namespace: ghostPaths().apiRoot.slice(1),
session: service(),
shouldBackgroundReloadRecord() {
return false;
},
query(store, type, query) {
let id;
if (query.id) {
id = query.id;
delete query.id;
}
return this.ajax(this.buildURL(type.modelName, id), 'GET', {data: query});
},
buildURL() {
// Ensure trailing slashes
let url = this._super(...arguments);
if (url.slice(-1) !== '/') {
url += '/';
}
return url;
},
handleResponse(status) {
if (status === 401) {
if (this.get('session.isAuthenticated')) {
this.get('session').invalidate();
return; // prevent error from bubbling because invalidate is async
}
}
return this._super(...arguments);
}
});

View file

@ -1,132 +0,0 @@
import Ember from 'ember';
import BaseAdapter from 'ghost/adapters/base';
const {get, isNone} = Ember;
// EmbeddedRelationAdapter will augment the query object in calls made to
// DS.Store#findRecord, findAll, query, and queryRecord with the correct "includes"
// (?include=relatedType) by introspecting on the provided subclass of the DS.Model.
// In cases where there is no query object (DS.Model#save, or simple finds) the URL
// that is built will be augmented with ?include=... where appropriate.
//
// Example:
// If a model has an embedded hasMany relation, the related type will be included:
// roles: DS.hasMany('role', { embedded: 'always' }) => ?include=roles
export default BaseAdapter.extend({
find(store, type, id, snapshot) {
return this.ajax(this.buildIncludeURL(store, type.modelName, id, snapshot, 'find'), 'GET');
},
findRecord(store, type, id, snapshot) {
return this.ajax(this.buildIncludeURL(store, type.modelName, id, snapshot, 'findRecord'), 'GET');
},
findAll(store, type, sinceToken) {
let query, url;
if (sinceToken) {
query = {since: sinceToken};
}
url = this.buildIncludeURL(store, type.modelName, null, null, 'findAll');
return this.ajax(url, 'GET', {data: query});
},
query(store, type, query) {
return this._super(store, type, this.buildQuery(store, type.modelName, query));
},
queryRecord(store, type, query) {
return this._super(store, type, this.buildQuery(store, type.modelName, query));
},
createRecord(store, type, snapshot) {
return this.saveRecord(store, type, snapshot, {method: 'POST'}, 'createRecord');
},
updateRecord(store, type, snapshot) {
let options = {
method: 'PUT',
id: get(snapshot, 'id')
};
return this.saveRecord(store, type, snapshot, options, 'updateRecord');
},
saveRecord(store, type, snapshot, options, requestType) {
let _options = options || {};
let url = this.buildIncludeURL(store, type.modelName, _options.id, snapshot, requestType);
let payload = this.preparePayload(store, type, snapshot);
return this.ajax(url, _options.method, payload);
},
preparePayload(store, type, snapshot) {
let serializer = store.serializerFor(type.modelName);
let payload = {};
serializer.serializeIntoHash(payload, type, snapshot);
return {data: payload};
},
buildIncludeURL(store, modelName, id, snapshot, requestType, query) {
let includes = this.getEmbeddedRelations(store, modelName);
let url = this.buildURL(modelName, id, snapshot, requestType, query);
if (includes.length) {
url += `?include=${includes.join(',')}`;
}
return url;
},
buildQuery(store, modelName, options) {
let deDupe = {};
let toInclude = this.getEmbeddedRelations(store, modelName);
let query = options || {};
if (toInclude.length) {
// If this is a find by id, build a query object and attach the includes
if (typeof options === 'string' || typeof options === 'number') {
query = {};
query.id = options;
query.include = toInclude.join(',');
} else if (typeof options === 'object' || isNone(options)) {
// If this is a find all (no existing query object) build one and attach
// the includes.
// If this is a find with an existing query object then merge the includes
// into the existing object. Existing properties and includes are preserved.
query = query || {};
toInclude = toInclude.concat(query.include ? query.include.split(',') : []);
toInclude.forEach((include) => {
deDupe[include] = true;
});
query.include = Object.keys(deDupe).join(',');
}
}
return query;
},
getEmbeddedRelations(store, modelName) {
let model = store.modelFor(modelName);
let ret = [];
// Iterate through the model's relationships and build a list
// of those that need to be pulled in via "include" from the API
model.eachRelationship(function (name, meta) {
if (meta.kind === 'hasMany' &&
Object.prototype.hasOwnProperty.call(meta.options, 'embedded') &&
meta.options.embedded === 'always') {
ret.push(name);
}
});
return ret;
}
});

View file

@ -1,19 +0,0 @@
import ApplicationAdapter from 'ghost/adapters/application';
export default ApplicationAdapter.extend({
updateRecord(store, type, record) {
let data = {};
let serializer = store.serializerFor(type.modelName);
// remove the fake id that we added onto the model.
delete record.id;
// use the SettingSerializer to transform the model back into
// an array of settings objects like the API expects
serializer.serializeIntoHash(data, type, record);
// use the ApplicationAdapter's buildURL method but do not
// pass in an id.
return this.ajax(this.buildURL(type.modelName), 'PUT', {data});
}
});

View file

@ -1,4 +0,0 @@
import ApplicationAdapter from 'ghost/adapters/application';
import SlugUrl from 'ghost/mixins/slug-url';
export default ApplicationAdapter.extend(SlugUrl);

View file

@ -1,23 +0,0 @@
import ApplicationAdapter from 'ghost/adapters/application';
import SlugUrl from 'ghost/mixins/slug-url';
export default ApplicationAdapter.extend(SlugUrl, {
find(store, type, id) {
return this.findQuery(store, type, {id, status: 'all'});
},
// TODO: This is needed because the API currently expects you to know the
// status of the record before retrieving by ID. Quick fix is to always
// include status=all in the query
findRecord(store, type, id, snapshot) {
let url = this.buildIncludeURL(store, type.modelName, id, snapshot, 'findRecord');
url += '&status=all';
return this.ajax(url, 'GET');
},
findAll(store, type, id) {
return this.query(store, type, {id, status: 'all'});
}
});

View file

@ -1,20 +0,0 @@
import Ember from 'ember';
import Resolver from './resolver';
import loadInitializers from 'ember-load-initializers';
import 'ghost/utils/link-component';
import 'ghost/utils/text-field';
import config from './config/environment';
const {Application} = Ember;
Ember.MODEL_FACTORY_INJECTIONS = true;
let App = Application.extend({
Resolver,
modulePrefix: config.modulePrefix,
podModulePrefix: config.podModulePrefix
});
loadInitializers(App, config.modulePrefix);
export default App;

View file

@ -1,28 +0,0 @@
import Ember from 'ember';
import Authenticator from 'ember-simple-auth/authenticators/oauth2-password-grant';
const {
computed,
inject: {service}
} = Ember;
export default Authenticator.extend({
config: service(),
ghostPaths: service(),
serverTokenEndpoint: computed('ghostPaths.apiRoot', function () {
return `${this.get('ghostPaths.apiRoot')}/authentication/token`;
}),
serverTokenRevocationEndpoint: computed('ghostPaths.apiRoot', function () {
return `${this.get('ghostPaths.apiRoot')}/authentication/revoke`;
}),
makeRequest(url, data) {
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
data.client_id = this.get('config.clientId');
data.client_secret = this.get('config.clientSecret');
/* jscs:enable requireCamelCaseOrUpperCaseIdentifiers */
return this._super(url, data);
}
});

View file

@ -1,3 +0,0 @@
import Oauth2Bearer from 'ember-simple-auth/authorizers/oauth2-bearer';
export default Oauth2Bearer;

View file

@ -1,22 +0,0 @@
import Ember from 'ember';
const {Component, run} = Ember;
export default Component.extend({
tagName: 'li',
classNameBindings: ['active'],
active: false,
linkClasses: null,
click() {
this.$('a').blur();
},
actions: {
setActive(value) {
run.schedule('afterRender', this, function () {
this.set('active', value);
});
}
}
});

View file

@ -1,40 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
export default Component.extend({
tagName: 'article',
classNames: ['gh-alert'],
classNameBindings: ['typeClass'],
notifications: service(),
typeClass: computed('message.type', function () {
let type = this.get('message.type');
let classes = '';
let typeMapping;
typeMapping = {
success: 'green',
error: 'red',
warn: 'yellow',
info: 'blue'
};
if (typeMapping[type] !== undefined) {
classes += `gh-alert-${typeMapping[type]}`;
}
return classes;
}),
actions: {
closeNotification() {
this.get('notifications').closeNotification(this.get('message'));
}
}
});

View file

@ -1,22 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service},
observer
} = Ember;
const {alias} = computed;
export default Component.extend({
tagName: 'aside',
classNames: 'gh-alerts',
notifications: service(),
messages: alias('notifications.alerts'),
messageCountObserver: observer('messages.[]', function () {
this.sendAction('notify', this.get('messages').length);
})
});

View file

@ -1,15 +0,0 @@
import Ember from 'ember';
const {Component, observer} = Ember;
export default Component.extend({
classNames: ['gh-app'],
showSettingsMenu: false,
toggleSettingsMenuBodyClass: observer('showSettingsMenu', function () {
let showSettingsMenu = this.get('showSettingsMenu');
Ember.$('body').toggleClass('settings-menu-expanded', showSettingsMenu);
})
});

View file

@ -1,12 +0,0 @@
import Ember from 'ember';
const {
Component,
inject: {service}
} = Ember;
export default Component.extend({
tagName: '',
config: service()
});

View file

@ -1,47 +0,0 @@
/* global CodeMirror */
import Ember from 'ember';
const {Component} = Ember;
export default Component.extend({
classNameBindings: ['isFocused:focused'],
value: '', // make sure a value exists
isFocused: false,
// options for the editor
lineNumbers: true,
indentUnit: 4,
mode: 'htmlmixed',
theme: 'xq-light',
_editor: null, // reference to CodeMirror editor
didInsertElement() {
this._super(...arguments);
let options = this.getProperties('lineNumbers', 'indentUnit', 'mode', 'theme');
let editor = new CodeMirror(this.get('element'), options);
editor.getDoc().setValue(this.get('value'));
// events
editor.on('focus', Ember.run.bind(this, 'set', 'isFocused', true));
editor.on('blur', Ember.run.bind(this, 'set', 'isFocused', false));
editor.on('change', () => {
Ember.run(this, function () {
this.set('value', editor.getDoc().getValue());
});
});
this._editor = editor;
},
willDestroyElement() {
this._super(...arguments);
let editor = this._editor.getWrapperElement();
editor.parentNode.removeChild(editor);
this._editor = null;
}
});

View file

@ -1,30 +0,0 @@
/*
Implements a div for covering the page content
when in a menu context that, for example,
should be closed when the user clicks elsewhere.
Example:
```
{{gh-content-cover onClick="closeMenus" onMouseEnter="closeAutoNav"}}
```
**/
import Ember from 'ember';
const {Component} = Ember;
export default Component.extend({
classNames: ['content-cover'],
onClick: null,
onMouseEnter: null,
click() {
this.sendAction('onClick');
},
mouseEnter() {
this.sendAction('onMouseEnter');
}
});

View file

@ -1,22 +0,0 @@
import Ember from 'ember';
const {Component} = Ember;
export default Component.extend({
classNames: ['content-preview-content'],
content: null,
didReceiveAttrs(options) {
this._super(...arguments);
// adjust when didReceiveAttrs gets both newAttrs and oldAttrs
if (options.newAttrs.content && this.get('content') !== options.newAttrs.content.value) {
let el = this.$();
if (el) {
el.closest('.content-preview').scrollTop(0);
}
}
}
});

View file

@ -1,15 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
export default Component.extend({
tagName: 'section',
classNames: ['gh-view', 'content-view-container'],
mediaQueries: service(),
previewIsHidden: computed.reads('mediaQueries.maxWidth900')
});

View file

@ -1,33 +0,0 @@
import Ember from 'ember';
import TextInputMixin from 'ghost/mixins/text-input';
import boundOneWay from 'ghost/utils/bound-one-way';
import {formatDate} from 'ghost/utils/date-formatting';
import {invokeAction} from 'ember-invoke-action';
const {Component} = Ember;
export default Component.extend(TextInputMixin, {
tagName: 'span',
classNames: 'input-icon icon-calendar',
datetime: boundOneWay('value'),
inputClass: null,
inputId: null,
inputName: null,
didReceiveAttrs() {
let datetime = this.get('datetime') || moment();
if (!this.get('update')) {
throw new Error(`You must provide an \`update\` action to \`{{${this.templateName}}}\`.`);
}
this.set('datetime', formatDate(datetime));
},
focusOut() {
let datetime = this.get('datetime');
invokeAction(this, 'update', datetime);
}
});

View file

@ -1,24 +0,0 @@
import Ember from 'ember';
import DropdownMixin from 'ghost/mixins/dropdown-mixin';
const {
Component,
inject: {service}
} = Ember;
export default Component.extend(DropdownMixin, {
tagName: 'button',
attributeBindings: 'role',
role: 'button',
// matches with the dropdown this button toggles
dropdownName: null,
dropdown: service(),
// Notify dropdown service this dropdown should be toggled
click(event) {
this._super(event);
this.get('dropdown').toggleDropdown(this.get('dropdownName'), this);
}
});

View file

@ -1,99 +0,0 @@
import Ember from 'ember';
import DropdownMixin from 'ghost/mixins/dropdown-mixin';
const {
Component,
computed,
inject: {service}
} = Ember;
export default Component.extend(DropdownMixin, {
classNames: 'dropdown',
classNameBindings: ['fadeIn:fade-in-scale:fade-out', 'isOpen:open:closed'],
name: null,
closeOnClick: false,
// Helps track the user re-opening the menu while it's fading out.
closing: false,
// Helps track whether the dropdown is open or closes, or in a transition to either
isOpen: false,
// Managed the toggle between the fade-in and fade-out classes
fadeIn: computed('isOpen', 'closing', function () {
return this.get('isOpen') && !this.get('closing');
}),
dropdown: service(),
open() {
this.set('isOpen', true);
this.set('closing', false);
this.set('button.isOpen', true);
},
close() {
this.set('closing', true);
if (this.get('button')) {
this.set('button.isOpen', false);
}
this.$().on('animationend webkitAnimationEnd oanimationend MSAnimationEnd', (event) => {
if (event.originalEvent.animationName === 'fade-out') {
Ember.run(this, function () {
if (this.get('closing')) {
this.set('isOpen', false);
this.set('closing', false);
}
});
}
});
},
// Called by the dropdown service when any dropdown button is clicked.
toggle(options) {
let isClosing = this.get('closing');
let isOpen = this.get('isOpen');
let name = this.get('name');
let targetDropdownName = options.target;
let button = this.get('button');
if (name === targetDropdownName && (!isOpen || isClosing)) {
if (!button) {
button = options.button;
this.set('button', button);
}
this.open();
} else if (isOpen) {
this.close();
}
},
click(event) {
this._super(event);
if (this.get('closeOnClick')) {
return this.close();
}
},
didInsertElement() {
let dropdownService = this.get('dropdown');
this._super(...arguments);
dropdownService.on('close', this, this.close);
dropdownService.on('toggle', this, this.toggle);
},
willDestroyElement() {
let dropdownService = this.get('dropdown');
this._super(...arguments);
dropdownService.off('close', this, this.close);
dropdownService.off('toggle', this, this.toggle);
}
});

View file

@ -1,52 +0,0 @@
import Ember from 'ember';
import EditorAPI from 'ghost/mixins/ed-editor-api';
import EditorShortcuts from 'ghost/mixins/ed-editor-shortcuts';
import EditorScroll from 'ghost/mixins/ed-editor-scroll';
import {invokeAction} from 'ember-invoke-action';
const {TextArea, run} = Ember;
export default TextArea.extend(EditorAPI, EditorShortcuts, EditorScroll, {
focus: false,
/**
* Tell the controller about focusIn events, will trigger an autosave on a new document
*/
focusIn() {
this.sendAction('onFocusIn');
},
/**
* Sets the focus of the textarea if needed
*/
setFocus() {
if (this.get('focus')) {
this.$().val(this.$().val()).focus();
}
},
/**
* Sets up properties at render time
*/
didInsertElement() {
this._super(...arguments);
this.setFocus();
invokeAction(this, 'setEditor', this);
run.scheduleOnce('afterRender', this, this.afterRenderEvent);
},
afterRenderEvent() {
if (this.get('focus') && this.get('focusCursorAtEnd')) {
this.setSelection('end');
}
},
actions: {
toggleCopyHTMLModal(generatedHTML) {
invokeAction(this, 'toggleCopyHTMLModal', generatedHTML);
}
}
});

View file

@ -1,110 +0,0 @@
import Ember from 'ember';
import {formatMarkdown} from 'ghost/helpers/gh-format-markdown';
const {
Component,
run,
uuid
} = Ember;
export default Component.extend({
_scrollWrapper: null,
previewHTML: '',
init() {
this._super(...arguments);
this.set('imageUploadComponents', Ember.A([]));
this.buildPreviewHTML();
},
didInsertElement() {
this._super(...arguments);
this._scrollWrapper = this.$().closest('.entry-preview-content');
this.adjustScrollPosition(this.get('scrollPosition'));
},
didReceiveAttrs(attrs) {
this._super(...arguments);
if (!attrs.oldAttrs) {
return;
}
if (attrs.newAttrs.scrollPosition && attrs.newAttrs.scrollPosition.value !== attrs.oldAttrs.scrollPosition.value) {
this.adjustScrollPosition(attrs.newAttrs.scrollPosition.value);
}
if (attrs.newAttrs.markdown.value !== attrs.oldAttrs.markdown.value) {
run.throttle(this, this.buildPreviewHTML, 30, false);
}
},
adjustScrollPosition(scrollPosition) {
let scrollWrapper = this._scrollWrapper;
if (scrollWrapper) {
scrollWrapper.scrollTop(scrollPosition);
}
},
buildPreviewHTML() {
let markdown = this.get('markdown');
let html = formatMarkdown([markdown]).string;
let template = document.createElement('template');
template.innerHTML = html;
let fragment = template.content;
let dropzones = fragment.querySelectorAll('.js-drop-zone');
let components = this.get('imageUploadComponents');
if (dropzones.length !== components.length) {
components = Ember.A([]);
this.set('imageUploadComponents', components);
}
[...dropzones].forEach((oldEl, i) => {
let el = oldEl.cloneNode(true);
let component = components[i];
let uploadTarget = el.querySelector('.js-upload-target');
let id = uuid();
let destinationElementId = `image-uploader-${id}`;
let src;
if (uploadTarget) {
src = uploadTarget.getAttribute('src');
}
if (component) {
component.set('destinationElementId', destinationElementId);
component.set('src', src);
} else {
let imageUpload = Ember.Object.create({
destinationElementId,
id,
src,
index: i
});
this.get('imageUploadComponents').pushObject(imageUpload);
}
el.id = destinationElementId;
el.innerHTML = '';
el.classList.remove('image-uploader');
oldEl.parentNode.replaceChild(el, oldEl);
});
this.set('previewHTML', fragment);
},
actions: {
updateImageSrc(index, url) {
this.attrs.updateImageSrc(index, url);
},
updateHeight() {
this.attrs.updateHeight(this.$().height());
}
}
});

View file

@ -1,50 +0,0 @@
import Ember from 'ember';
const {Component, computed} = Ember;
export default Component.extend({
tagName: 'section',
classNames: ['splitbtn', 'js-publish-splitbutton'],
classNameBindings: ['isNew:unsaved'],
isNew: null,
isPublished: null,
willPublish: null,
postOrPage: null,
submitting: false,
// Tracks whether we're going to change the state of the post on save
isDangerous: computed('isPublished', 'willPublish', function () {
return this.get('isPublished') !== this.get('willPublish');
}),
publishText: computed('isPublished', 'postOrPage', function () {
return this.get('isPublished') ? `Update ${this.get('postOrPage')}` : 'Publish Now';
}),
draftText: computed('isPublished', function () {
return this.get('isPublished') ? 'Unpublish' : 'Save Draft';
}),
deleteText: computed('postOrPage', function () {
return `Delete ${this.get('postOrPage')}`;
}),
saveText: computed('willPublish', 'publishText', 'draftText', function () {
return this.get('willPublish') ? this.get('publishText') : this.get('draftText');
}),
actions: {
save() {
this.sendAction('save');
},
setSaveType(saveType) {
this.sendAction('setSaveType', saveType);
},
delete() {
this.sendAction('delete');
}
}
});

View file

@ -1,123 +0,0 @@
import Ember from 'ember';
import ShortcutsMixin from 'ghost/mixins/shortcuts';
import imageManager from 'ghost/utils/ed-image-manager';
import editorShortcuts from 'ghost/utils/editor-shortcuts';
import {invokeAction} from 'ember-invoke-action';
const {Component, computed, run} = Ember;
const {equal} = computed;
export default Component.extend(ShortcutsMixin, {
tagName: 'section',
classNames: ['view-container', 'view-editor'],
activeTab: 'markdown',
editor: null,
editorDisabled: undefined,
editorScrollInfo: null, // updated when gh-ed-editor component scrolls
height: null, // updated when markdown is rendered
shouldFocusEditor: false,
showCopyHTMLModal: false,
copyHTMLModalContent: null,
shortcuts: editorShortcuts,
markdownActive: equal('activeTab', 'markdown'),
previewActive: equal('activeTab', 'preview'),
// HTML Preview listens to scrollPosition and updates its scrollTop value
// This property receives scrollInfo from the textEditor, and height from the preview pane, and will update the
// scrollPosition value such that when either scrolling or typing-at-the-end of the text editor the preview pane
// stays in sync
scrollPosition: computed('editorScrollInfo', 'height', function () {
let scrollInfo = this.get('editorScrollInfo');
let {$previewContent, $previewViewPort} = this;
if (!scrollInfo || !$previewContent || !$previewViewPort) {
return 0;
}
let previewHeight = $previewContent.height() - $previewViewPort.height();
let previewPosition, ratio;
ratio = previewHeight / scrollInfo.diff;
previewPosition = scrollInfo.top * ratio;
return previewPosition;
}),
didInsertElement() {
this._super(...arguments);
this.registerShortcuts();
run.scheduleOnce('afterRender', this, this._cacheElements);
},
willDestroyElement() {
invokeAction(this, 'onTeardown');
this.removeShortcuts();
},
_cacheElements() {
// cache these elements for use in other methods
this.$previewViewPort = this.$('.js-entry-preview-content');
this.$previewContent = this.$('.js-rendered-markdown');
},
actions: {
selectTab(tab) {
this.set('activeTab', tab);
},
updateScrollInfo(scrollInfo) {
this.set('editorScrollInfo', scrollInfo);
},
updateHeight(height) {
this.set('height', height);
},
// set from a `sendAction` on the gh-ed-editor component,
// so that we get a reference for handling uploads.
setEditor(editor) {
this.set('editor', editor);
},
disableEditor() {
this.set('editorDisabled', true);
},
enableEditor() {
this.set('editorDisabled', undefined);
},
// The actual functionality is implemented in utils/ed-editor-shortcuts
editorShortcut(options) {
if (this.editor.$().is(':focus')) {
this.editor.shortcut(options.type);
}
},
// Match the uploaded file to a line in the editor, and update that line with a path reference
// ensuring that everything ends up in the correct place and format.
handleImgUpload(imageIndex, newSrc) {
let editor = this.get('editor');
let editorValue = editor.getValue();
let replacement = imageManager.getSrcRange(editorValue, imageIndex);
let cursorPosition;
if (replacement) {
cursorPosition = replacement.start + newSrc.length + 1;
if (replacement.needsParens) {
newSrc = `(${newSrc})`;
}
editor.replaceSelection(newSrc, replacement.start, replacement.end, cursorPosition);
}
},
toggleCopyHTMLModal(generatedHTML) {
this.set('copyHTMLModalContent', generatedHTML);
this.toggleProperty('showCopyHTMLModal');
}
}
});

View file

@ -1,36 +0,0 @@
import Ember from 'ember';
const {Component, computed, isEmpty} = Ember;
/**
* Renders one random error message when passed a DS.Errors object
* and a property name. The message will be one of the ones associated with
* that specific property. If there are no errors associated with the property,
* nothing will be rendered.
* @param {DS.Errors} errors The DS.Errors object
* @param {string} property The property name
*/
export default Component.extend({
tagName: 'p',
classNames: ['response'],
errors: null,
property: '',
isVisible: computed.notEmpty('errors'),
message: computed('errors.[]', 'property', function () {
let property = this.get('property');
let errors = this.get('errors');
let messages = [];
let index;
if (!isEmpty(errors) && errors.get(property)) {
errors.get(property).forEach((error) => {
messages.push(error);
});
index = Math.floor(Math.random() * messages.length);
return messages[index].message;
}
})
});

View file

@ -1,45 +0,0 @@
import Ember from 'ember';
const {
computed,
inject: {service},
Component
} = Ember;
const FeatureFlagComponent = Component.extend({
tagName: 'label',
classNames: 'checkbox',
attributeBindings: ['for'],
_flagValue: null,
feature: service(),
init() {
this._super(...arguments);
this.set('_flagValue', this.get(`feature.${this.get('flag')}`));
},
value: computed('_flagValue', {
get() {
return this.get('_flagValue');
},
set(key, value) {
return this.set(`feature.${this.get('flag')}`, value);
}
}),
for: computed('flag', function () {
return `labs-${this.get('flag')}`;
}),
name: computed('flag', function () {
return `labs[${this.get('flag')}]`;
})
});
FeatureFlagComponent.reopenClass({
positionalParams: ['flag']
});
export default FeatureFlagComponent;

View file

@ -1,37 +0,0 @@
import Ember from 'ember';
const {Component} = Ember;
export default Component.extend({
_file: null,
uploadButtonText: 'Text',
uploadButtonDisabled: true,
onUpload: null,
onAdd: null,
shouldResetForm: true,
change(event) {
this.set('uploadButtonDisabled', false);
this.sendAction('onAdd');
this._file = event.target.files[0];
},
actions: {
upload() {
if (!this.get('uploadButtonDisabled') && this._file) {
this.sendAction('onUpload', this._file);
}
// Prevent double post by disabling the button.
this.set('uploadButtonDisabled', true);
// Reset form
if (this.get('shouldResetForm')) {
this.$().closest('form')[0].reset();
}
}
}
});

View file

@ -1,161 +0,0 @@
import Ember from 'ember';
import { invoke, invokeAction } from 'ember-invoke-action';
import {
RequestEntityTooLargeError,
UnsupportedMediaTypeError
} from 'ghost/services/ajax';
const {
Component,
computed,
inject: {service},
isBlank,
run
} = Ember;
export default Component.extend({
tagName: 'section',
classNames: ['gh-image-uploader'],
classNameBindings: ['dragClass'],
labelText: 'Select or drag-and-drop a file',
url: null,
paramName: 'file',
file: null,
response: null,
dragClass: null,
failureMessage: null,
uploadPercentage: 0,
ajax: service(),
formData: computed('file', function () {
let paramName = this.get('paramName');
let file = this.get('file');
let formData = new FormData();
formData.append(paramName, file);
return formData;
}),
progressStyle: computed('uploadPercentage', function () {
let percentage = this.get('uploadPercentage');
let width = '';
if (percentage > 0) {
width = `${percentage}%`;
} else {
width = '0';
}
return Ember.String.htmlSafe(`width: ${width}`);
}),
dragOver(event) {
if (!event.dataTransfer) {
return;
}
// this is needed to work around inconsistencies with dropping files
// from Chrome's downloads bar
let eA = event.dataTransfer.effectAllowed;
event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy';
event.stopPropagation();
event.preventDefault();
this.set('dragClass', '--drag-over');
},
dragLeave(event) {
event.preventDefault();
this.set('dragClass', null);
},
drop(event) {
event.preventDefault();
this.set('dragClass', null);
if (event.dataTransfer.files) {
invoke(this, 'fileSelected', event.dataTransfer.files);
}
},
generateRequest() {
let ajax = this.get('ajax');
let formData = this.get('formData');
let url = this.get('url');
invokeAction(this, 'uploadStarted');
ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text',
xhr: () => {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
this._uploadProgress(event);
}, false);
return xhr;
}
}).then((response) => {
this._uploadSuccess(JSON.parse(response));
}).catch((error) => {
this._uploadFailed(error);
}).finally(() => {
invokeAction(this, 'uploadFinished');
});
},
_uploadProgress(event) {
if (event.lengthComputable) {
run(() => {
let percentage = Math.round((event.loaded / event.total) * 100);
this.set('uploadPercentage', percentage);
});
}
},
_uploadSuccess(response) {
invokeAction(this, 'uploadSuccess', response);
invoke(this, 'reset');
},
_uploadFailed(error) {
let message;
if (error instanceof UnsupportedMediaTypeError) {
message = 'The file type you uploaded is not supported.';
} else if (error instanceof RequestEntityTooLargeError) {
message = 'The file you uploaded was larger than the maximum file size your server allows.';
} else if (error.errors && !isBlank(error.errors[0].message)) {
message = error.errors[0].message;
} else {
message = 'Something went wrong :(';
}
this.set('failureMessage', message);
invokeAction(this, 'uploadFailed', error);
},
actions: {
fileSelected(fileList) {
this.set('file', fileList[0]);
run.schedule('actions', this, function () {
this.generateRequest();
});
},
reset() {
this.set('file', null);
this.set('uploadPercentage', 0);
this.set('failureMessage', null);
}
}
});

View file

@ -1,5 +0,0 @@
import ValidationStatusContainer from 'ghost/components/gh-validation-status-container';
export default ValidationStatusContainer.extend({
classNames: 'form-group'
});

View file

@ -1,85 +0,0 @@
import Ember from 'ember';
import LiquidTether from 'liquid-tether/components/liquid-tether';
import {invokeAction} from 'ember-invoke-action';
const {
RSVP: {Promise},
inject: {service},
isBlank,
on,
run
} = Ember;
const emberA = Ember.A;
const FullScreenModalComponent = LiquidTether.extend({
to: 'fullscreen-modal',
target: 'document.body',
targetModifier: 'visible',
targetAttachment: 'top center',
attachment: 'top center',
tetherClass: 'fullscreen-modal',
overlayClass: 'fullscreen-modal-background',
modalPath: 'unknown',
dropdown: service(),
init() {
this._super(...arguments);
this.modalPath = `modals/${this.get('modal')}`;
},
setTetherClass: on('init', function () {
let tetherClass = this.get('tetherClass');
let modifiers = (this.get('modifier') || '').split(' ');
let tetherClasses = emberA([tetherClass]);
modifiers.forEach((modifier) => {
if (!isBlank(modifier)) {
let className = `${tetherClass}-${modifier}`;
tetherClasses.push(className);
}
});
this.set('tetherClass', tetherClasses.join(' '));
}),
closeDropdowns: on('didInsertElement', function () {
run.schedule('afterRender', this, function () {
this.get('dropdown').closeDropdowns();
});
}),
actions: {
close() {
// Because we return the promise from invokeAction, we have
// to check if "close" exists first
if (this.get('close')) {
return invokeAction(this, 'close');
}
return new Promise((resolve) => {
resolve();
});
},
confirm() {
if (this.get('confirm')) {
return invokeAction(this, 'confirm');
}
return new Promise((resolve) => {
resolve();
});
},
clickOverlay() {
this.send('close');
}
}
});
FullScreenModalComponent.reopenClass({
positionalParams: ['modal']
});
export default FullScreenModalComponent;

View file

@ -1,39 +0,0 @@
import Ember from 'ember';
const {
Component
} = Ember;
export default Component.extend({
actions: {
update() {
if (typeof this.attrs.update === 'function') {
this.attrs.update(...arguments);
}
},
onInput() {
if (typeof this.attrs.onInput === 'function') {
this.attrs.onInput(...arguments);
}
},
uploadStarted() {
if (typeof this.attrs.uploadStarted === 'function') {
this.attrs.uploadStarted(...arguments);
}
},
uploadFinished() {
if (typeof this.attrs.uploadFinished === 'function') {
this.attrs.uploadFinished(...arguments);
}
},
formChanged() {
if (typeof this.attrs.formChanged === 'function') {
this.attrs.formChanged(...arguments);
}
}
}
});

View file

@ -1,226 +0,0 @@
import Ember from 'ember';
import ghostPaths from 'ghost/utils/ghost-paths';
import {RequestEntityTooLargeError, UnsupportedMediaTypeError} from 'ghost/services/ajax';
const {
Component,
computed,
inject: {service},
isBlank,
run
} = Ember;
export default Component.extend({
tagName: 'section',
classNames: ['gh-image-uploader'],
classNameBindings: ['dragClass'],
image: null,
text: 'Upload an image',
saveButton: true,
dragClass: null,
failureMessage: null,
file: null,
formType: 'upload',
url: null,
uploadPercentage: 0,
ajax: service(),
config: service(),
// TODO: this wouldn't be necessary if the server could accept direct
// file uploads
formData: computed('file', function () {
let file = this.get('file');
let formData = new FormData();
formData.append('uploadimage', file);
return formData;
}),
progressStyle: computed('uploadPercentage', function () {
let percentage = this.get('uploadPercentage');
let width = '';
if (percentage > 0) {
width = `${percentage}%`;
} else {
width = '0';
}
return Ember.String.htmlSafe(`width: ${width}`);
}),
canShowUploadForm: computed('config.fileStorage', function () {
return this.get('config.fileStorage') !== false;
}),
showUploadForm: computed('formType', function () {
let canShowUploadForm = this.get('canShowUploadForm');
let formType = this.get('formType');
return formType === 'upload' && canShowUploadForm;
}),
didReceiveAttrs() {
let image = this.get('image');
this.set('url', image);
},
dragOver(event) {
let showUploadForm = this.get('showUploadForm');
if (!event.dataTransfer) {
return;
}
// this is needed to work around inconsistencies with dropping files
// from Chrome's downloads bar
let eA = event.dataTransfer.effectAllowed;
event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy';
event.stopPropagation();
event.preventDefault();
if (showUploadForm) {
this.set('dragClass', '--drag-over');
}
},
dragLeave(event) {
let showUploadForm = this.get('showUploadForm');
event.preventDefault();
if (showUploadForm) {
this.set('dragClass', null);
}
},
drop(event) {
let showUploadForm = this.get('showUploadForm');
event.preventDefault();
this.set('dragClass', null);
if (showUploadForm) {
if (event.dataTransfer.files) {
this.send('fileSelected', event.dataTransfer.files);
}
}
},
uploadStarted() {
if (typeof this.attrs.uploadStarted === 'function') {
this.attrs.uploadStarted();
}
},
uploadProgress(event) {
if (event.lengthComputable) {
run(() => {
let percentage = Math.round((event.loaded / event.total) * 100);
this.set('uploadPercentage', percentage);
});
}
},
uploadFinished() {
if (typeof this.attrs.uploadFinished === 'function') {
this.attrs.uploadFinished();
}
},
uploadSuccess(response) {
this.set('url', response);
this.send('saveUrl');
this.send('reset');
},
uploadFailed(error) {
let message;
if (error instanceof UnsupportedMediaTypeError) {
message = 'The image type you uploaded is not supported. Please use .PNG, .JPG, .GIF, .SVG.';
} else if (error instanceof RequestEntityTooLargeError) {
message = 'The image you uploaded was larger than the maximum file size your server allows.';
} else if (error.errors && !isBlank(error.errors[0].message)) {
message = error.errors[0].message;
} else {
message = 'Something went wrong :(';
}
this.set('failureMessage', message);
},
generateRequest() {
let ajax = this.get('ajax');
let formData = this.get('formData');
let url = `${ghostPaths().apiRoot}/uploads/`;
this.uploadStarted();
ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text',
xhr: () => {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
this.uploadProgress(event);
}, false);
return xhr;
}
}).then((response) => {
let url = JSON.parse(response);
this.uploadSuccess(url);
}).catch((error) => {
this.uploadFailed(error);
}).finally(() => {
this.uploadFinished();
});
},
actions: {
fileSelected(fileList) {
this.set('file', fileList[0]);
run.schedule('actions', this, function () {
this.generateRequest();
});
},
onInput(url) {
this.set('url', url);
if (typeof this.attrs.onInput === 'function') {
this.attrs.onInput(url);
}
},
reset() {
this.set('file', null);
this.set('uploadPercentage', 0);
},
switchForm(formType) {
this.set('formType', formType);
if (typeof this.attrs.formChanged === 'function') {
run.scheduleOnce('afterRender', this, function () {
this.attrs.formChanged(formType);
});
}
},
saveUrl() {
let url = this.get('url');
this.attrs.update(url);
}
}
});

View file

@ -1,12 +0,0 @@
import Ember from 'ember';
import InfiniteScrollMixin from 'ghost/mixins/infinite-scroll';
const {Component} = Ember;
export default Component.extend(InfiniteScrollMixin, {
actions: {
checkScroll() {
this._checkScroll();
}
}
});

View file

@ -1,8 +0,0 @@
import Ember from 'ember';
import TextInputMixin from 'ghost/mixins/text-input';
const {TextField} = Ember;
export default TextField.extend(TextInputMixin, {
classNames: 'gh-input'
});

View file

@ -1,27 +0,0 @@
import Ember from 'ember';
import LightTable from 'ember-light-table/components/light-table';
const {$, run} = Ember;
export default LightTable.extend({
// HACK: infinite pagination was not triggering when scrolling very fast
// as the throttle triggers before scrolling into the buffer area but
// the scroll finishes before the throttle timeout. Adding a debounce that
// does the same thing means that we are guaranteed a final trigger when
// scrolling stops
//
// An issue has been opened upstream, this can be removed if it gets fixed
// https://github.com/offirgolan/ember-light-table/issues/15
_setupScrollEvents() {
$(this.get('touchMoveContainer')).on('touchmove.light-table', run.bind(this, this._scrollHandler, '_touchmoveTimer'));
$(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollTimer'));
$(this.get('scrollContainer')).on('scroll.light-table', run.bind(this, this._scrollHandler, '_scrollDebounce'));
},
_scrollHandler(timer) {
this.set(timer, run.debounce(this, this._onScroll, 100));
this.set(timer, run.throttle(this, this._onScroll, 100));
}
});

View file

@ -1,13 +0,0 @@
import Ember from 'ember';
const {Component} = Ember;
export default Component.extend({
tagName: 'main',
classNames: ['gh-main'],
ariaRole: 'main',
mouseEnter() {
this.sendAction('onMouseEnter');
}
});

View file

@ -1,42 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
/*
This cute little component has two jobs.
On desktop, it toggles autoNav behaviour. It tracks
that state via the maximise property, and uses the
state to render the appropriate icon.
On mobile, it renders a closing icon, and clicking it
closes the mobile menu
*/
export default Component.extend({
classNames: ['gh-menu-toggle'],
mediaQueries: service(),
isMobile: computed.reads('mediaQueries.isMobile'),
maximise: false,
iconClass: computed('maximise', 'isMobile', function () {
if (this.get('maximise') && !this.get('isMobile')) {
return 'icon-maximise';
} else {
return 'icon-minimise';
}
}),
click() {
if (this.get('isMobile')) {
this.sendAction('mobileAction');
} else {
this.toggleProperty('maximise');
this.sendAction('desktopAction');
}
}
});

View file

@ -1,48 +0,0 @@
import Ember from 'ember';
const {
Component,
inject: {service},
computed
} = Ember;
export default Component.extend({
tagName: 'nav',
classNames: ['gh-nav'],
classNameBindings: ['open'],
open: false,
navMenuIcon: computed('ghostPaths.subdir', function () {
let url = `${this.get('ghostPaths.subdir')}/ghost/img/ghosticon.jpg`;
return Ember.String.htmlSafe(`background-image: url(${url})`);
}),
config: service(),
session: service(),
ghostPaths: service(),
feature: service(),
mouseEnter() {
this.sendAction('onMouseEnter');
},
actions: {
toggleAutoNav() {
this.sendAction('toggleMaximise');
},
showMarkdownHelp() {
this.sendAction('showMarkdownHelp');
},
closeMobileMenu() {
this.sendAction('closeMobileMenu');
},
openAutoNav() {
this.sendAction('openAutoNav');
}
}
});

View file

@ -1,39 +0,0 @@
import Ember from 'ember';
const {Component, run} = Ember;
export default Component.extend({
tagName: 'section',
classNames: 'gh-view',
didInsertElement() {
let navContainer = this.$('.js-gh-blognav');
let navElements = '.gh-blognav-item:not(.gh-blognav-item:last-child)';
// needed because jqueryui sortable doesn't trigger babel's autoscoping
let _this = this;
this._super(...arguments);
navContainer.sortable({
handle: '.gh-blognav-grab',
items: navElements,
start(event, ui) {
run(() => {
ui.item.data('start-index', ui.item.index());
});
},
update(event, ui) {
run(() => {
_this.sendAction('moveItem', ui.item.data('start-index'), ui.item.index());
});
}
});
},
willDestroyElement() {
this._super(...arguments);
this.$('.ui-sortable').sortable('destroy');
}
});

View file

@ -1,145 +0,0 @@
import Ember from 'ember';
import {invokeAction} from 'ember-invoke-action';
const {TextField, computed, run} = Ember;
let joinUrlParts = function (url, path) {
if (path[0] !== '/' && url.slice(-1) !== '/') {
path = `/${path}`;
} else if (path[0] === '/' && url.slice(-1) === '/') {
path = path.slice(1);
}
return url + path;
};
let isRelative = function (url) {
// "protocol://", "//example.com", "scheme:", "#anchor", & invalid paths
// should all be treated as absolute
return !url.match(/\s/) && !validator.isURL(url) && !url.match(/^(\/\/|#|[a-zA-Z0-9\-]+:)/);
};
export default TextField.extend({
classNames: 'gh-input',
isBaseUrl: computed('baseUrl', 'value', function () {
return this.get('baseUrl') === this.get('value');
}),
didReceiveAttrs() {
this._super(...arguments);
let baseUrl = this.get('baseUrl');
let url = this.get('url');
// if we have a relative url, create the absolute url to be displayed in the input
if (isRelative(url)) {
url = joinUrlParts(baseUrl, url);
}
this.set('value', url);
},
focusIn(event) {
this.set('hasFocus', true);
if (this.get('isBaseUrl')) {
// position the cursor at the end of the input
run.next(function (el) {
let {length} = el.value;
el.setSelectionRange(length, length);
}, event.target);
}
},
keyDown(event) {
// delete the "placeholder" value all at once
if (this.get('isBaseUrl') && (event.keyCode === 8 || event.keyCode === 46)) {
this.set('value', '');
event.preventDefault();
}
// CMD-S
if (event.keyCode === 83 && event.metaKey) {
this.notifyUrlChanged();
}
},
keyPress(event) {
invokeAction(this, 'clearErrors');
// enter key
if (event.keyCode === 13) {
this.notifyUrlChanged();
}
return true;
},
focusOut() {
this.set('hasFocus', false);
this.notifyUrlChanged();
},
notifyUrlChanged() {
let url = this.get('value').trim();
let urlParts = document.createElement('a');
let baseUrl = this.get('baseUrl');
let baseUrlParts = document.createElement('a');
// ensure value property is trimmed
this.set('value', url);
// leverage the browser's native URI parsing
urlParts.href = url;
baseUrlParts.href = baseUrl;
// if we have an email address, add the mailto:
if (validator.isEmail(url)) {
url = `mailto:${url}`;
this.set('value', url);
}
// if we have a relative url, create the absolute url to be displayed in the input
if (isRelative(url)) {
url = joinUrlParts(baseUrl, url);
this.set('value', url);
}
// get our baseUrl relativity checks in order
let isOnSameHost = urlParts.host === baseUrlParts.host;
let isAnchorLink = url.match(/^#/);
let isRelativeToBasePath = urlParts.pathname.indexOf(baseUrlParts.pathname) === 0;
// if our pathname is only missing a trailing / mark it as relative
if (`${urlParts.pathname}/` === baseUrlParts.pathname) {
isRelativeToBasePath = true;
}
// if relative to baseUrl, remove the base url before sending to action
if (!isAnchorLink && isOnSameHost && isRelativeToBasePath) {
url = url.replace(/^[a-zA-Z0-9\-]+:/, '');
url = url.replace(/^\/\//, '');
url = url.replace(baseUrlParts.host, '');
url = url.replace(baseUrlParts.pathname, '');
// handle case where url path is same as baseUrl path but missing trailing slash
if (urlParts.pathname.slice(-1) !== '/') {
url = url.replace(baseUrlParts.pathname.slice(0, -1), '');
}
if (!url.match(/^\//)) {
url = `/${url}`;
}
if (!url.match(/\/$/) && !url.match(/[\.#\?]/)) {
url = `${url}/`;
}
}
this.sendAction('change', url);
}
});

View file

@ -1,55 +0,0 @@
import Ember from 'ember';
import ValidationState from 'ghost/mixins/validation-state';
import SortableItem from 'ember-sortable/mixins/sortable-item';
const {Component, computed, run} = Ember;
const {alias, readOnly} = computed;
export default Component.extend(ValidationState, SortableItem, {
classNames: 'gh-blognav-item',
classNameBindings: ['errorClass', 'navItem.isNew::gh-blognav-item--sortable'],
new: false,
handle: '.gh-blognav-grab',
model: alias('navItem'),
errors: readOnly('navItem.errors'),
errorClass: computed('hasError', function () {
if (this.get('hasError')) {
return 'gh-blognav-item--error';
}
}),
keyPress(event) {
// enter key
if (event.keyCode === 13 && this.get('navItem.isNew')) {
event.preventDefault();
run.scheduleOnce('actions', this, function () {
this.send('addItem');
});
}
},
actions: {
addItem() {
this.sendAction('addItem');
},
deleteItem(item) {
this.sendAction('deleteItem', item);
},
updateUrl(value) {
this.sendAction('updateUrl', value, this.get('navItem'));
},
clearLabelErrors() {
this.get('navItem.errors').remove('label');
},
clearUrlErrors() {
this.get('navItem.errors').remove('url');
}
}
});

View file

@ -1,56 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
export default Component.extend({
tagName: 'article',
classNames: ['gh-notification', 'gh-notification-passive'],
classNameBindings: ['typeClass'],
message: null,
notifications: service(),
typeClass: computed('message.type', function () {
let type = this.get('message.type');
let classes = '';
let typeMapping;
typeMapping = {
success: 'green',
error: 'red',
warn: 'yellow'
};
if (typeMapping[type] !== undefined) {
classes += `gh-notification-${typeMapping[type]}`;
}
return classes;
}),
didInsertElement() {
this._super(...arguments);
this.$().on('animationend webkitAnimationEnd oanimationend MSAnimationEnd', (event) => {
if (event.originalEvent.animationName === 'fade-out') {
this.get('notifications').closeNotification(this.get('message'));
}
});
},
willDestroyElement() {
this._super(...arguments);
this.$().off('animationend webkitAnimationEnd oanimationend MSAnimationEnd');
},
actions: {
closeNotification() {
this.get('notifications').closeNotification(this.get('message'));
}
}
});

View file

@ -1,17 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
const {alias} = computed;
export default Component.extend({
tagName: 'aside',
classNames: 'gh-notifications',
notifications: service(),
messages: alias('notifications.notifications')
});

View file

@ -1,26 +0,0 @@
import Ember from 'ember';
import DropdownButton from 'ghost/components/gh-dropdown-button';
const {
inject: {service}
} = Ember;
function K() {
return this;
}
export default DropdownButton.extend({
dropdown: service(),
click: K,
mouseEnter() {
this._super(...arguments);
this.get('dropdown').toggleDropdown(this.get('popoverName'), this);
},
mouseLeave() {
this._super(...arguments);
this.get('dropdown').toggleDropdown(this.get('popoverName'), this);
}
});

View file

@ -1,11 +0,0 @@
import Ember from 'ember';
import GhostDropdown from 'ghost/components/gh-dropdown';
const {
inject: {service}
} = Ember;
export default GhostDropdown.extend({
classNames: 'ghost-popover',
dropdown: service()
});

View file

@ -1,85 +0,0 @@
import Ember from 'ember';
import ActiveLinkWrapper from 'ghost/mixins/active-link-wrapper';
import {invokeAction} from 'ember-invoke-action';
const {
$,
Component,
computed,
inject: {service}
} = Ember;
const {alias, equal} = computed;
export default Component.extend(ActiveLinkWrapper, {
tagName: 'li',
classNameBindings: ['isFeatured:featured', 'isPage:page'],
post: null,
previewIsHidden: false,
isFeatured: alias('post.featured'),
isPage: alias('post.page'),
isPublished: equal('post.status', 'published'),
ghostPaths: service(),
authorName: computed('post.author.name', 'post.author.email', function () {
return this.get('post.author.name') || this.get('post.author.email');
}),
authorAvatar: computed('post.author.image', function () {
return this.get('post.author.image') || `${this.get('ghostPaths.subdir')}/ghost/img/user-image.png`;
}),
authorAvatarBackground: computed('authorAvatar', function () {
return Ember.String.htmlSafe(`background-image: url(${this.get('authorAvatar')})`);
}),
click() {
this.sendAction('onClick', this.get('post'));
},
doubleClick() {
this.sendAction('onDoubleClick', this.get('post'));
},
didInsertElement() {
this._super(...arguments);
this.addObserver('active', this, this.scrollIntoView);
},
willDestroyElement() {
this._super(...arguments);
this.removeObserver('active', this, this.scrollIntoView);
if (this.get('post.isDeleted') && this.get('onDelete')) {
invokeAction(this, 'onDelete');
}
},
scrollIntoView() {
if (!this.get('active')) {
return;
}
let element = this.$();
let offset = element.offset().top;
let elementHeight = element.height();
let container = $('.js-content-scrollbox');
let containerHeight = container.height();
let currentScroll = container.scrollTop();
let isBelowTop, isAboveBottom, isOnScreen;
isAboveBottom = offset < containerHeight;
isBelowTop = offset > elementHeight;
isOnScreen = isBelowTop && isAboveBottom;
if (!isOnScreen) {
// Scroll so that element is centered in container
// 40 is the amount of padding on the container
container.clearQueue().animate({
scrollTop: currentScroll + offset - 40 - containerHeight / 2
});
}
}
});

View file

@ -1,142 +0,0 @@
import Ember from 'ember';
import AjaxService from 'ember-ajax/services/ajax';
import {NotFoundError} from 'ghost/services/ajax';
const {
Component,
computed,
inject: {service},
isBlank,
run
} = Ember;
const {notEmpty} = computed;
/**
* A component to manage a user profile image. By default it just handles picture uploads,
* but if passed a bound 'email' property it will render the user's gravatar image
*
* Example: {{gh-profile-image email=controllerEmailProperty setImage="controllerActionName" debounce=500}}
*
* @param {int} size The size of the image to render
* @param {String} email Reference to a bound email object if gravatar image behavior is desired.
* @param {String|action} setImage The string name of the action on the controller to be called when an image is added.
* @param {int} debounce Period to wait after changes to email before attempting to load gravatar
* @property {Boolean} hasUploadedImage Whether or not the user has uploaded an image (whether or not to show the default image/gravatar image)
* @property {String} defaultImage String containing the background-image css property of the default user profile image
* @property {String} imageBackground String containing the background-image css property with the gravatar url
*/
export default Component.extend({
email: '',
size: 90,
debounce: 300,
validEmail: '',
hasUploadedImage: false,
fileStorage: true,
ajax: AjaxService.create(),
config: service(),
ghostPaths: service(),
displayGravatar: notEmpty('validEmail'),
init() {
this._super(...arguments);
// Fire this immediately in case we're initialized with a valid email
this.trySetValidEmail();
},
defaultImage: computed('ghostPaths', function () {
let url = `${this.get('ghostPaths.subdir')}/ghost/img/user-image.png`;
return Ember.String.htmlSafe(`background-image: url(${url})`);
}),
trySetValidEmail() {
if (!this.get('isDestroyed')) {
let email = this.get('email');
this.set('validEmail', validator.isEmail(email) ? email : '');
}
},
didReceiveAttrs(attrs) {
this._super(...arguments);
let timeout = parseInt(attrs.newAttrs.throttle || this.get('debounce'));
run.debounce(this, 'trySetValidEmail', timeout);
},
imageBackground: computed('validEmail', 'size', function () {
let email = this.get('validEmail');
let size = this.get('size');
let style = '';
if (!isBlank(email)) {
let gravatarUrl = `//www.gravatar.com/avatar/${window.md5(email)}?s=${size}&d=404`;
this.get('ajax').request(gravatarUrl)
.catch((error) => {
let defaultImageUrl = `url("${this.get('ghostPaths.subdir')}/ghost/img/user-image.png")`;
if (error instanceof NotFoundError) {
this.$('.placeholder-img')[0].style.backgroundImage = Ember.String.htmlSafe(defaultImageUrl);
} else {
this.$('.placeholder-img')[0].style.backgroundImage = 'url()';
}
});
style = `background-image: url(${gravatarUrl})`;
}
return Ember.String.htmlSafe(style);
}),
didInsertElement() {
let size = this.get('size');
let uploadElement = this.$('.js-file-input');
this._super(...arguments);
// while theoretically the 'add' and 'processalways' functions could be
// added as properties of the hash passed to fileupload(), for some reason
// they needed to be placed in an on() call for the add method to work correctly
uploadElement.fileupload({
url: this.get('ghostPaths.url').api('uploads'),
dropZone: this.$('.js-img-dropzone'),
previewMaxHeight: size,
previewMaxWidth: size,
previewCrop: true,
maxNumberOfFiles: 1,
autoUpload: false
})
.on('fileuploadadd', run.bind(this, this.queueFile))
.on('fileuploadprocessalways', run.bind(this, this.triggerPreview));
},
willDestroyElement() {
let $input = this.$('.js-file-input');
this._super(...arguments);
if ($input.length && $input.data()['blueimp-fileupload']) {
$input.fileupload('destroy');
}
},
queueFile(e, data) {
let fileName = data.files[0].name;
if ((/\.(gif|jpe?g|png|svg?z)$/i).test(fileName)) {
this.sendAction('setImage', data);
}
},
triggerPreview(e, data) {
let file = data.files[data.index];
if (file.preview) {
this.set('hasUploadedImage', true);
// necessary jQuery code because file.preview is a raw DOM object
// potential todo: rename 'gravatar-img' class in the CSS to be something
// that both the gravatar and the image preview can use that's not so confusing
this.$('.js-img-preview').empty().append(this.$(file.preview).addClass('gravatar-img'));
}
}
});

View file

@ -1,206 +0,0 @@
/* global key */
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
import Ember from 'ember';
const {
Component,
RSVP,
computed,
run,
inject: {service},
isBlank,
isEmpty
} = Ember;
export function computedGroup(category) {
return computed('content', 'currentSearch', function () {
if (!this.get('currentSearch') || !this.get('content')) {
return [];
}
return this.get('content').filter((item) => {
let search = new RegExp(this.get('currentSearch'), 'ig');
return (item.category === category) &&
item.title.match(search);
});
});
}
export default Component.extend({
selection: null,
content: [],
isLoading: false,
contentExpiry: 10 * 1000,
contentExpiresAt: false,
currentSearch: '',
posts: computedGroup('Posts'),
pages: computedGroup('Pages'),
users: computedGroup('Users'),
tags: computedGroup('Tags'),
_store: service('store'),
_routing: service('-routing'),
ajax: service(),
refreshContent() {
let promises = [];
let now = new Date();
let contentExpiry = this.get('contentExpiry');
let contentExpiresAt = this.get('contentExpiresAt');
if (this.get('isLoading') || contentExpiresAt > now) {
return RSVP.resolve();
}
this.set('isLoading', true);
this.set('content', []);
promises.pushObject(this._loadPosts());
promises.pushObject(this._loadUsers());
promises.pushObject(this._loadTags());
return RSVP.all(promises).then(() => { }).finally(() => {
this.set('isLoading', false);
this.set('contentExpiresAt', new Date(now.getTime() + contentExpiry));
});
},
groupedContent: computed('posts', 'pages', 'users', 'tags', function () {
let groups = [];
if (!isEmpty(this.get('posts'))) {
groups.pushObject({groupName: 'Posts', options: this.get('posts')});
}
if (!isEmpty(this.get('pages'))) {
groups.pushObject({groupName: 'Pages', options: this.get('pages')});
}
if (!isEmpty(this.get('users'))) {
groups.pushObject({groupName: 'Users', options: this.get('users')});
}
if (!isEmpty(this.get('tags'))) {
groups.pushObject({groupName: 'Tags', options: this.get('tags')});
}
return groups;
}),
_loadPosts() {
let store = this.get('_store');
let postsUrl = `${store.adapterFor('post').urlForQuery({}, 'post')}/`;
let postsQuery = {fields: 'id,title,page', limit: 'all', status: 'all', staticPages: 'all'};
let content = this.get('content');
return this.get('ajax').request(postsUrl, {data: postsQuery}).then((posts) => {
content.pushObjects(posts.posts.map((post) => {
return {
id: `post.${post.id}`,
title: post.title,
category: post.page ? 'Pages' : 'Posts'
};
}));
});
},
_loadUsers() {
let store = this.get('_store');
let usersUrl = `${store.adapterFor('user').urlForQuery({}, 'user')}/`;
let usersQuery = {fields: 'name,slug', limit: 'all'};
let content = this.get('content');
return this.get('ajax').request(usersUrl, {data: usersQuery}).then((users) => {
content.pushObjects(users.users.map((user) => {
return {
id: `user.${user.slug}`,
title: user.name,
category: 'Users'
};
}));
});
},
_loadTags() {
let store = this.get('_store');
let tagsUrl = `${store.adapterFor('tag').urlForQuery({}, 'tag')}/`;
let tagsQuery = {fields: 'name,slug', limit: 'all'};
let content = this.get('content');
return this.get('ajax').request(tagsUrl, {data: tagsQuery}).then((tags) => {
content.pushObjects(tags.tags.map((tag) => {
return {
id: `tag.${tag.slug}`,
title: tag.name,
category: 'Tags'
};
}));
});
},
_performSearch(term, resolve, reject) {
if (isBlank(term)) {
return resolve([]);
}
this.refreshContent().then(() => {
this.set('currentSearch', term);
return resolve(this.get('groupedContent'));
}).catch(reject);
},
_setKeymasterScope() {
key.setScope('search-input');
},
_resetKeymasterScope() {
key.setScope('default');
},
willDestroy() {
this._super(...arguments);
this._resetKeymasterScope();
},
actions: {
openSelected(selected) {
if (!selected) {
return;
}
if (selected.category === 'Posts' || selected.category === 'Pages') {
let id = selected.id.replace('post.', '');
this.get('_routing.router').transitionTo('editor.edit', id);
}
if (selected.category === 'Users') {
let id = selected.id.replace('user.', '');
this.get('_routing.router').transitionTo('team.user', id);
}
if (selected.category === 'Tags') {
let id = selected.id.replace('tag.', '');
this.get('_routing.router').transitionTo('settings.tags.tag', id);
}
},
onFocus() {
this._setKeymasterScope();
},
onBlur() {
this._resetKeymasterScope();
},
search(term) {
return new RSVP.Promise((resolve, reject) => {
run.debounce(this, this._performSearch, term, resolve, reject, 200);
});
}
}
});

View file

@ -1,43 +0,0 @@
import Ember from 'ember';
import {invokeAction} from 'ember-invoke-action';
const {run, isBlank, Component} = Ember;
export default Component.extend({
open() {
this.get('select.actions').open();
},
close() {
this.get('select.actions').close();
},
actions: {
captureMouseDown(e) {
e.stopPropagation();
},
search(term) {
if (isBlank(term) === this.get('select.isOpen')) {
run.scheduleOnce('afterRender', this, isBlank(term) ? this.close : this.open);
}
invokeAction(this, 'select.actions.search', term);
},
focusInput() {
this.$('input')[0].focus();
},
resetInput() {
this.$('input').val('');
},
handleKeydown(e) {
let select = this.get('select');
if (!select.isOpen) {
e.stopPropagation();
}
}
}
});

View file

@ -1,42 +0,0 @@
import Ember from 'ember';
const {Component, computed} = Ember;
const {reads} = computed;
function K() {
return this;
}
export default Component.extend({
content: null,
prompt: null,
optionValuePath: 'id',
optionLabelPath: 'title',
selection: null,
action: K, // action to fire on change
// shadow the passed-in `selection` to avoid
// leaking changes to it via a 2-way binding
_selection: reads('selection'),
actions: {
change() {
// jscs:disable requireArrayDestructuring
let selectEl = this.$('select')[0];
// jscs:enable requireArrayDestructuring
let {selectedIndex} = selectEl;
// decrement index by 1 if we have a prompt
let hasPrompt = !!this.get('prompt');
let contentIndex = hasPrompt ? selectedIndex - 1 : selectedIndex;
let selection = this.get('content').objectAt(contentIndex);
// set the local, shadowed selection to avoid leaking
// changes to `selection` out via 2-way binding
this.set('_selection', selection);
this.sendAction('action', selection);
}
}
});

View file

@ -1,124 +0,0 @@
/* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */
import Ember from 'ember';
import EmberSelectizeComponent from 'ember-cli-selectize/components/ember-selectize';
const {computed, isArray, isBlank, get, run} = Ember;
const emberA = Ember.A;
export default EmberSelectizeComponent.extend({
selectizeOptions: computed(function () {
let options = this._super(...arguments);
options.onChange = run.bind(this, '_onChange');
return options;
}),
/**
* Event callback that is triggered when user creates a tag
* - modified to pass the caret position to the action
*/
_create(input, callback) {
let caret = this._selectize.caretPos;
// Delete user entered text
this._selectize.setTextboxValue('');
// Send create action
// allow the observers and computed properties to run first
run.schedule('actions', this, function () {
this.sendAction('create-item', input, caret);
});
// We cancel the creation here, so it's up to you to include the created element
// in the content and selection property
callback(null);
},
_addSelection(obj) {
let _valuePath = this.get('_valuePath');
let val = get(obj, _valuePath);
let caret = this._selectize.caretPos;
// caret position is always 1 more than the desired index as this method
// is called after selectize has inserted the item and the caret has moved
// to the right
caret = caret - 1;
this.get('selection').insertAt(caret, obj);
run.schedule('actions', this, function () {
this.sendAction('add-item', obj);
this.sendAction('add-value', val);
});
},
_onChange(args) {
let selection = Ember.get(this, 'selection');
let valuePath = Ember.get(this, '_valuePath');
let reorderedSelection = emberA([]);
if (!args || !selection || !isArray(selection) || args.length !== get(selection, 'length')) {
return;
}
// exit if we're not dealing with the same objects as the selection
let objectsHaveChanged = selection.any(function (obj) {
return args.indexOf(get(obj, valuePath)) === -1;
});
if (objectsHaveChanged) {
return;
}
// exit if the order is still the same
let orderIsSame = selection.every(function (obj, idx) {
return get(obj, valuePath) === args[idx];
});
if (orderIsSame) {
return;
}
// we have a re-order, update the selection
args.forEach((value) => {
let obj = selection.find(function (item) {
return `${get(item, valuePath)}` === value;
});
if (obj) {
reorderedSelection.addObject(obj);
}
});
this.set('selection', reorderedSelection);
},
_preventOpeningWhenBlank() {
let openOnFocus = this.get('openOnFocus');
if (!openOnFocus) {
run.schedule('afterRender', this, function () {
let selectize = this._selectize;
if (selectize) {
selectize.on('dropdown_open', function () {
if (isBlank(selectize.$control_input.val())) {
selectize.close();
}
});
selectize.on('type', function (filter) {
if (isBlank(filter)) {
selectize.close();
}
});
}
});
}
},
didInsertElement() {
this._super(...arguments);
this._preventOpeningWhenBlank();
}
});

View file

@ -1,36 +0,0 @@
/*jshint scripturl:true*/
import Ember from 'ember';
const {$, Component} = Ember;
export default Component.extend({
tagName: 'a',
anchor: '',
classNames: ['sr-only', 'sr-only-focusable'],
// Add attributes to component for href
// href should be set to retain anchor properties
// such as pointer cursor and text underline
attributeBindings: ['href'],
// Used so that upon clicking on the link
// anchor behaviors or ignored
href: Ember.String.htmlSafe('javascript:;'),
click() {
let anchor = this.get('anchor');
let $el = Ember.$(anchor);
if ($el) {
// Scrolls to the top of main content or whatever
// is passed to the anchor attribute
Ember.$('body').scrollTop($el.offset().top);
// This sets focus on the content which was skipped to
// upon losing focus, the tabindex should be removed
// so that normal keyboard navigation picks up from focused
// element
Ember.$($el).attr('tabindex', -1).on('blur focusout', function () {
$(this).removeAttr('tabindex');
}).focus();
}
}
});

View file

@ -1,59 +0,0 @@
import Ember from 'ember';
const {Component, computed, observer, run} = Ember;
const {equal} = computed;
export default Component.extend({
tagName: 'button',
buttonText: '',
submitting: false,
showSpinner: false,
showSpinnerTimeout: null,
autoWidth: true,
// Disable Button when isLoading equals true
attributeBindings: ['disabled', 'type', 'tabindex'],
// Must be set on the controller
disabled: equal('showSpinner', true),
click() {
if (this.get('action')) {
this.sendAction('action');
return false;
}
return true;
},
toggleSpinner: observer('submitting', function () {
let submitting = this.get('submitting');
let timeout = this.get('showSpinnerTimeout');
if (submitting) {
this.set('showSpinner', true);
this.set('showSpinnerTimeout', run.later(this, function () {
if (!this.get('submitting')) {
this.set('showSpinner', false);
}
this.set('showSpinnerTimeout', null);
}, 1000));
} else if (!submitting && timeout === null) {
this.set('showSpinner', false);
}
}),
setSize: observer('showSpinner', function () {
if (this.get('showSpinner') && this.get('autoWidth')) {
this.$().width(this.$().width());
this.$().height(this.$().height());
} else {
this.$().width('');
this.$().height('');
}
}),
willDestroy() {
this._super(...arguments);
run.cancel(this.get('showSpinnerTimeout'));
}
});

View file

@ -1,17 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['subscribers-table'],
table: null,
actions: {
onScrolledToBottom() {
let loadNextPage = this.get('loadNextPage');
if (!this.get('isLoading')) {
loadNextPage();
}
}
}
});

View file

@ -1,34 +0,0 @@
import Ember from 'ember';
const {Component, computed} = Ember;
const {alias} = computed;
// See gh-tabs-manager.js for use
export default Component.extend({
classNameBindings: ['active'],
tabsManager: computed(function () {
return this.nearestWithProperty('isTabsManager');
}),
tab: computed('tabsManager.tabs.[]', 'tabsManager.tabPanes.[]', function () {
let index = this.get('tabsManager.tabPanes').indexOf(this);
let tabs = this.get('tabsManager.tabs');
return tabs && tabs.objectAt(index);
}),
active: alias('tab.active'),
willRender() {
this._super(...arguments);
// Register with the tabs manager
this.get('tabsManager').registerTabPane(this);
},
willDestroyElement() {
this._super(...arguments);
// Deregister with the tabs manager
this.get('tabsManager').unregisterTabPane(this);
}
});

View file

@ -1,35 +0,0 @@
import Ember from 'ember';
const {Component, computed} = Ember;
// See gh-tabs-manager.js for use
export default Component.extend({
tabsManager: computed(function () {
return this.nearestWithProperty('isTabsManager');
}),
active: computed('tabsManager.activeTab', function () {
return this.get('tabsManager.activeTab') === this;
}),
index: computed('tabsManager.tabs.[]', function () {
return this.get('tabsManager.tabs').indexOf(this);
}),
// Select on click
click() {
this.get('tabsManager').select(this);
},
willRender() {
this._super(...arguments);
// register the tabs with the tab manager
this.get('tabsManager').registerTab(this);
},
willDestroyElement() {
this._super(...arguments);
// unregister the tabs with the tab manager
this.get('tabsManager').unregisterTab(this);
}
});

View file

@ -1,85 +0,0 @@
import Ember from 'ember';
const {Component} = Ember;
/**
Heavily inspired by ic-tabs (https://github.com/instructure/ic-tabs)
Three components work together for smooth tabbing.
1. tabs-manager (gh-tabs)
2. tab (gh-tab)
3. tab-pane (gh-tab-pane)
## Usage:
The tabs-manager must wrap all tab and tab-pane components,
but they can be nested at any level.
A tab and its pane are tied together via their order.
So, the second tab within a tab manager will activate
the second pane within that manager.
```hbs
{{#gh-tabs-manager}}
{{#gh-tab}}
First tab
{{/gh-tab}}
{{#gh-tab}}
Second tab
{{/gh-tab}}
....
{{#gh-tab-pane}}
First pane
{{/gh-tab-pane}}
{{#gh-tab-pane}}
Second pane
{{/gh-tab-pane}}
{{/gh-tabs-manager}}
```
## Options:
the tabs-manager will send a "selected" action whenever one of its
tabs is clicked.
```hbs
{{#gh-tabs-manager selected="myAction"}}
....
{{/gh-tabs-manager}}
```
## Styling:
Both tab and tab-pane elements have an "active"
class applied when they are active.
*/
export default Component.extend({
activeTab: null,
tabs: [],
tabPanes: [],
// Used by children to find this tabsManager
isTabsManager: true,
// Called when a gh-tab is clicked.
select(tab) {
this.set('activeTab', tab);
this.sendAction('selected');
},
// Register tabs and their panes to allow for
// interaction between components.
registerTab(tab) {
this.get('tabs').addObject(tab);
},
unregisterTab(tab) {
this.get('tabs').removeObject(tab);
},
registerTabPane(tabPane) {
this.get('tabPanes').addObject(tabPane);
},
unregisterTabPane(tabPane) {
this.get('tabPanes').removeObject(tabPane);
}
});

View file

@ -1,136 +0,0 @@
/* global key */
import Ember from 'ember';
import boundOneWay from 'ghost/utils/bound-one-way';
import {invokeAction} from 'ember-invoke-action';
const {
Component,
Handlebars,
computed,
get,
inject: {service}
} = Ember;
const {reads} = computed;
export default Component.extend({
tag: null,
scratchName: boundOneWay('tag.name'),
scratchSlug: boundOneWay('tag.slug'),
scratchDescription: boundOneWay('tag.description'),
scratchMetaTitle: boundOneWay('tag.metaTitle'),
scratchMetaDescription: boundOneWay('tag.metaDescription'),
isViewingSubview: false,
config: service(),
mediaQueries: service(),
isMobile: reads('mediaQueries.maxWidth600'),
title: computed('tag.isNew', function () {
if (this.get('tag.isNew')) {
return 'New Tag';
} else {
return 'Tag Settings';
}
}),
seoTitle: computed('scratchName', 'scratchMetaTitle', function () {
let metaTitle = this.get('scratchMetaTitle') || '';
metaTitle = metaTitle.length > 0 ? metaTitle : this.get('scratchName');
if (metaTitle && metaTitle.length > 70) {
metaTitle = metaTitle.substring(0, 70).trim();
metaTitle = Handlebars.Utils.escapeExpression(metaTitle);
metaTitle = Ember.String.htmlSafe(`${metaTitle}&hellip;`);
}
return metaTitle;
}),
seoURL: computed('scratchSlug', function () {
let blogUrl = this.get('config.blogUrl');
let seoSlug = this.get('scratchSlug') || '';
let seoURL = `${blogUrl}/tag/${seoSlug}`;
// only append a slash to the URL if the slug exists
if (seoSlug) {
seoURL += '/';
}
if (seoURL.length > 70) {
seoURL = seoURL.substring(0, 70).trim();
seoURL = Ember.String.htmlSafe(`${seoURL}&hellip;`);
}
return seoURL;
}),
seoDescription: computed('scratchDescription', 'scratchMetaDescription', function () {
let metaDescription = this.get('scratchMetaDescription') || '';
metaDescription = metaDescription.length > 0 ? metaDescription : this.get('scratchDescription');
if (metaDescription && metaDescription.length > 156) {
metaDescription = metaDescription.substring(0, 156).trim();
metaDescription = Handlebars.Utils.escapeExpression(metaDescription);
metaDescription = Ember.String.htmlSafe(`${metaDescription}&hellip;`);
}
return metaDescription;
}),
didReceiveAttrs(attrs) {
this._super(...arguments);
if (get(attrs, 'newAttrs.tag.value.id') !== get(attrs, 'oldAttrs.tag.value.id')) {
this.reset();
}
},
reset() {
this.set('isViewingSubview', false);
if (this.$()) {
this.$('.settings-menu-pane').scrollTop(0);
}
},
focusIn() {
key.setScope('tag-settings-form');
},
focusOut() {
key.setScope('default');
},
actions: {
setProperty(property, value) {
invokeAction(this, 'setProperty', property, value);
},
setCoverImage(image) {
invokeAction(this, 'setProperty', 'image', image);
},
clearCoverImage() {
invokeAction(this, 'setProperty', 'image', '');
},
openMeta() {
this.set('isViewingSubview', true);
},
closeMeta() {
this.set('isViewingSubview', false);
},
deleteTag() {
invokeAction(this, 'showDeleteTagModal');
}
}
});

View file

@ -1,12 +0,0 @@
import Ember from 'ember';
import {invokeAction} from 'ember-invoke-action';
export default Ember.Component.extend({
willDestroyElement() {
this._super(...arguments);
if (this.get('tag.isDeleted') && this.get('onDelete')) {
invokeAction(this, 'onDelete');
}
}
});

View file

@ -1,54 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service},
isBlank,
observer,
run
} = Ember;
const {equal, reads} = computed;
export default Component.extend({
classNames: ['view-container'],
classNameBindings: ['isMobile'],
mediaQueries: service(),
tags: null,
selectedTag: null,
isMobile: reads('mediaQueries.maxWidth600'),
isEmpty: equal('tags.length', 0),
init() {
this._super(...arguments);
run.schedule('actions', this, this.fireMobileChangeActions);
},
displaySettingsPane: computed('isEmpty', 'selectedTag', 'isMobile', function () {
let isEmpty = this.get('isEmpty');
let selectedTag = this.get('selectedTag');
let isMobile = this.get('isMobile');
// always display settings pane for blank-slate on mobile
if (isMobile && isEmpty) {
return true;
}
// display list if no tag is selected on mobile
if (isMobile && isBlank(selectedTag)) {
return false;
}
// default to displaying settings pane
return true;
}),
fireMobileChangeActions: observer('isMobile', function () {
if (!this.get('isMobile')) {
this.sendAction('leftMobile');
}
})
});

View file

@ -1,8 +0,0 @@
import Ember from 'ember';
import TextInputMixin from 'ghost/mixins/text-input';
const {TextArea} = Ember;
export default TextArea.extend(TextInputMixin, {
classNames: 'gh-input'
});

View file

@ -1,41 +0,0 @@
/*global device*/
import Ember from 'ember';
const {TextField, computed} = Ember;
export default TextField.extend({
focus: true,
classNames: 'gh-input',
attributeBindings: ['autofocus'],
autofocus: computed(function () {
if (this.get('focus')) {
return (device.ios()) ? false : 'autofocus';
}
return false;
}),
_focusField() {
// This fix is required until Mobile Safari has reliable
// autofocus, select() or focus() support
if (this.get('focus') && !device.ios()) {
this.$().val(this.$().val()).focus();
}
},
_trimValue() {
let text = this.$().val();
this.$().val(text.trim());
},
didInsertElement() {
this._super(...arguments);
this._focusField();
},
focusOut() {
this._super(...arguments);
this._trimValue();
}
});

View file

@ -1,35 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
/*
Example usage:
{{gh-url-preview prefix="tag" slug=theSlugValue tagName="p" classNames="description"}}
*/
export default Component.extend({
classNames: 'ghost-url-preview',
prefix: null,
slug: null,
config: service(),
url: computed('slug', function () {
// Get the blog URL and strip the scheme
let blogUrl = this.get('config.blogUrl');
// Remove `http[s]://`
let noSchemeBlogUrl = blogUrl.substr(blogUrl.indexOf('://') + 3);
// Get the prefix and slug values
let prefix = this.get('prefix') ? `${this.get('prefix')}/` : '';
let slug = this.get('slug') ? `${this.get('slug')}/` : '';
// Join parts of the URL together with slashes
let theUrl = `${noSchemeBlogUrl}/${prefix}${slug}`;
return theUrl;
})
});

View file

@ -1,31 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
export default Component.extend({
tagName: '',
user: null,
ghostPaths: service(),
userDefault: computed('ghostPaths', function () {
return `${this.get('ghostPaths.subdir')}/ghost/img/user-image.png`;
}),
userImageBackground: computed('user.image', 'userDefault', function () {
let url = this.get('user.image') || this.get('userDefault');
return Ember.String.htmlSafe(`background-image: url(${url})`);
}),
lastLogin: computed('user.lastLogin', function () {
let lastLogin = this.get('user.lastLogin');
return lastLogin ? lastLogin.fromNow() : '(Never)';
})
});

View file

@ -1,69 +0,0 @@
import Ember from 'ember';
const {
Component,
computed,
inject: {service}
} = Ember;
export default Component.extend({
tagName: '',
user: null,
isSending: false,
notifications: service(),
createdAt: computed('user.createdAt', function () {
let createdAt = this.get('user.createdAt');
return createdAt ? createdAt.fromNow() : '';
}),
actions: {
resend() {
let user = this.get('user');
let notifications = this.get('notifications');
this.set('isSending', true);
user.resendInvite().then((result) => {
let notificationText = `Invitation resent! (${user.get('email')})`;
// If sending the invitation email fails, the API will still return a status of 201
// but the user's status in the response object will be 'invited-pending'.
if (result.users[0].status === 'invited-pending') {
notifications.showAlert('Invitation email was not sent. Please try resending.', {type: 'error', key: 'invite.resend.not-sent'});
} else {
user.set('status', result.users[0].status);
notifications.showNotification(notificationText, {key: 'invite.resend.success'});
}
}).catch((error) => {
notifications.showAPIError(error, {key: 'invite.resend'});
}).finally(() => {
this.set('isSending', false);
});
},
revoke() {
let user = this.get('user');
let email = user.get('email');
let notifications = this.get('notifications');
// reload the user to get the most up-to-date information
user.reload().then(() => {
if (user.get('invited')) {
user.destroyRecord().then(() => {
let notificationText = `Invitation revoked. (${email})`;
notifications.showNotification(notificationText, {key: 'invite.revoke.success'});
}).catch((error) => {
notifications.showAPIError(error, {key: 'invite.revoke'});
});
} else {
// if the user is no longer marked as "invited", then show a warning and reload the route
this.sendAction('reload');
notifications.showAlert('This user has already accepted the invitation.', {type: 'error', delayed: true, key: 'invite.revoke.already-accepted'});
}
});
}
}
});

View file

@ -1,26 +0,0 @@
import Ember from 'ember';
import ValidationStateMixin from 'ghost/mixins/validation-state';
const {Component, computed} = Ember;
/**
* Handles the CSS necessary to show a specific property state. When passed a
* DS.Errors object and a property name, if the DS.Errors object has errors for
* the specified property, it will change the CSS to reflect the error state
* @param {DS.Errors} errors The DS.Errors object
* @param {string} property Name of the property
*/
export default Component.extend(ValidationStateMixin, {
classNameBindings: ['errorClass'],
errorClass: computed('property', 'hasError', 'hasValidated.[]', function () {
let hasValidated = this.get('hasValidated');
let property = this.get('property');
if (hasValidated && hasValidated.contains(property)) {
return this.get('hasError') ? 'error' : 'success';
} else {
return '';
}
})
});

View file

@ -1,14 +0,0 @@
import Ember from 'ember';
const {Component} = Ember;
export default Component.extend({
tagName: 'h2',
classNames: ['view-title'],
actions: {
openMobileMenu() {
this.sendAction('openMobileMenu');
}
}
});

View file

@ -1,56 +0,0 @@
/* global key */
import Ember from 'ember';
import {invokeAction} from 'ember-invoke-action';
const {Component, run} = Ember;
export default Component.extend({
tagName: 'section',
classNames: 'modal-content',
_previousKeymasterScope: null,
_setupShortcuts() {
run(function () {
document.activeElement.blur();
});
this._previousKeymasterScope = key.getScope();
key('enter', 'modal', () => {
this.send('confirm');
});
key('escape', 'modal', () => {
this.send('closeModal');
});
key.setScope('modal');
},
_removeShortcuts() {
key.unbind('enter', 'modal');
key.unbind('escape', 'modal');
key.setScope(this._previousKeymasterScope);
},
didInsertElement() {
this._super(...arguments);
this._setupShortcuts();
},
willDestroyElement() {
this._super(...arguments);
this._removeShortcuts();
},
actions: {
confirm() {
throw new Error('You must override the "confirm" action in your modal component');
},
closeModal() {
invokeAction(this, 'closeModal');
}
}
});

View file

@ -1,9 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
const {computed} = Ember;
const {alias} = computed;
export default ModalComponent.extend({
generatedHtml: alias('model')
});

View file

@ -1,49 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
const {
inject: {service}
} = Ember;
export default ModalComponent.extend({
submitting: false,
ghostPaths: service(),
notifications: service(),
store: service(),
ajax: service(),
_deleteAll() {
let deleteUrl = this.get('ghostPaths.url').api('db');
return this.get('ajax').del(deleteUrl);
},
_unloadData() {
this.get('store').unloadAll('post');
this.get('store').unloadAll('tag');
},
_showSuccess() {
this.get('notifications').showAlert('All content deleted from database.', {type: 'success', key: 'all-content.delete.success'});
},
_showFailure(error) {
this.get('notifications').showAPIError(error, {key: 'all-content.delete'});
},
actions: {
confirm() {
this.set('submitting', true);
this._deleteAll().then(() => {
this._unloadData();
this._showSuccess();
}).catch((error) => {
this._showFailure(error);
}).finally(() => {
this.send('closeModal');
});
}
}
});

View file

@ -1,55 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
const {
computed,
inject: {service}
} = Ember;
const {alias} = computed;
export default ModalComponent.extend({
submitting: false,
post: alias('model'),
notifications: service(),
routing: service('-routing'),
_deletePost() {
let post = this.get('post');
// definitely want to clear the data store and post of any unsaved,
// client-generated tags
post.updateTags();
return post.destroyRecord();
},
_success() {
// clear any previous error messages
this.get('notifications').closeAlerts('post.delete');
// redirect to content screen
this.get('routing').transitionTo('posts');
},
_failure() {
this.get('notifications').showAlert('Your post could not be deleted. Please try again.', {type: 'error', key: 'post.delete.failed'});
},
actions: {
confirm() {
this.set('submitting', true);
this._deletePost().then(() => {
this._success();
}, () => {
this._failure();
}).finally(() => {
this.send('closeModal');
});
}
}
});

View file

@ -1,23 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
import {invokeAction} from 'ember-invoke-action';
const {computed} = Ember;
const {alias} = computed;
export default ModalComponent.extend({
submitting: false,
subscriber: alias('model'),
actions: {
confirm() {
this.set('submitting', true);
invokeAction(this, 'confirm').finally(() => {
this.set('submitting', false);
});
}
}
});

View file

@ -1,27 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
import {invokeAction} from 'ember-invoke-action';
const {computed} = Ember;
const {alias} = computed;
export default ModalComponent.extend({
submitting: false,
tag: alias('model'),
postInflection: computed('tag.count.posts', function () {
return this.get('tag.count.posts') > 1 ? 'posts' : 'post';
}),
actions: {
confirm() {
this.set('submitting', true);
invokeAction(this, 'confirm').finally(() => {
this.send('closeModal');
});
}
}
});

View file

@ -1,19 +0,0 @@
import ModalComponent from 'ghost/components/modals/base';
import {invokeAction} from 'ember-invoke-action';
export default ModalComponent.extend({
submitting: false,
user: null,
actions: {
confirm() {
this.set('submitting', true);
invokeAction(this, 'confirm').finally(() => {
this.send('closeModal');
});
}
}
});

View file

@ -1,43 +0,0 @@
import Ember from 'ember';
import { invokeAction } from 'ember-invoke-action';
import ModalComponent from 'ghost/components/modals/base';
import ghostPaths from 'ghost/utils/ghost-paths';
const {computed} = Ember;
export default ModalComponent.extend({
labelText: 'Select or drag-and-drop a CSV File',
response: null,
closeDisabled: false,
uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/subscribers/csv/`;
}),
actions: {
uploadStarted() {
this.set('closeDisabled', true);
},
uploadFinished() {
this.set('closeDisabled', false);
},
uploadSuccess(response) {
this.set('response', response.meta.stats);
// invoke the passed in confirm action
invokeAction(this, 'confirm');
},
confirm() {
// noop - we don't want the enter key doing anything
},
closeModal() {
if (!this.get('closeDisabled')) {
this._super(...arguments);
}
}
}
});

View file

@ -1,125 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
import ValidationEngine from 'ghost/mixins/validation-engine';
const {
RSVP: {Promise},
inject: {service},
run
} = Ember;
const emberA = Ember.A;
export default ModalComponent.extend(ValidationEngine, {
classNames: 'modal-content invite-new-user',
role: null,
roles: null,
authorRole: null,
submitting: false,
validationType: 'inviteUser',
notifications: service(),
store: service(),
init() {
this._super(...arguments);
// populate roles and set initial value for the dropdown
run.schedule('afterRender', this, function () {
this.get('store').query('role', {permissions: 'assign'}).then((roles) => {
let authorRole = roles.findBy('name', 'Author');
this.set('roles', roles);
this.set('authorRole', authorRole);
if (!this.get('role')) {
this.set('role', authorRole);
}
});
});
},
willDestroyElement() {
this._super(...arguments);
// TODO: this should not be needed, ValidationEngine acts as a
// singleton and so it's errors and hasValidated state stick around
this.get('errors').clear();
this.set('hasValidated', emberA());
},
validate() {
let email = this.get('email');
// TODO: either the validator should check the email's existence or
// the API should return an appropriate error when attempting to save
return new Promise((resolve, reject) => {
return this._super().then(() => {
this.get('store').findAll('user', {reload: true}).then((result) => {
let invitedUser = result.findBy('email', email);
if (invitedUser) {
this.get('errors').clear('email');
if (invitedUser.get('status') === 'invited' || invitedUser.get('status') === 'invited-pending') {
this.get('errors').add('email', 'A user with that email address was already invited.');
} else {
this.get('errors').add('email', 'A user with that email address already exists.');
}
// TODO: this shouldn't be needed, ValidationEngine doesn't mark
// properties as validated when validating an entire object
this.get('hasValidated').addObject('email');
reject();
} else {
resolve();
}
});
}, () => {
// TODO: this shouldn't be needed, ValidationEngine doesn't mark
// properties as validated when validating an entire object
this.get('hasValidated').addObject('email');
reject();
});
});
},
actions: {
setRole(role) {
this.set('role', role);
},
confirm() {
let email = this.get('email');
let role = this.get('role');
let notifications = this.get('notifications');
let newUser;
this.validate().then(() => {
this.set('submitting', true);
newUser = this.get('store').createRecord('user', {
email,
role,
status: 'invited'
});
newUser.save().then(() => {
let notificationText = `Invitation sent! (${email})`;
// If sending the invitation email fails, the API will still return a status of 201
// but the user's status in the response object will be 'invited-pending'.
if (newUser.get('status') === 'invited-pending') {
notifications.showAlert('Invitation email was not sent. Please try resending.', {type: 'error', key: 'invite.send.failed'});
} else {
notifications.showNotification(notificationText, {key: 'invite.send.success'});
}
}).catch((errors) => {
newUser.deleteRecord();
notifications.showErrors(errors, {key: 'invite.send'});
}).finally(() => {
this.send('closeModal');
});
});
}
}
});

View file

@ -1,12 +0,0 @@
import ModalComponent from 'ghost/components/modals/base';
import {invokeAction} from 'ember-invoke-action';
export default ModalComponent.extend({
actions: {
confirm() {
invokeAction(this, 'confirm').finally(() => {
this.send('closeModal');
});
}
}
});

View file

@ -1,4 +0,0 @@
import ModalComponent from 'ghost/components/modals/base';
export default ModalComponent.extend({
});

View file

@ -1,32 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
export default ModalComponent.extend({
actions: {
updateEmail(newEmail) {
this.set('model.email', newEmail);
this.set('model.hasValidated', Ember.A());
this.get('model.errors').clear();
},
confirm() {
let confirmAction = this.get('confirm');
this.set('submitting', true);
confirmAction().then(() => {
this.send('closeModal');
}).catch((errors) => {
let [error] = errors;
if (error && error.match(/email/i)) {
this.get('model.errors').add('email', error);
this.get('model.hasValidated').pushObject('email');
}
}).finally(() => {
if (!this.get('isDestroying') && !this.get('isDestroyed')) {
this.set('submitting', false);
}
});
}
}
});

View file

@ -1,68 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
import ValidationEngine from 'ghost/mixins/validation-engine';
const {
$,
computed,
inject: {service}
} = Ember;
export default ModalComponent.extend(ValidationEngine, {
validationType: 'signin',
submitting: false,
authenticationError: null,
notifications: service(),
session: service(),
identification: computed('session.user.email', function () {
return this.get('session.user.email');
}),
_authenticate() {
let session = this.get('session');
let authStrategy = 'authenticator:oauth2';
let identification = this.get('identification');
let password = this.get('password');
session.set('skipAuthSuccessHandler', true);
this.toggleProperty('submitting');
return session.authenticate(authStrategy, identification, password).finally(() => {
this.toggleProperty('submitting');
session.set('skipAuthSuccessHandler', undefined);
});
},
actions: {
confirm() {
// Manually trigger events for input fields, ensuring legacy compatibility with
// browsers and password managers that don't send proper events on autofill
$('#login').find('input').trigger('change');
this.set('authenticationError', null);
this.validate({property: 'signin'}).then(() => {
this._authenticate().then(() => {
this.get('notifications').closeAlerts('post.save');
this.send('closeModal');
}).catch((error) => {
if (error && error.errors) {
error.errors.forEach((err) => {
err.message = Ember.String.htmlSafe(err.message);
});
this.get('errors').add('password', 'Incorrect password');
this.get('hasValidated').pushObject('password');
this.set('authenticationError', error.errors[0].message);
}
});
}, () => {
this.get('hasValidated').pushObject('password');
});
}
}
});

View file

@ -1,17 +0,0 @@
import ModalComponent from 'ghost/components/modals/base';
import {invokeAction} from 'ember-invoke-action';
export default ModalComponent.extend({
user: null,
submitting: false,
actions: {
confirm() {
this.set('submitting', true);
invokeAction(this, 'confirm').finally(() => {
this.send('closeModal');
});
}
}
});

View file

@ -1,101 +0,0 @@
import Ember from 'ember';
import ModalComponent from 'ghost/components/modals/base';
import cajaSanitizers from 'ghost/utils/caja-sanitizers';
const {
computed,
inject: {service},
isEmpty
} = Ember;
export default ModalComponent.extend({
model: null,
submitting: false,
url: '',
newUrl: '',
config: service(),
notifications: service(),
image: computed('model.model', 'model.imageProperty', {
get() {
let imageProperty = this.get('model.imageProperty');
return this.get(`model.model.${imageProperty}`);
},
set(key, value) {
let model = this.get('model.model');
let imageProperty = this.get('model.imageProperty');
return model.set(imageProperty, value);
}
}),
didReceiveAttrs() {
let image = this.get('image');
this.set('url', image);
this.set('newUrl', image);
},
// TODO: should validation be handled in the gh-image-uploader component?
// pro - consistency everywhere, simplification here
// con - difficult if the "save" is happening externally as it does here
//
// maybe it should be handled at the model level?
// - automatically present everywhere
// - file uploads should always result in valid urls so it should only
// affect the url input form
keyDown() {
this._setErrorState(false);
},
_setErrorState(state) {
if (state) {
this.$('.url').addClass('error');
} else {
this.$('.url').removeClass('error');
}
},
_validateUrl(url) {
if (!isEmpty(url) && !cajaSanitizers.url(url)) {
this._setErrorState(true);
return {message: 'Image URI is not valid'};
}
return true;
},
// end validation
actions: {
fileUploaded(url) {
this.set('url', url);
this.set('newUrl', url);
},
removeImage() {
this.set('url', '');
this.set('newUrl', '');
},
confirm() {
let model = this.get('model.model');
let newUrl = this.get('newUrl');
let result = this._validateUrl(newUrl);
let notifications = this.get('notifications');
if (result === true) {
this.set('submitting', true);
this.set('image', newUrl);
model.save().catch((err) => {
notifications.showAPIError(err, {key: 'image.upload'});
}).finally(() => {
this.send('closeModal');
});
}
}
}
});

View file

@ -1,21 +0,0 @@
import Ember from 'ember';
const {
Controller,
computed
} = Ember;
export default Controller.extend({
updateNotificationCount: 0,
actions: {
updateNotificationChange(count) {
this.set('updateNotificationCount', count);
}
},
copyrightYear: computed(function () {
let date = new Date();
return date.getFullYear();
})
});

View file

@ -1,57 +0,0 @@
import Ember from 'ember';
const {
Controller,
computed,
inject: {service}
} = Ember;
export default Controller.extend({
dropdown: service(),
session: service(),
showNavMenu: computed('currentPath', 'session.isAuthenticated', function () {
return (this.get('currentPath') !== 'error404' || this.get('session.isAuthenticated')) &&
!this.get('currentPath').match(/(signin|signup|setup|reset)/);
}),
topNotificationCount: 0,
showMobileMenu: false,
showSettingsMenu: false,
showMarkdownHelpModal: false,
autoNav: false,
autoNavOpen: computed('autoNav', {
get() {
return false;
},
set(key, value) {
if (this.get('autoNav')) {
return value;
}
return false;
}
}),
actions: {
topNotificationChange(count) {
this.set('topNotificationCount', count);
},
toggleAutoNav() {
this.toggleProperty('autoNav');
},
openAutoNav() {
this.set('autoNavOpen', true);
},
closeAutoNav() {
this.set('autoNavOpen', false);
},
closeMobileMenu() {
this.set('showMobileMenu', false);
}
}
});

View file

@ -1,14 +0,0 @@
import Ember from 'ember';
import EditorControllerMixin from 'ghost/mixins/editor-base-controller';
const {Controller} = Ember;
export default Controller.extend(EditorControllerMixin, {
showDeletePostModal: false,
actions: {
toggleDeletePostModal() {
this.toggleProperty('showDeletePostModal');
}
}
});

View file

@ -1,25 +0,0 @@
import Ember from 'ember';
import EditorControllerMixin from 'ghost/mixins/editor-base-controller';
const {Controller} = Ember;
function K() {
return this;
}
export default Controller.extend(EditorControllerMixin, {
// Overriding autoSave on the base controller, as the new controller shouldn't be autosaving
autoSave: K,
actions: {
/**
* Redirect to editor after the first save
*/
save(options) {
return this._super(options).then((model) => {
if (model.get('id')) {
this.replaceRoute('editor.edit', model);
}
});
}
}
});

View file

@ -1,20 +0,0 @@
import Ember from 'ember';
const {Controller, computed} = Ember;
export default Controller.extend({
stack: false,
code: computed('content.status', function () {
return this.get('content.status') > 200 ? this.get('content.status') : 500;
}),
message: computed('content.statusText', function () {
if (this.get('code') === 404) {
return 'Page not found';
}
return this.get('content.statusText') !== 'error' ? this.get('content.statusText') : 'Internal Server Error';
})
});

View file

@ -1,491 +0,0 @@
import Ember from 'ember';
import {parseDateString} from 'ghost/utils/date-formatting';
import SettingsMenuMixin from 'ghost/mixins/settings-menu-controller';
import boundOneWay from 'ghost/utils/bound-one-way';
import isNumber from 'ghost/utils/isNumber';
const {
$,
ArrayProxy,
Controller,
Handlebars,
PromiseProxyMixin,
RSVP,
computed,
guidFor,
inject: {service, controller},
isArray,
isBlank,
observer,
run
} = Ember;
export default Controller.extend(SettingsMenuMixin, {
debounceId: null,
lastPromise: null,
selectedAuthor: null,
application: controller(),
config: service(),
ghostPaths: service(),
notifications: service(),
session: service(),
slugGenerator: service(),
initializeSelectedAuthor: observer('model', function () {
return this.get('model.author').then((author) => {
this.set('selectedAuthor', author);
return author;
});
}),
authors: computed(function () {
// Loaded asynchronously, so must use promise proxies.
let deferred = {};
deferred.promise = this.store.query('user', {limit: 'all'}).then((users) => {
return users.rejectBy('id', 'me').sortBy('name');
}).then((users) => {
return users.filter((user) => {
return user.get('active');
});
});
return ArrayProxy
.extend(PromiseProxyMixin)
.create(deferred);
}),
slugValue: boundOneWay('model.slug'),
// Requests slug from title
generateAndSetSlug(destination) {
let title = this.get('model.titleScratch');
let afterSave = this.get('lastPromise');
let promise;
// Only set an "untitled" slug once per post
if (title === '(Untitled)' && this.get('model.slug')) {
return;
}
promise = RSVP.resolve(afterSave).then(() => {
return this.get('slugGenerator').generateSlug('post', title).then((slug) => {
if (!isBlank(slug)) {
this.set(destination, slug);
}
}).catch(() => {
// Nothing to do (would be nice to log this somewhere though),
// but a rejected promise needs to be handled here so that a resolved
// promise is returned.
});
});
this.set('lastPromise', promise);
},
metaTitleScratch: boundOneWay('model.metaTitle'),
metaDescriptionScratch: boundOneWay('model.metaDescription'),
seoTitle: computed('model.titleScratch', 'metaTitleScratch', function () {
let metaTitle = this.get('metaTitleScratch') || '';
metaTitle = metaTitle.length > 0 ? metaTitle : this.get('model.titleScratch');
if (metaTitle.length > 70) {
metaTitle = metaTitle.substring(0, 70).trim();
metaTitle = Handlebars.Utils.escapeExpression(metaTitle);
metaTitle = Ember.String.htmlSafe(`${metaTitle}&hellip;`);
}
return metaTitle;
}),
seoDescription: computed('model.scratch', 'metaDescriptionScratch', function () {
let metaDescription = this.get('metaDescriptionScratch') || '';
let html = '';
let el, placeholder;
if (metaDescription.length > 0) {
placeholder = metaDescription;
} else {
el = $('.rendered-markdown');
// Get rendered markdown
if (el !== undefined && el.length > 0) {
html = el.clone();
html.find('.js-drop-zone').remove();
html = html[0].innerHTML;
}
// Strip HTML
placeholder = $('<div />', {html}).text();
// Replace new lines and trim
placeholder = placeholder.replace(/\n+/g, ' ').trim();
}
if (placeholder.length > 156) {
// Limit to 156 characters
placeholder = placeholder.substring(0, 156).trim();
placeholder = Handlebars.Utils.escapeExpression(placeholder);
placeholder = Ember.String.htmlSafe(`${placeholder}&hellip;`);
}
return placeholder;
}),
seoURL: computed('model.slug', 'config.blogUrl', function () {
let blogUrl = this.get('config.blogUrl');
let seoSlug = this.get('model.slug') ? this.get('model.slug') : '';
let seoURL = `${blogUrl}/${seoSlug}`;
// only append a slash to the URL if the slug exists
if (seoSlug) {
seoURL += '/';
}
if (seoURL.length > 70) {
seoURL = seoURL.substring(0, 70).trim();
seoURL = Ember.String.htmlSafe(`${seoURL}&hellip;`);
}
return seoURL;
}),
// observe titleScratch, keeping the post's slug in sync
// with it until saved for the first time.
addTitleObserver: observer('model', function () {
if (this.get('model.isNew') || this.get('model.title') === '(Untitled)') {
this.addObserver('model.titleScratch', this, 'titleObserver');
}
}),
titleObserver() {
let title = this.get('model.title');
let debounceId;
// generate a slug if a post is new and doesn't have a title yet or
// if the title is still '(Untitled)' and the slug is unaltered.
if ((this.get('model.isNew') && !title) || title === '(Untitled)') {
debounceId = run.debounce(this, 'generateAndSetSlug', 'model.slug', 700);
}
this.set('debounceId', debounceId);
},
// live-query of all tags for tag input autocomplete
availableTags: computed(function () {
return this.get('store').filter('tag', {limit: 'all'}, () => {
return true;
});
}),
showErrors(errors) {
errors = isArray(errors) ? errors : [errors];
this.get('notifications').showErrors(errors);
},
actions: {
discardEnter() {
return false;
},
togglePage() {
this.toggleProperty('model.page');
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (this.get('model.isNew')) {
return;
}
this.get('model').save().catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
});
},
toggleFeatured() {
this.toggleProperty('model.featured');
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (this.get('model.isNew')) {
return;
}
this.get('model').save(this.get('saveOptions')).catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
});
},
/**
* triggered by user manually changing slug
*/
updateSlug(newSlug) {
let slug = this.get('model.slug');
newSlug = newSlug || slug;
newSlug = newSlug && newSlug.trim();
// Ignore unchanged slugs or candidate slugs that are empty
if (!newSlug || slug === newSlug) {
// reset the input to its previous state
this.set('slugValue', slug);
return;
}
this.get('slugGenerator').generateSlug('post', newSlug).then((serverSlug) => {
// If after getting the sanitized and unique slug back from the API
// we end up with a slug that matches the existing slug, abort the change
if (serverSlug === slug) {
return;
}
// Because the server transforms the candidate slug by stripping
// certain characters and appending a number onto the end of slugs
// to enforce uniqueness, there are cases where we can get back a
// candidate slug that is a duplicate of the original except for
// the trailing incrementor (e.g., this-is-a-slug and this-is-a-slug-2)
// get the last token out of the slug candidate and see if it's a number
let slugTokens = serverSlug.split('-');
let check = Number(slugTokens.pop());
// if the candidate slug is the same as the existing slug except
// for the incrementor then the existing slug should be used
if (isNumber(check) && check > 0) {
if (slug === slugTokens.join('-') && serverSlug !== newSlug) {
this.set('slugValue', slug);
return;
}
}
this.set('model.slug', serverSlug);
if (this.hasObserverFor('model.titleScratch')) {
this.removeObserver('model.titleScratch', this, 'titleObserver');
}
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (this.get('model.isNew')) {
return;
}
return this.get('model').save();
}).catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
});
},
/**
* Parse user's set published date.
* Action sent by post settings menu view.
* (#1351)
*/
setPublishedAt(userInput) {
if (!userInput) {
// Clear out the publishedAt field for a draft
if (this.get('model.isDraft')) {
this.set('model.publishedAt', null);
}
return;
}
let newPublishedAt = parseDateString(userInput);
let publishedAt = moment(this.get('model.publishedAt'));
let errMessage = '';
// Clear previous errors
this.get('model.errors').remove('post-setting-date');
// Validate new Published date
if (!newPublishedAt.isValid()) {
errMessage = 'Published Date must be a valid date with format: ' +
'DD MMM YY @ HH:mm (e.g. 6 Dec 14 @ 15:00)';
} else if (newPublishedAt.diff(new Date(), 'h') > 0) {
errMessage = 'Published Date cannot currently be in the future.';
}
// If errors, notify and exit.
if (errMessage) {
this.get('model.errors').add('post-setting-date', errMessage);
return;
}
// Validation complete, update the view
this.set('model.publishedAt', newPublishedAt);
// Don't save the date if the user didn't actually changed the date
if (publishedAt && publishedAt.isSame(newPublishedAt)) {
return;
}
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (this.get('model.isNew')) {
return;
}
this.get('model').save().catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
});
},
setMetaTitle(metaTitle) {
let property = 'metaTitle';
let model = this.get('model');
let currentTitle = model.get(property) || '';
// Only update if the title has changed
if (currentTitle === metaTitle) {
return;
}
model.set(property, metaTitle);
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (model.get('isNew')) {
return;
}
model.save();
},
setMetaDescription(metaDescription) {
let property = 'metaDescription';
let model = this.get('model');
let currentDescription = model.get(property) || '';
// Only update if the description has changed
if (currentDescription === metaDescription) {
return;
}
model.set(property, metaDescription);
// If this is a new post. Don't save the model. Defer the save
// to the user pressing the save button
if (model.get('isNew')) {
return;
}
model.save();
},
setCoverImage(image) {
this.set('model.image', image);
if (this.get('model.isNew')) {
return;
}
this.get('model').save().catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
});
},
clearCoverImage() {
this.set('model.image', '');
if (this.get('model.isNew')) {
return;
}
this.get('model').save().catch((errors) => {
this.showErrors(errors);
this.get('model').rollbackAttributes();
});
},
resetPubDate() {
this.set('publishedAtValue', '');
},
closeNavMenu() {
this.get('application').send('closeNavMenu');
},
changeAuthor(newAuthor) {
let author = this.get('model.author');
let model = this.get('model');
// return if nothing changed
if (newAuthor.get('id') === author.get('id')) {
return;
}
model.set('author', newAuthor);
// if this is a new post (never been saved before), don't try to save it
if (this.get('model.isNew')) {
return;
}
model.save().catch((errors) => {
this.showErrors(errors);
this.set('selectedAuthor', author);
model.rollbackAttributes();
});
},
addTag(tagName, index) {
let currentTags = this.get('model.tags');
let currentTagNames = currentTags.map((tag) => {
return tag.get('name').toLowerCase();
});
let availableTagNames,
tagToAdd;
tagName = tagName.trim();
// abort if tag is already selected
if (currentTagNames.contains(tagName.toLowerCase())) {
return;
}
this.get('availableTags').then((availableTags) => {
availableTagNames = availableTags.map((tag) => {
return tag.get('name').toLowerCase();
});
// find existing tag or create new
if (availableTagNames.contains(tagName.toLowerCase())) {
tagToAdd = availableTags.find((tag) => {
return tag.get('name').toLowerCase() === tagName.toLowerCase();
});
} else {
tagToAdd = this.get('store').createRecord('tag', {
name: tagName
});
// we need to set a UUID so that selectize has a unique value
// it will be ignored when sent to the server
tagToAdd.set('uuid', guidFor(tagToAdd));
}
// push tag onto post relationship
if (tagToAdd) {
this.get('model.tags').insertAt(index, tagToAdd);
}
});
},
removeTag(tag) {
this.get('model.tags').removeObject(tag);
if (tag.get('isNew')) {
tag.destroyRecord();
}
}
}
});

View file

@ -1,88 +0,0 @@
import Ember from 'ember';
const {Controller, compare, computed} = Ember;
const {equal} = computed;
// a custom sort function is needed in order to sort the posts list the same way the server would:
// status: ASC
// publishedAt: DESC
// updatedAt: DESC
// id: DESC
function comparator(item1, item2) {
let updated1 = item1.get('updatedAt');
let updated2 = item2.get('updatedAt');
let idResult,
publishedAtResult,
statusResult,
updatedAtResult;
// when `updatedAt` is undefined, the model is still
// being written to with the results from the server
if (item1.get('isNew') || !updated1) {
return -1;
}
if (item2.get('isNew') || !updated2) {
return 1;
}
idResult = compare(parseInt(item1.get('id')), parseInt(item2.get('id')));
statusResult = compare(item1.get('status'), item2.get('status'));
updatedAtResult = compare(updated1.valueOf(), updated2.valueOf());
publishedAtResult = publishedAtCompare(item1, item2);
if (statusResult === 0) {
if (publishedAtResult === 0) {
if (updatedAtResult === 0) {
// This should be DESC
return idResult * -1;
}
// This should be DESC
return updatedAtResult * -1;
}
// This should be DESC
return publishedAtResult * -1;
}
return statusResult;
}
function publishedAtCompare(item1, item2) {
let published1 = item1.get('publishedAt');
let published2 = item2.get('publishedAt');
if (!published1 && !published2) {
return 0;
}
if (!published1 && published2) {
return -1;
}
if (!published2 && published1) {
return 1;
}
return compare(published1.valueOf(), published2.valueOf());
}
export default Controller.extend({
showDeletePostModal: false,
// See PostsRoute's shortcuts
postListFocused: equal('keyboardFocus', 'postList'),
postContentFocused: equal('keyboardFocus', 'postContent'),
sortedPosts: computed('model.@each.status', 'model.@each.publishedAt', 'model.@each.isNew', 'model.@each.updatedAt', function () {
let postsArray = this.get('model').toArray();
return postsArray.sort(comparator);
}),
actions: {
toggleDeletePostModal() {
this.toggleProperty('showDeletePostModal');
}
}
});

View file

@ -1,75 +0,0 @@
import Ember from 'ember';
import ValidationEngine from 'ghost/mixins/validation-engine';
const {
Controller,
computed,
inject: {service}
} = Ember;
export default Controller.extend(ValidationEngine, {
newPassword: '',
ne2Password: '',
token: '',
submitting: false,
flowErrors: '',
validationType: 'reset',
ghostPaths: service(),
notifications: service(),
session: service(),
ajax: service(),
email: computed('token', function () {
// The token base64 encodes the email (and some other stuff),
// each section is divided by a '|'. Email comes second.
return atob(this.get('token')).split('|')[1];
}),
// Used to clear sensitive information
clearData() {
this.setProperties({
newPassword: '',
ne2Password: '',
token: ''
});
},
actions: {
submit() {
let credentials = this.getProperties('newPassword', 'ne2Password', 'token');
this.set('flowErrors', '');
this.get('hasValidated').addObjects(['newPassword', 'ne2Password']);
this.validate().then(() => {
let authUrl = this.get('ghostPaths.url').api('authentication', 'passwordreset');
this.toggleProperty('submitting');
this.get('ajax').put(authUrl, {
data: {
passwordreset: [credentials]
}
}).then((resp) => {
this.toggleProperty('submitting');
this.get('notifications').showAlert(resp.passwordreset[0].message, {type: 'warn', delayed: true, key: 'password.reset'});
this.get('session').authenticate('authenticator:oauth2', this.get('email'), credentials.newPassword);
}).catch((error) => {
this.get('notifications').showAPIError(error, {key: 'password.reset'});
this.toggleProperty('submitting');
});
}).catch((error) => {
if (this.get('errors.newPassword')) {
this.set('flowErrors', this.get('errors.newPassword')[0].message);
}
if (this.get('errors.ne2Password')) {
this.set('flowErrors', this.get('errors.ne2Password')[0].message);
}
if (this.get('errors.length') === 0) {
throw error;
}
});
}
}
});

View file

@ -1,14 +0,0 @@
import Ember from 'ember';
const {
computed,
inject: {controller}
} = Ember;
const {alias} = computed;
export default Ember.Controller.extend({
appsController: controller('settings.apps'),
slack: alias('appsController.model.slack.firstObject')
});

View file

@ -1,75 +0,0 @@
import Ember from 'ember';
import { invoke } from 'ember-invoke-action';
const {
Controller,
computed: {empty},
inject: {service}
} = Ember;
export default Controller.extend({
ghostPaths: service(),
ajax: service(),
notifications: service(),
// will be set by route
settings: null,
isSaving: false,
savePromise: null,
isSendingTest: false,
testNotificationDisabled: empty('model.url'),
actions: {
sendTestNotification() {
let notifications = this.get('notifications');
let slackApi = this.get('ghostPaths.url').api('slack', 'test');
if (this.get('isSendingTest')) {
return;
}
this.set('isSendingTest', true);
invoke(this, 'save').then(() => {
this.get('ajax').post(slackApi).then(() => {
notifications.showAlert('Check your slack channel test message.', {type: 'info', key: 'slack-test.send.success'});
}).catch((error) => {
notifications.showAPIError(error, {key: 'slack-test:send'});
});
}).catch(() => {
// noop - error already handled in .save
}).finally(() => {
this.set('isSendingTest', false);
});
},
updateURL(value) {
this.set('model.url', value);
this.get('model.errors').clear();
},
save() {
let slack = this.get('model');
let settings = this.get('settings');
if (this.get('isSaving')) {
return;
}
return slack.validate().then(() => {
settings.get('slack').clear().pushObject(slack);
this.set('isSaving', true);
return settings.save().catch((err) => {
this.get('notifications').showErrors(err);
throw err;
}).finally(() => {
this.set('isSaving', false);
});
});
}
}
});

Some files were not shown because too many files have changed in this diff Show more