mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-25 09:03:12 +03:00
✨ welcome tour (#527)
refs https://github.com/TryGhost/Ghost/issues/5168 - adds a `tour` service that handles syncing and management of tour throbbers & content - adds a `gh-tour-item` component that handles the display of a throbber and it's associated popover when clicked - uses settings API endpoint to populate viewed tour items on app boot/signin - adds `liquid-tether@2.0.3` dependency for attaching throbbers and popups - adds initial tour contents
This commit is contained in:
parent
1288d58f18
commit
05a3a11855
@ -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;
|
||||
},
|
||||
|
171
ghost/admin/app/components/gh-tour-item.js
Normal file
171
ghost/admin/app/components/gh-tour-item.js
Normal file
@ -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;
|
@ -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(),
|
||||
|
@ -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
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
143
ghost/admin/app/services/tour.js
Normal file
143
ghost/admin/app/services/tour.js
Normal file
@ -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 <strong>feature tour</strong> 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 <strong><a href="${adminUrl}" target="_blank">${adminDisplayUrl}</a></strong>`
|
||||
}, {
|
||||
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 <strong>?</strong> 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: <strong><a href="https://themes.ghost.org" target="_blank">https://themes.ghost.org</a></strong>'
|
||||
}];
|
||||
},
|
||||
|
||||
_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();
|
||||
}
|
||||
|
||||
});
|
@ -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
|
||||
|
@ -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
|
||||
|
226
ghost/admin/app/styles/components/popovers.css
Normal file
226
ghost/admin/app/styles/components/popovers.css
Normal file
@ -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;
|
||||
}
|
134
ghost/admin/app/styles/components/tour.css
Normal file
134
ghost/admin/app/styles/components/tour.css
Normal file
@ -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;
|
||||
}
|
75
ghost/admin/app/styles/patterns/throbber.css
Normal file
75
ghost/admin/app/styles/patterns/throbber.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
@ -60,3 +60,9 @@
|
||||
<a class="gh-nav-foot-sitelink" href="{{config.blogUrl}}/" target="_blank">View site {{inline-svg "external"}}</a>
|
||||
</footer>
|
||||
<div class="gh-autonav-toggle" {{action "openAutoNav" on="mouseEnter"}}></div>
|
||||
|
||||
{{gh-tour-item "getting-started"
|
||||
target=".gh-menu-toggle"
|
||||
throbberAttachment="bottom middle"
|
||||
popoverTriangleClass="left-top"
|
||||
}}
|
||||
|
@ -163,3 +163,24 @@
|
||||
</div>
|
||||
</div>
|
||||
{{/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}}
|
||||
|
43
ghost/admin/app/templates/components/gh-tour-item.hbs
Normal file
43
ghost/admin/app/templates/components/gh-tour-item.hbs
Normal file
@ -0,0 +1,43 @@
|
||||
{{#if isVisible}}
|
||||
{{!-- tether the throbber --}}
|
||||
{{#liquid-tether
|
||||
class="throbber-container"
|
||||
target=target
|
||||
attachment="middle center"
|
||||
targetAttachment=throbberAttachment
|
||||
targetOffset=throbberOffset
|
||||
}}
|
||||
<a class="throbber-trigger" href="#" {{action "open"}} id={{_throbberElementId}}>
|
||||
<span class="throbber"></span>
|
||||
</a>
|
||||
{{/liquid-tether}}
|
||||
|
||||
{{#if isOpen}}
|
||||
{{!-- wormhole the background click overlay --}}
|
||||
{{#liquid-wormhole class="tour-container"}}
|
||||
<div class="tour-background" {{action "close" on="click"}}></div>
|
||||
{{/liquid-wormhole}}
|
||||
|
||||
{{!-- tether the popover --}}
|
||||
{{#liquid-tether
|
||||
class="tour"
|
||||
target=_throbberElementSelector
|
||||
attachment=_popoverAttachment
|
||||
targetAttachment=_popoverTargetAttachment
|
||||
offset=_popoverOffset
|
||||
}}
|
||||
<div class="popover-item popover-triangle-{{popoverTriangleClass}}">
|
||||
<h3 class="popover-title">{{_throbber.title}}</h3>
|
||||
<div class="popover-body">
|
||||
{{{_throbber.message}}}
|
||||
</div>
|
||||
<footer class="popover-foot">
|
||||
<small class="tour-optout">
|
||||
Not your first time? <a href="#" {{action 'optOut'}}>Skip these tips</a>
|
||||
</small>
|
||||
<a class="tour-dismiss btn btn-blue" href="#" {{action 'markAsViewed'}}>Ok, got it</a>
|
||||
</footer>
|
||||
</div>
|
||||
{{/liquid-tether}}
|
||||
{{/if}}
|
||||
{{/if}}
|
@ -73,6 +73,13 @@
|
||||
<div class="gh-markdown-editor-preview-content"></div>
|
||||
</div>
|
||||
{{/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 --}}
|
||||
|
@ -68,3 +68,9 @@
|
||||
</section>
|
||||
|
||||
{{outlet}}
|
||||
|
||||
{{gh-tour-item "upload-a-theme"
|
||||
target=".gh-themes-uploadbtn"
|
||||
throbberAttachment="top middle"
|
||||
popoverTriangleClass="bottom"
|
||||
}}
|
||||
|
@ -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})
|
||||
);
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -14,7 +14,8 @@ describe('Unit: Model: invite', function() {
|
||||
'service:ghost-paths',
|
||||
'service:ajax',
|
||||
'service:session',
|
||||
'service:feature'
|
||||
'service:feature',
|
||||
'service:tour'
|
||||
]
|
||||
});
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user