diff --git a/ghost/admin/app/components/gh-post-settings-menu.js b/ghost/admin/app/components/gh-post-settings-menu.js index d2643aa213..d661a59cc7 100644 --- a/ghost/admin/app/components/gh-post-settings-menu.js +++ b/ghost/admin/app/components/gh-post-settings-menu.js @@ -10,9 +10,12 @@ import moment from 'moment'; import {guidFor} from 'ember-metal/utils'; import {htmlSafe} from 'ember-string'; import {invokeAction} from 'ember-invoke-action'; +import {task, timeout} from 'ember-concurrency'; const {Handlebars} = Ember; +const PSM_ANIMATION_LENGTH = 400; + export default Component.extend(SettingsMenuMixin, { selectedAuthor: null, authors: [], @@ -31,6 +34,7 @@ export default Component.extend(SettingsMenuMixin, { metaDescriptionScratch: alias('model.metaDescriptionScratch'), _showSettingsMenu: false, + _showThrobbers: false, didReceiveAttrs() { this._super(...arguments); @@ -43,20 +47,37 @@ export default Component.extend(SettingsMenuMixin, { this.set('selectedAuthor', author); }); - // reset the publish date on close if it has an error + // HACK: ugly method of working around the CSS animations so that we + // can add throbbers only when the animation has finished + // TODO: use liquid-fire to handle PSM slide-in and replace tabs manager + // with something more ember-like + if (this.get('showSettingsMenu') && !this._showSettingsMenu) { + this.get('showThrobbers').perform(); + } + + // fired when menu is closed if (!this.get('showSettingsMenu') && this._showSettingsMenu) { let post = this.get('model'); let errors = post.get('errors'); + // reset the publish date if it has an error if (errors.has('publishedAtBlogDate') || errors.has('publishedAtBlogTime')) { post.set('publishedAtBlogTZ', post.get('publishedAtUTC')); post.validate({attribute: 'publishedAtBlog'}); } + + // remove throbbers + this.set('_showThrobbers', false); } this._showSettingsMenu = this.get('showSettingsMenu'); }, + showThrobbers: task(function* () { + yield timeout(PSM_ANIMATION_LENGTH); + this.set('_showThrobbers', true); + }).restartable(), + seoTitle: computed('model.titleScratch', 'metaTitleScratch', function () { let metaTitle = this.get('metaTitleScratch') || ''; @@ -133,6 +154,16 @@ export default Component.extend(SettingsMenuMixin, { }, actions: { + showSubview() { + this._super(...arguments); + this.set('_showThrobbers', false); + }, + + closeSubview() { + this._super(...arguments); + this.get('showThrobbers').perform(); + }, + discardEnter() { return false; }, diff --git a/ghost/admin/app/components/gh-tour-item.js b/ghost/admin/app/components/gh-tour-item.js new file mode 100644 index 0000000000..aa24b7c3c2 --- /dev/null +++ b/ghost/admin/app/components/gh-tour-item.js @@ -0,0 +1,171 @@ +import Component from 'ember-component'; +import computed, {reads} from 'ember-computed'; +import injectService from 'ember-service/inject'; +import run from 'ember-runloop'; +import {isBlank} from 'ember-utils'; + +let instancesCounter = 0; + +let triangleClassPositions = { + 'top-left': { + attachment: 'top left', + targetAttachment: 'bottom center', + offset: '0 28px' + }, + 'top': { + attachment: 'top center', + targetAttachment: 'bottom center' + }, + 'top-right': { + attachment: 'top right', + targetAttachment: 'bottom center', + offset: '0 -28px' + }, + 'right-top': { + attachment: 'top right', + targetAttachment: 'middle left', + offset: '28px 0' + }, + 'right': { + attachment: 'middle right', + targetAttachment: 'middle left' + }, + 'right-bottom': { + attachment: 'bottom right', + targetAttachment: 'middle left', + offset: '-28px 0' + }, + 'bottom-right': { + attachment: 'bottom right', + targetAttachment: 'top center', + offset: '0 -28px' + }, + 'bottom': { + attachment: 'bottom center', + targetAttachment: 'top center' + }, + 'bottom-left': { + attachment: 'bottom left', + targetAttachment: 'top center', + offset: '0 28px' + }, + 'left-bottom': { + attachment: 'bottom left', + targetAttachment: 'middle right', + offset: '-28px 0' + }, + 'left': { + attachment: 'middle left', + targetAttachment: 'middle right' + }, + 'left-top': { + attachment: 'top left', + targetAttachment: 'middle right', + offset: '28px 0' + } +}; + +const GhTourItemComponent = Component.extend({ + + mediaQueries: injectService(), + tour: injectService(), + + tagName: '', + + throbberId: null, + target: null, + throbberAttachment: 'middle center', + popoverTriangleClass: 'top', + isOpen: false, + + _elementId: null, + _throbber: null, + _throbberElementId: null, + _throbberElementSelector: null, + _popoverAttachment: null, + _popoverTargetAttachment: null, + _popoverOffset: null, + + isMobile: reads('mediaQueries.isMobile'), + isVisible: computed('isMobile', '_throbber', function () { + let isMobile = this.get('isMobile'); + let hasThrobber = !isBlank(this.get('_throbber')); + + return !isMobile && hasThrobber; + }), + + init() { + this._super(...arguments); + // this is a tagless component so we need to generate our own elementId + this._elementId = instancesCounter++; + this._throbberElementId = `throbber-${this._elementId}`; + this._throbberElementSelector = `#throbber-${this._elementId}`; + + this._handleOptOut = run.bind(this, this._remove); + this._handleViewed = run.bind(this, this._removeIfViewed); + + this.get('tour').on('optOut', this._handleOptOut); + this.get('tour').on('viewed', this._handleViewed); + }, + + didReceiveAttrs() { + let throbberId = this.get('throbberId'); + let throbber = this.get('tour').activeThrobber(throbberId); + let triangleClass = this.get('popoverTriangleClass'); + let popoverPositions = triangleClassPositions[triangleClass]; + + this._throbber = throbber; + this._popoverAttachment = popoverPositions.attachment; + this._popoverTargetAttachment = popoverPositions.targetAttachment; + this._popoverOffset = popoverPositions.offset; + }, + + willDestroyElement() { + this._super(...arguments); + this.get('tour').off('optOut', this._handleOptOut); + this.get('tour').off('viewed', this._handleOptOut); + }, + + _removeIfViewed(id) { + if (id === this.get('throbberId')) { + this._remove(); + } + }, + + _remove() { + this.set('_throbber', null); + }, + + _close() { + this.set('isOpen', false); + }, + + actions: { + open() { + this.set('isOpen', true); + }, + + close() { + this._close(); + }, + + markAsViewed() { + let throbberId = this.get('throbberId'); + this.get('tour').markThrobberAsViewed(throbberId); + this.set('_throbber', null); + this._close(); + }, + + optOut() { + this.get('tour').optOut(); + this.set('_throbber', null); + this._close(); + } + } +}); + +GhTourItemComponent.reopenClass({ + positionalParams: ['throbberId'] +}); + +export default GhTourItemComponent; diff --git a/ghost/admin/app/models/user.js b/ghost/admin/app/models/user.js index ae42621570..dc170c9fef 100644 --- a/ghost/admin/app/models/user.js +++ b/ghost/admin/app/models/user.js @@ -35,6 +35,7 @@ export default Model.extend(ValidationEngine, { count: attr('raw'), facebook: attr('facebook-url-user'), twitter: attr('twitter-url-user'), + tour: attr('json-string'), ghostPaths: injectService(), ajax: injectService(), diff --git a/ghost/admin/app/routes/application.js b/ghost/admin/app/routes/application.js index 15b4366ad0..a0b0057356 100644 --- a/ghost/admin/app/routes/application.js +++ b/ghost/admin/app/routes/application.js @@ -34,6 +34,7 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { notifications: injectService(), settings: injectService(), upgradeNotification: injectService(), + tour: injectService(), beforeModel() { return this.get('config').fetch(); @@ -64,12 +65,14 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { }); let settingsPromise = this.get('settings').fetch(); + let tourPromise = this.get('tour').fetchViewed(); // return the feature/settings load promises so that we block until // they are loaded to enable synchronous access everywhere return RSVP.all([ featurePromise, - settingsPromise + settingsPromise, + tourPromise ]); } }, diff --git a/ghost/admin/app/services/session.js b/ghost/admin/app/services/session.js index 41e8143f3d..570fd36f85 100644 --- a/ghost/admin/app/services/session.js +++ b/ghost/admin/app/services/session.js @@ -1,10 +1,12 @@ +import RSVP from 'rsvp'; import SessionService from 'ember-simple-auth/services/session'; import computed from 'ember-computed'; import injectService from 'ember-service/inject'; export default SessionService.extend({ - store: injectService(), feature: injectService(), + store: injectService(), + tour: injectService(), user: computed(function () { return this.get('store').queryRecord('user', {id: 'me'}); @@ -12,7 +14,13 @@ export default SessionService.extend({ authenticate() { return this._super(...arguments).then((authResult) => { - return this.get('feature').fetch().then(() => { + // TODO: remove duplication with application.afterModel + let preloadPromises = [ + this.get('feature').fetch(), + this.get('tour').fetchViewed() + ]; + + return RSVP.all(preloadPromises).then(() => { return authResult; }); }); diff --git a/ghost/admin/app/services/tour.js b/ghost/admin/app/services/tour.js new file mode 100644 index 0000000000..c6e46ef9ca --- /dev/null +++ b/ghost/admin/app/services/tour.js @@ -0,0 +1,143 @@ +import Evented from 'ember-evented'; +import RSVP from 'rsvp'; +import Service from 'ember-service'; +import computed from 'ember-computed'; +import injectService from 'ember-service/inject'; + +export default Service.extend(Evented, { + + ghostPaths: injectService(), + session: injectService(), + + // this service is responsible for managing tour item visibility and syncing + // the viewed state with the server + // + // tour items need to be centrally defined here so that we have a single + // source of truth for marking all tour items as viewed + // + // a {{gh-tour-item "unique-id"}} component can be inserted in any template, + // this will use the tour service to grab content and determine visibility + // with the component in control of rendering the throbber/controlling the + // modal - this allows the component lifecycle hooks to perform automatic + // display/cleanup when the relevant UI is visible. + + viewed: [], + + // IDs should **NOT** be changed if they have been part of a release unless + // the re-display of the throbber should be forced. In that case it may be + // useful to add a version number eg. `my-feature` -> `my-feature-v2`. + // Format is as follows: + // + // { + // id: 'test', + // title: 'This is a test', + // message: 'This is a test of our feature tour feature' + // } + // + // TODO: it may be better to keep this configuration elsewhere to keep the + // service clean. Eventually we'll want apps to be able to register their + // own throbbers and tour content + throbbers: [], + + init() { + let adminUrl = `${window.location.origin}${this.get('ghostPaths.url').admin()}`; + let adminDisplayUrl = adminUrl.replace(`${window.location.protocol}//`, ''); + + this.throbbers = [{ + id: 'getting-started', + title: 'Getting started with Ghost', + message: `This is your admin area! You'll find all of your content, users and settings right here. You can come back any time by visiting ${adminDisplayUrl}` + }, { + id: 'using-the-editor', + title: 'Using the Ghost editor', + message: 'Ghost uses Markdown to allow you to write and format content quickly and easily. This toolbar also helps! Hit the ? icon for more editor shortcuts.' + }, { + id: 'static-post', + title: 'Turning posts into pages', + message: 'Static pages are permanent pieces of content which live outside of your usual stream of posts, for example and \'about\' or \'contact\' page.' + }, { + id: 'featured-post', + title: 'Setting a featured post', + message: 'Depending on your theme, featured posts receive special styling to make them stand out as a particularly important or emphasised story.' + }, { + id: 'upload-a-theme', + title: 'Customising your publication', + message: 'Using custom themes, you can completely control the look and feel of your site to suit your branch. Here\'s a full guide to help: https://themes.ghost.org' + }]; + }, + + _activeThrobbers: computed('viewed.[]', 'throbbers.[]', function () { + // return throbbers that haven't been viewed + let viewed = this.get('viewed'); + let throbbers = this.get('throbbers'); + + return throbbers.reject((throbber) => { + return viewed.includes(throbber.id); + }); + }), + + // retrieve the IDs of the viewed throbbers from the server, always returns + // a promise + fetchViewed() { + return this.get('session.user').then((user) => { + let viewed = user.get('tour') || []; + + this.set('viewed', viewed); + + return viewed; + }); + }, + + // save the list of viewed throbbers to the server overwriting the + // entire list + syncViewed() { + let viewed = this.get('viewed'); + + return this.get('session.user').then((user) => { + user.set('tour', viewed); + + return user.save(); + }); + }, + + // returns throbber content for a given ID only if that throbber hasn't been + // viewed. Used by the {{gh-tour-item}} component to determine visibility + activeThrobber(id) { + let activeThrobbers = this.get('_activeThrobbers'); + return activeThrobbers.findBy('id', id); + }, + + // when a throbber is opened the component will call this method to mark + // it as viewed and sync with the server. Always returns a promise + markThrobberAsViewed(id) { + let viewed = this.get('viewed'); + + if (!viewed.includes(id)) { + this.get('viewed').push(id); + this.trigger('viewed', id); + return this.syncViewed(); + } else { + return RSVP.resolve(true); + } + }, + + // opting-out will use the list of IDs defined in this file making it the + // single-source-of-truth and allowing future client updates to control when + // new UI should be surfaced through tour items + optOut() { + let allThrobberIds = this.get('throbbers').mapBy('id'); + + this.set('viewed', allThrobberIds); + this.trigger('optOut'); + + return this.syncViewed(); + }, + + // this is not used anywhere at the moment but it's useful to use via ember + // inspector as a reset mechanism + reEnable() { + this.set('viewed', []); + return this.syncViewed(); + } + +}); diff --git a/ghost/admin/app/styles/app-dark.css b/ghost/admin/app/styles/app-dark.css index 6489c81b19..9781df7af1 100644 --- a/ghost/admin/app/styles/app-dark.css +++ b/ghost/admin/app/styles/app-dark.css @@ -14,6 +14,7 @@ @import "patterns/tables.css"; @import "patterns/navlist.css"; @import "patterns/boxes.css"; +@import "patterns/throbber.css"; /* Components: Groups of Patterns @@ -31,6 +32,8 @@ @import "components/power-select.css"; @import "components/power-calendar.css"; @import "components/publishmenu.css"; +@import "components/popovers.css"; +@import "components/tour.css"; /* Layouts: Groups of Components diff --git a/ghost/admin/app/styles/app.css b/ghost/admin/app/styles/app.css index 1573818151..07f75614e9 100644 --- a/ghost/admin/app/styles/app.css +++ b/ghost/admin/app/styles/app.css @@ -14,6 +14,7 @@ @import "patterns/tables.css"; @import "patterns/navlist.css"; @import "patterns/boxes.css"; +@import "patterns/throbber.css"; /* Components: Groups of Patterns @@ -31,6 +32,8 @@ @import "components/power-select.css"; @import "components/power-calendar.css"; @import "components/publishmenu.css"; +@import "components/popovers.css"; +@import "components/tour.css"; /* Layouts: Groups of Components diff --git a/ghost/admin/app/styles/components/popovers.css b/ghost/admin/app/styles/components/popovers.css new file mode 100644 index 0000000000..1c775b927d --- /dev/null +++ b/ghost/admin/app/styles/components/popovers.css @@ -0,0 +1,226 @@ +/* Popovers +/* ---------------------------------------------------------- */ + +.popover-item { + position: relative; + display: inline-block; + padding: 11px 26px 13px 16px; + min-width: 300px; + max-width: 400px; + background: var(--darkgrey); + border-radius: 6px; + color: var(--midgrey); + font-size: 1.2rem; +} + +.popover-title { + color: #fff; + font-size: 1.4rem; + font-weight: 300; +} + +.popover-desc { + margin-top: -4px; +} + +.popover-body { + margin-top: 11px; + line-height: 1.7; +} + +.popover-body b { + color: #fff; +} + +.popover-body > *:last-child { + margin: 0; +} + + +/* Open / Close +/* ---------------------------------------------------------- */ + +.popover { + position: relative; + display: inline-block; +} + +.popover .popover-item { + position: absolute; + z-index: 20; +} + +.popover .popover-item.open { + display: block; +} + +.popover .popover-item.closed { + display: none; +} + +.popover-triangle-top { + transform-origin: top center; +} + +.popover-triangle-top-left { + transform-origin: top left; +} + +.popover-triangle-top-right { + transform-origin: top right; +} + +.popover-triangle-bottom { + transform-origin: bottom center; +} + +.popover-triangle-bottom-left { + transform-origin: bottom left; +} + +.popover-triangle-bottom-right { + transform-origin: bottom right; +} + +.popover-triangle-left { + transform-origin: left center; +} + +.popover-triangle-left-top { + transform-origin: left top; +} + +.popover-triangle-left-bottom { + transform-origin: left bottom; +} + +.popover-triangle-right { + transform-origin: right center; +} + +.popover-triangle-right-top { + transform-origin: right top; +} + +.popover-triangle-right-bottom { + transform-origin: right bottom; +} + +.popover-triangle-top:before, +.popover-triangle-top:after, +.popover-triangle-top-left:before, +.popover-triangle-top-left:after, +.popover-triangle-top-right:before, +.popover-triangle-top-right:after, +.popover-triangle-bottom:before, +.popover-triangle-bottom:after, +.popover-triangle-bottom-left:before, +.popover-triangle-bottom-left:after, +.popover-triangle-bottom-right:before, +.popover-triangle-bottom-right:after, +.popover-triangle-left:before, +.popover-triangle-left:after, +.popover-triangle-left-top:before, +.popover-triangle-left-top:after, +.popover-triangle-left-bottom:before, +.popover-triangle-left-bottom:after, +.popover-triangle-right:before, +.popover-triangle-right:after, +.popover-triangle-right-top:before, +.popover-triangle-right-top:after, +.popover-triangle-right-bottom:before, +.popover-triangle-right-bottom:after { + content: ""; + position: absolute; + display: block; +} + +.popover-triangle-top:before, +.popover-triangle-top:after, +.popover-triangle-bottom:before, +.popover-triangle-bottom:after { + left: 50%; + margin-left: -14px; +} + +.popover-triangle-top-left:before, +.popover-triangle-top-left:after, +.popover-triangle-bottom-left:before, +.popover-triangle-bottom-left:after { + left: 14px; +} + +.popover-triangle-top-right:before, +.popover-triangle-top-right:after, +.popover-triangle-bottom-right:before, +.popover-triangle-bottom-right:after { + right: 14px; + left: auto; +} + +.popover-triangle-top:before, +.popover-triangle-top-left:before, +.popover-triangle-top-right:before { + top: calc(-14px * 0.8); + width: 0; + height: 0; + border-right: 14px solid transparent; + border-bottom: calc(14px * 0.8) solid #242628; + border-left: 14px solid transparent; +} + +.popover-triangle-bottom:before, +.popover-triangle-bottom-left:before, +.popover-triangle-bottom-right:before { + bottom: calc(-14px * 0.8); + width: 0; + height: 0; + border-top: calc(14px * 0.8) solid #242628; + border-right: 14px solid transparent; + border-left: 14px solid transparent; +} + +.popover-triangle-left:before, +.popover-triangle-left:after, +.popover-triangle-right:before, +.popover-triangle-right:after { + top: 50%; + margin-top: -14px; +} + +.popover-triangle-left-top:before, +.popover-triangle-left-top:after, +.popover-triangle-right-top:before, +.popover-triangle-right-top:after { + top: 14px; +} + +.popover-triangle-left-bottom:before, +.popover-triangle-left-bottom:after, +.popover-triangle-right-bottom:before, +.popover-triangle-right-bottom:after { + top: auto; + bottom: 14px; +} + +.popover-triangle-left:before, +.popover-triangle-left-top:before, +.popover-triangle-left-bottom:before { + left: calc(-14px * 0.8); + width: 0; + height: 0; + border-top: 14px solid transparent; + border-right: calc(14px * 0.8) solid #242628; + border-bottom: 14px solid transparent; +} + +.popover-triangle-right:before, +.popover-triangle-right-top:before, +.popover-triangle-right-bottom:before { + right: calc(-14px * 0.8); + width: 0; + height: 0; + border-top: 14px solid transparent; + border-bottom: 14px solid transparent; + border-left: calc(14px * 0.8) solid #242628; +} diff --git a/ghost/admin/app/styles/components/tour.css b/ghost/admin/app/styles/components/tour.css new file mode 100644 index 0000000000..26382dd2fc --- /dev/null +++ b/ghost/admin/app/styles/components/tour.css @@ -0,0 +1,134 @@ +/* ------------------------------------------------------------ + Popovers + + Styles for the popover component + * Popovers + * Open/Close + * Positioning + * Triangles classes + ------------------------------------------------------------ */ + + .throbber-container { + z-index: 998; + } + +.tour-background { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 999; + background: rgba(54,83,109,0.08); +} + +.tour.liquid-wormhole-element { + z-index: 999; +} + +.tour .popover-item { + padding: 25px; + max-width: none; + width: 480px; + border: #bfbfbf 1px solid; + background: #fff; + box-shadow: rgba(0,0,0,0.15) 0 1px 8px; + color: var(--midgrey); +} + +.tour .popover-title { + margin-bottom: 0.4em; + color: var(--darkgrey); + font-size: 1.8rem; + font-weight: bold; +} + +.tour .popover-desc { + margin: 0; +} + +.tour .popover-body { + margin: 0; + font-size: 1.5rem; + line-height: 1.55em; +} + +.tour .popover-foot { + display: flex; + justify-content: space-between; + margin-top: 25px; +} + +.tour-optout { + align-self: flex-end; +} + +/* 'dem Triangles */ + +.tour .popover-triangle-top:before, +.tour .popover-triangle-top-left:before, +.tour .popover-triangle-top-right:before { + border-right: 14px solid transparent; + border-bottom: calc(14px * 0.8) solid #bfbfbf; + border-left: 14px solid transparent; +} + +.tour .popover-triangle-top:after, +.tour .popover-triangle-top-left:after, +.tour .popover-triangle-top-right:after { + top: -10px; + border-right: 14px solid transparent; + border-bottom: calc(14px * 0.8) solid #fff; + border-left: 14px solid transparent; +} + +.tour .popover-triangle-bottom:before, +.tour .popover-triangle-bottom-left:before, +.tour .popover-triangle-bottom-right:before { + border-top: calc(14px * 0.8) solid #bfbfbf; + border-right: 14px solid transparent; + border-left: 14px solid transparent; +} + +.tour .popover-triangle-bottom:after, +.tour .popover-triangle-bottom-left:after, +.tour .popover-triangle-bottom-right:after { + bottom: -10px; + border-top: calc(14px * 0.8) solid #fff; + border-right: 14px solid transparent; + border-left: 14px solid transparent; +} + +.tour .popover-triangle-left:before, +.tour .popover-triangle-left-top:before, +.tour .popover-triangle-left-bottom:before { + border-top: 14px solid transparent; + border-right: calc(14px * 0.8) solid #bfbfbf; + border-bottom: 14px solid transparent; +} + +.tour .popover-triangle-left:after, +.tour .popover-triangle-left-top:after, +.tour .popover-triangle-left-bottom:after { + left: -10px; + border-top: 14px solid transparent; + border-right: calc(14px * 0.8) solid #fff; + border-bottom: 14px solid transparent; +} + +.tour .popover-triangle-right:before, +.tour .popover-triangle-right-top:before, +.tour .popover-triangle-right-bottom:before { + border-top: 14px solid transparent; + border-bottom: 14px solid transparent; + border-left: calc(14px * 0.8) solid #bfbfbf; +} + +.tour .popover-triangle-right:after, +.tour .popover-triangle-right-top:after, +.tour .popover-triangle-right-bottom:after { + right: -10px; + border-top: 14px solid transparent; + border-bottom: 14px solid transparent; + border-left: calc(14px * 0.8) solid #fff; +} diff --git a/ghost/admin/app/styles/patterns/throbber.css b/ghost/admin/app/styles/patterns/throbber.css new file mode 100644 index 0000000000..acbcca44b7 --- /dev/null +++ b/ghost/admin/app/styles/patterns/throbber.css @@ -0,0 +1,75 @@ +/* ------------------------------------------------------------ + Throbber + + Pulsing little circle which indicates a tour is available + ------------------------------------------------------------ */ + +/* click-area for triggering the popover */ +.throbber-trigger { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; +} + +.throbber { + position: relative; + display: block; + width: 14px; + height: 14px; + border: rgba(255,255,255,0.3) 2px solid; + background: rgba(255,255,255,0.6); + border-radius: 100%; + box-shadow: rgba(0,0,0,0.25) 0 0 0 2px; + animation-name: throbber-fade; + animation-duration: 1.2s; + animation-timing-function: ease-out; + animation-iteration-count: infinite; + animation-fill-mode: forwards; +} + +.throbber:before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + display: block; + margin: -13px 0 0 -13px; + width: 26px; + height: 26px; + border: rgba(255,255,255,0.4) 2px solid; + background: rgba(255,255,255,0.2); + border-radius: 100%; + box-shadow: rgba(0,0,0,0.15) 0 0 0 2px; + animation-name: throbber-pulse, throbber-fade; + animation-duration: 1.2s; + animation-timing-function: ease-out; + animation-iteration-count: infinite; + animation-fill-mode: forwards; + pointer-events: none; +} + +@keyframes throbber-fade { + 0%, + 100% { + opacity: 0; + } + 40%, + 60% { + opacity: 0.8; + } +} + +@keyframes throbber-pulse { + 0% { + transform: scale3d(0.5, 0.5, 1); + } + 50% { + transform: scale3d(1.4, 1.4, 1); + } + 100% { + transform: scale3d(0.5, 0.5, 1); + } +} diff --git a/ghost/admin/app/templates/components/gh-nav-menu.hbs b/ghost/admin/app/templates/components/gh-nav-menu.hbs index 2ba4ce84b4..8d472869a4 100644 --- a/ghost/admin/app/templates/components/gh-nav-menu.hbs +++ b/ghost/admin/app/templates/components/gh-nav-menu.hbs @@ -60,3 +60,9 @@ View site {{inline-svg "external"}}
+ +{{gh-tour-item "getting-started" + target=".gh-menu-toggle" + throbberAttachment="bottom middle" + popoverTriangleClass="left-top" +}} diff --git a/ghost/admin/app/templates/components/gh-post-settings-menu.hbs b/ghost/admin/app/templates/components/gh-post-settings-menu.hbs index 15c7bc1a98..fd14d04e31 100644 --- a/ghost/admin/app/templates/components/gh-post-settings-menu.hbs +++ b/ghost/admin/app/templates/components/gh-post-settings-menu.hbs @@ -163,3 +163,24 @@ {{/gh-tabs-manager}} + +{{!-- + _showThrobbers is on a timer so that throbbers don't get positioned until + the slide-in animation has finished and it gets toggled when the meta + pane is shown +--}} +{{#if _showThrobbers}} + {{gh-tour-item "static-post" + target="label[for='static-page'] p" + throbberAttachment="middle middle" + throbberOffset="0px 75px" + popoverTriangleClass="bottom-right" + }} + + {{gh-tour-item "featured-post" + target="label[for='featured'] p" + throbberAttachment="middle middle" + throbberOffset="0px -20px" + popoverTriangleClass="bottom-right" + }} +{{/if}} diff --git a/ghost/admin/app/templates/components/gh-tour-item.hbs b/ghost/admin/app/templates/components/gh-tour-item.hbs new file mode 100644 index 0000000000..13dca127ba --- /dev/null +++ b/ghost/admin/app/templates/components/gh-tour-item.hbs @@ -0,0 +1,43 @@ +{{#if isVisible}} + {{!-- tether the throbber --}} + {{#liquid-tether + class="throbber-container" + target=target + attachment="middle center" + targetAttachment=throbberAttachment + targetOffset=throbberOffset + }} + + + + {{/liquid-tether}} + + {{#if isOpen}} + {{!-- wormhole the background click overlay --}} + {{#liquid-wormhole class="tour-container"}} +
+ {{/liquid-wormhole}} + + {{!-- tether the popover --}} + {{#liquid-tether + class="tour" + target=_throbberElementSelector + attachment=_popoverAttachment + targetAttachment=_popoverTargetAttachment + offset=_popoverOffset + }} +
+

{{_throbber.title}}

+
+ {{{_throbber.message}}} +
+ +
+ {{/liquid-tether}} + {{/if}} +{{/if}} diff --git a/ghost/admin/app/templates/editor/edit.hbs b/ghost/admin/app/templates/editor/edit.hbs index 8f9e932a8a..19bb7c917c 100644 --- a/ghost/admin/app/templates/editor/edit.hbs +++ b/ghost/admin/app/templates/editor/edit.hbs @@ -73,6 +73,13 @@
{{/if}} + + {{gh-tour-item "using-the-editor" + target=".gh-editor-footer" + throbberAttachment="top left" + throbberOffset="0 20%" + popoverTriangleClass="bottom" + }} {{/gh-markdown-editor}} {{!-- TODO: put tool/status bar in here so that scroll area can be fixed --}} diff --git a/ghost/admin/app/templates/settings/design.hbs b/ghost/admin/app/templates/settings/design.hbs index 87fc371109..5587e94568 100644 --- a/ghost/admin/app/templates/settings/design.hbs +++ b/ghost/admin/app/templates/settings/design.hbs @@ -68,3 +68,9 @@ {{outlet}} + +{{gh-tour-item "upload-a-theme" + target=".gh-themes-uploadbtn" + throbberAttachment="top middle" + popoverTriangleClass="bottom" +}} diff --git a/ghost/admin/app/transitions.js b/ghost/admin/app/transitions.js index d67919ebb4..ebe6721f79 100644 --- a/ghost/admin/app/transitions.js +++ b/ghost/admin/app/transitions.js @@ -10,4 +10,18 @@ export default function () { this.hasClass('fade-transition'), this.use('crossFade', {duration: 100}) ); + + this.transition( + this.hasClass('tour-container'), + this.toValue(true), + this.use('fade', {duration: 150}), + this.reverse('fade', {duration: 150}) + ); + + this.transition( + this.hasClass('tour'), + this.toValue(true), + this.use('fade', {duration: 300}), + this.reverse('fade', {duration: 300}) + ); } diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 1e23bead90..0262c43f89 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -98,6 +98,7 @@ "grunt-shell": "2.1.0", "jquery-deparam": "0.5.3", "liquid-fire": "0.27.3", + "liquid-tether": "2.0.4", "liquid-wormhole": "2.0.5", "loader.js": "4.4.0", "markdown-it": "8.3.1", diff --git a/ghost/admin/tests/unit/models/invite-test.js b/ghost/admin/tests/unit/models/invite-test.js index faa909dc0b..7ba7045199 100644 --- a/ghost/admin/tests/unit/models/invite-test.js +++ b/ghost/admin/tests/unit/models/invite-test.js @@ -14,7 +14,8 @@ describe('Unit: Model: invite', function() { 'service:ghost-paths', 'service:ajax', 'service:session', - 'service:feature' + 'service:feature', + 'service:tour' ] }); diff --git a/ghost/admin/tests/unit/serializers/user-test.js b/ghost/admin/tests/unit/serializers/user-test.js index 36471d9fe8..12b6e961bd 100644 --- a/ghost/admin/tests/unit/serializers/user-test.js +++ b/ghost/admin/tests/unit/serializers/user-test.js @@ -13,6 +13,7 @@ describe('Unit: Serializer: user', function() { 'service:notifications', 'service:session', 'transform:facebook-url-user', + 'transform:json-string', 'transform:moment-utc', 'transform:raw', 'transform:twitter-url-user' diff --git a/ghost/admin/yarn.lock b/ghost/admin/yarn.lock index a211d6bdee..28d9d2699c 100644 --- a/ghost/admin/yarn.lock +++ b/ghost/admin/yarn.lock @@ -1448,7 +1448,7 @@ broccoli-merge-trees@2.0.0, broccoli-merge-trees@^2.0.0: broccoli-plugin "^1.3.0" merge-trees "^1.0.1" -broccoli-merge-trees@^1.0.0, broccoli-merge-trees@^1.1.0, broccoli-merge-trees@^1.1.1, broccoli-merge-trees@^1.1.2, broccoli-merge-trees@^1.1.4, broccoli-merge-trees@^1.1.5, broccoli-merge-trees@^1.2.1: +broccoli-merge-trees@^1.0.0, broccoli-merge-trees@^1.1.0, broccoli-merge-trees@^1.1.1, broccoli-merge-trees@^1.1.2, broccoli-merge-trees@^1.1.4, broccoli-merge-trees@^1.1.5, broccoli-merge-trees@^1.2.1, broccoli-merge-trees@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/broccoli-merge-trees/-/broccoli-merge-trees-1.2.4.tgz#a001519bb5067f06589d91afa2942445a2d0fdb5" dependencies: @@ -5203,14 +5203,14 @@ js-tokens@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7" -js-yaml@3.6.1, js-yaml@3.x, js-yaml@^3.2.5, js-yaml@^3.6.1, js-yaml@~3.6.0: +js-yaml@3.6.1, js-yaml@3.x, js-yaml@^3.2.5, js-yaml@^3.5.1, js-yaml@^3.6.1, js-yaml@~3.6.0: version "3.6.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.6.1.tgz#6e5fe67d8b205ce4d22fad05b7781e8dadcc4b30" dependencies: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.2.7, js-yaml@^3.5.1, js-yaml@~3.7.0: +js-yaml@^3.2.7, js-yaml@~3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" dependencies: @@ -5391,7 +5391,18 @@ liquid-fire@0.27.3: match-media "^0.2.0" velocity-animate ">= 0.11.8" -liquid-wormhole@2.0.5: +liquid-tether@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/liquid-tether/-/liquid-tether-2.0.4.tgz#6abcb53c3b3750d2b714a875f83de362c06e528a" + dependencies: + broccoli-funnel "^1.1.0" + broccoli-merge-trees "^1.2.4" + ember-cli-babel "^5.1.7" + ember-cli-htmlbars "^1.0.10" + liquid-wormhole "^2.0.2" + tether "^1.4.0" + +liquid-wormhole@2.0.5, liquid-wormhole@^2.0.2: version "2.0.5" resolved "https://registry.yarnpkg.com/liquid-wormhole/-/liquid-wormhole-2.0.5.tgz#70d346892aff649945645848962209fffb331740" dependencies: @@ -7879,6 +7890,10 @@ testem@^1.15.0: tap-parser "^5.1.0" xmldom "^0.1.19" +tether@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/tether/-/tether-1.4.0.tgz#0f9fa171f75bf58485d8149e94799d7ae74d1c1a" + text-encoding@0.6.4: version "0.6.4" resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"