diff --git a/app/components/gh-tag.js b/app/components/gh-tag.js deleted file mode 100644 index 041ba0ad1..000000000 --- a/app/components/gh-tag.js +++ /dev/null @@ -1,5 +0,0 @@ -import Component from '@ember/component'; - -export default Component.extend({ - tagName: '' -}); diff --git a/app/components/gh-tags-list-item.js b/app/components/gh-tags-list-item.js new file mode 100644 index 000000000..8320f3892 --- /dev/null +++ b/app/components/gh-tags-list-item.js @@ -0,0 +1,38 @@ +import Component from '@ember/component'; +import {alias} from '@ember/object/computed'; +import {computed} from '@ember/object'; +import {inject as service} from '@ember/service'; + +export default Component.extend({ + ghostPaths: service(), + notifications: service(), + router: service(), + + tagName: 'li', + classNames: ['gh-list-row', 'gh-tags-list-item'], + + active: false, + + id: alias('tag.id'), + slug: alias('tag.slug'), + name: alias('tag.name'), + isInternal: alias('tag.isInternal'), + description: alias('tag.description'), + postsCount: alias('tag.count.posts'), + postsLabel: computed('tag.count.posts', function () { + let noOfPosts = this.postsCount || 0; + return (noOfPosts === 1) ? `${noOfPosts} post` : `${noOfPosts} posts`; + }), + + _deleteTag() { + let tag = this.tag; + + return tag.destroyRecord().then(() => {}, (error) => { + this._deleteTagFailure(error); + }); + }, + + _deleteTagFailure(error) { + this.notifications.showAPIError(error, {key: 'tag.delete'}); + } +}); diff --git a/app/controllers/tags.js b/app/controllers/tags.js index 612939f9e..3c0edb0c4 100644 --- a/app/controllers/tags.js +++ b/app/controllers/tags.js @@ -1,20 +1,20 @@ import Controller, {inject as controller} from '@ember/controller'; -import {alias, equal, sort} from '@ember/object/computed'; +import {alias, sort} from '@ember/object/computed'; import {computed} from '@ember/object'; -import {run} from '@ember/runloop'; export default Controller.extend({ tagController: controller('tags.tag'), + queryParams: ['type'], + type: 'public', tags: alias('model'), selectedTag: alias('tagController.tag'), - tagListFocused: equal('keyboardFocus', 'tagList'), - tagContentFocused: equal('keyboardFocus', 'tagContent'), - - filteredTags: computed('tags.@each.isNew', function () { - return this.tags.filterBy('isNew', false); + filteredTags: computed('tags.@each.isNew', 'type', function () { + return this.tags.filter((tag) => { + return (!tag.isNew && (!this.type || tag.visibility === this.type)); + }); }), // tags are sorted by name @@ -24,37 +24,8 @@ export default Controller.extend({ }), actions: { - leftMobile() { - let firstTag = this.get('tags.firstObject'); - // redirect to first tag if possible so that you're not left with - // tag settings blank slate when switching from portrait to landscape - if (firstTag && !this.get('tagController.tag')) { - this.transitionToRoute('tags.tag', firstTag); - } + changeType(type) { + this.set('type', type); } - }, - - scrollTagIntoView(tag) { - run.scheduleOnce('afterRender', this, function () { - let id = `#gh-tag-${tag.get('id')}`; - let element = document.querySelector(id); - - if (element) { - let scroll = document.querySelector('.tag-list'); - let {scrollTop} = scroll; - let scrollHeight = scroll.offsetHeight; - let element = document.querySelector(id); - let elementTop = element.offsetTop; - let elementHeight = element.offsetHeight; - - if (elementTop < scrollTop) { - element.scrollIntoView(true); - } - - if (elementTop + elementHeight > scrollTop + scrollHeight) { - element.scrollIntoView(false); - } - } - }); } }); diff --git a/app/controllers/tags/tag.js b/app/controllers/tags/tag.js index f6d3c5c7a..1d37c0b82 100644 --- a/app/controllers/tags/tag.js +++ b/app/controllers/tags/tag.js @@ -2,6 +2,8 @@ import Controller, {inject as controller} from '@ember/controller'; import windowProxy from 'ghost-admin/utils/window-proxy'; import {alias} from '@ember/object/computed'; import {inject as service} from '@ember/service'; +import {slugify} from '@tryghost/string'; +import {task} from 'ember-concurrency'; export default Controller.extend({ tagsController: controller('tags'), @@ -24,6 +26,47 @@ export default Controller.extend({ deleteTag() { return this._deleteTag(); + }, + save() { + return this.save.perform(); + }, + + toggleUnsavedChangesModal(transition) { + let leaveTransition = this.leaveScreenTransition; + + if (!transition && this.showUnsavedChangesModal) { + this.set('leaveScreenTransition', null); + this.set('showUnsavedChangesModal', false); + return; + } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.set('leaveScreenTransition', transition); + + // if a save is running, wait for it to finish then transition + if (this.save.isRunning) { + return this.save.last.then(() => { + transition.retry(); + }); + } + + // we genuinely have unsaved data, show the modal + this.set('showUnsavedChangesModal', true); + } + }, + + leaveScreen() { + let transition = this.leaveScreenTransition; + + if (!transition) { + this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); + return; + } + + // roll back changes on model props + this.tag.rollbackAttributes(); + + return transition.retry(); } }, @@ -42,29 +85,43 @@ export default Controller.extend({ } tag.set(propKey, newValue); + + // Generate slug based on name for new tag when empty + if (propKey === 'name' && !tag.get('slug') && isNewTag) { + let slugValue = slugify(newValue); + tag.set('slug', slugValue); + } // TODO: This is required until .validate/.save mark fields as validated tag.get('hasValidated').addObject(propKey); + }, - tag.save().then((savedTag) => { + save: task(function* () { + let tag = this.tag; + let isNewTag = tag.get('isNew'); + try { + let savedTag = yield tag.save(); // replace 'new' route with 'tag' route this.replaceRoute('tags.tag', savedTag); // update the URL if the slug changed - if (propKey === 'slug' && !isNewTag) { + if (!isNewTag) { let currentPath = window.location.hash; let newPath = currentPath.split('/'); - newPath[newPath.length - 1] = savedTag.get('slug'); - newPath = newPath.join('/'); + if (newPath[newPath.length - 1] !== savedTag.get('slug')) { + newPath[newPath.length - 1] = savedTag.get('slug'); + newPath = newPath.join('/'); - windowProxy.replaceState({path: newPath}, '', newPath); + windowProxy.replaceState({path: newPath}, '', newPath); + } } - }).catch((error) => { + return savedTag; + } catch (error) { if (error) { this.notifications.showAPIError(error, {key: 'tag.save'}); } - }); - }, + } + }), _deleteTag() { let tag = this.tag; diff --git a/app/routes/tags.js b/app/routes/tags.js index bc50c6492..25eb9dd1a 100644 --- a/app/routes/tags.js +++ b/app/routes/tags.js @@ -1,20 +1,20 @@ -/* global key */ -import $ from 'jquery'; import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route'; export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { + queryParams: { + type: { + refreshModel: true, + replace: true + } + }, shortcuts: null, init() { this._super(...arguments); this.shortcuts = { - 'up, k': 'moveUp', - 'down, j': 'moveDown', - left: 'focusList', - right: 'focusContent', c: 'newTag' }; }, @@ -33,7 +33,6 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { model() { let promise = this.store.query('tag', {limit: 'all', include: 'count.posts'}); let tags = this.store.peekAll('tag'); - if (this.store.peekAll('tag').get('length') === 0) { return promise.then(() => tags); } else { @@ -41,44 +40,9 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { } }, - deactivate() { - this._super(...arguments); - if (!this.isDestroyed && !this.isDestroying) { - this.send('resetShortcutsScope'); - } - }, - actions: { - moveUp() { - if (this.controller.get('tagContentFocused')) { - this.scrollContent(-1); - } else { - this.stepThroughTags(-1); - } - }, - - moveDown() { - if (this.controller.get('tagContentFocused')) { - this.scrollContent(1); - } else { - this.stepThroughTags(1); - } - }, - - focusList() { - this.set('controller.keyboardFocus', 'tagList'); - }, - - focusContent() { - this.set('controller.keyboardFocus', 'tagContent'); - }, - newTag() { this.transitionTo('tags.new'); - }, - - resetShortcutsScope() { - key.setScope('default'); } }, @@ -86,30 +50,5 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, ShortcutsRoute, { return { titleToken: 'Tags' }; - }, - - stepThroughTags(step) { - let currentTag = this.modelFor('tags.tag'); - let tags = this.get('controller.sortedTags'); - let length = tags.get('length'); - - if (currentTag && length) { - let newPosition = tags.indexOf(currentTag) + step; - - if (newPosition >= length) { - return; - } else if (newPosition < 0) { - return; - } - - this.transitionTo('tags.tag', tags.objectAt(newPosition)); - } - }, - - scrollContent(amount) { - let content = $('.tag-settings-pane'); - let scrolled = content.scrollTop(); - - content.scrollTop(scrolled + 50 * amount); } }); diff --git a/app/routes/tags/index.js b/app/routes/tags/index.js deleted file mode 100644 index a48ce15e7..000000000 --- a/app/routes/tags/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import {inject as service} from '@ember/service'; - -export default AuthenticatedRoute.extend({ - mediaQueries: service(), - - beforeModel() { - let firstTag = this.modelFor('tags').get('firstObject'); - - this._super(...arguments); - - if (firstTag && !this.get('mediaQueries.maxWidth600')) { - this.transitionTo('tags.tag', firstTag); - } - } -}); diff --git a/app/routes/tags/new.js b/app/routes/tags/new.js index e20b03165..c18f012af 100644 --- a/app/routes/tags/new.js +++ b/app/routes/tags/new.js @@ -1,17 +1,25 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import {isEmpty} from '@ember/utils'; +import {inject as service} from '@ember/service'; export default AuthenticatedRoute.extend({ + router: service(), + controllerName: 'tags.tag', + templateName: 'tags/tag', + + init() { + this._super(...arguments); + this.router.on('routeWillChange', (transition) => { + this.showUnsavedChangesModal(transition); + }); + }, model() { return this.store.createRecord('tag'); }, - renderTemplate() { - this.render('tags.tag'); - }, - // reset the model so that mobile screens react to an empty selectedTag deactivate() { this._super(...arguments); @@ -19,6 +27,18 @@ export default AuthenticatedRoute.extend({ let {controller} = this; controller.model.rollbackAttributes(); controller.set('model', null); + }, + + showUnsavedChangesModal(transition) { + if (transition.from && transition.from.name.match(/^tags\.new/) && transition.targetName) { + let {controller} = this; + let isUnchanged = isEmpty(Object.keys(controller.tag.changedAttributes())); + if (!controller.tag.isDeleted && !isUnchanged) { + transition.abort(); + controller.send('toggleUnsavedChangesModal', transition); + return; + } + } } }); diff --git a/app/routes/tags/tag.js b/app/routes/tags/tag.js index 029b62421..04490bfbf 100644 --- a/app/routes/tags/tag.js +++ b/app/routes/tags/tag.js @@ -1,7 +1,23 @@ /* eslint-disable camelcase */ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings'; +import {inject as service} from '@ember/service'; -export default AuthenticatedRoute.extend({ +export default AuthenticatedRoute.extend(CurrentUserSettings, { + router: service(), + + init() { + this._super(...arguments); + this.router.on('routeWillChange', (transition) => { + this.showUnsavedChangesModal(transition); + }); + }, + + beforeModel() { + this._super(...arguments); + return this.get('session.user') + .then(this.transitionAuthor()); + }, model(params) { return this.store.queryRecord('tag', {slug: params.tag_slug}); @@ -11,14 +27,34 @@ export default AuthenticatedRoute.extend({ return {tag_slug: model.get('slug')}; }, - setupController(controller, model) { + setupController() { this._super(...arguments); - this.controllerFor('tags').scrollTagIntoView(model); }, // reset the model so that mobile screens react to an empty selectedTag deactivate() { this._super(...arguments); + let {controller} = this; + controller.model.rollbackAttributes(); this.set('controller.model', null); + }, + + actions: { + save() { + this.controller.send('save'); + } + }, + + showUnsavedChangesModal(transition) { + if (transition.from && transition.from.name.match(/^tags\.tag/) && transition.targetName) { + let {controller} = this; + + if (!controller.tag.isDeleted && controller.tag.hasDirtyAttributes) { + transition.abort(); + controller.send('toggleUnsavedChangesModal', transition); + return; + } + } } + }); diff --git a/app/styles/components/lists.css b/app/styles/components/lists.css index c1dbc0c79..c90f3662c 100644 --- a/app/styles/components/lists.css +++ b/app/styles/components/lists.css @@ -98,6 +98,27 @@ text-align: right; } +.gh-list-cellwidth-2-3 { + width: 67%; +} + +.gh-list-cellwidth-1-2 { + width: 50%; +} + +.gh-list-cellwidth-1-3 { + width: 33%; +} + +/* Typography +/* --------------------------------------------------- */ +.gh-list h3 { + margin: 0 0 3px 0; + font-size: 1.5rem; + font-weight: 600; +} + + /* Helpers for smaller sizes /* --------------------------------------------------- */ diff --git a/app/styles/layouts/content.css b/app/styles/layouts/content.css index 14a87160a..c817cf848 100644 --- a/app/styles/layouts/content.css +++ b/app/styles/layouts/content.css @@ -134,10 +134,9 @@ text-decoration: none; } -.content-list .gh-list-header:first-child { +.content-list .gh-list-header.no-padding { padding: 0 !important; } - .gh-posts-title-header { padding-left: 10px; } diff --git a/app/styles/layouts/tags.css b/app/styles/layouts/tags.css index 9717664ff..cdbdef01c 100644 --- a/app/styles/layouts/tags.css +++ b/app/styles/layouts/tags.css @@ -1,152 +1,34 @@ -/* Tag Management /ghost/tags/ +/* Tag list /* ---------------------------------------------------------- */ +.gh-tags-count:hover { + color: color-mod(var(--blue) l(-25%) s(+15%)); + font-weight: 500; +} + +textarea.gh-tag-details-textarea { + max-width: 100%; +} + +.gh-tag-image-uploader .gh-image-uploader { + margin: 4px 0 0; + border: 1px solid var(--lightgrey); + min-height: 147px; +} + +.gh-tags-placeholder { + width: 118px; + margin: -30px 0 15px; +} + +.gh-tag-list-slug { + white-space: nowrap; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} + .tags-view { max-width: 1220px; margin: 0 auto; -} - -.tags-container { - background: var(--white); - border-radius: 5px; - box-shadow: var(--shadow-1); - margin: 0 24px 24px; -} - - -/* Tag -/* ---------------------------------------------------------- */ - -.settings-tag { - position: relative; - display: block; - padding: 0 60px 0 0; - border-bottom: var(--lightgrey) 1px solid; -} - -.settings-tag .tag-edit-button { - display: block; - padding: 16px 20px; - width: calc(100% + 60px); - text-align: left; -} - -.settings-tag .tag-edit-button.active { - border-left: 4px solid; - padding-left: 16px; - background: var(--whitegrey-l2); -} - -.settings-tag .label { - display: inline-block; - overflow: hidden; - max-width: 100%; - vertical-align: middle; - text-overflow: ellipsis; - white-space: nowrap; -} - -.settings-tag .label-alt { - text-transform: uppercase; -} - -.settings-tag .tag-title { - color: var(--darkgrey); - font-size: 1.5rem; - font-weight: 600; -} - -.settings-tag .tag-description { - margin: 0; - color: var(--middarkgrey); - word-wrap: break-word; - font-size: 13px; -} - -.settings-tag .tags-count { - position: absolute; - top: calc(50% - 11px); - right: 20px; - color: var(--midgrey); - font-size: 1.4rem; -} - -/* Tag List (Left pane) -/* ---------------------------------------------------------- */ - -.tag-list { - position: absolute; - top: 0; - bottom: 0; - left: 0; - overflow: auto; - max-width: calc(100% - 350px); - width: 66%; - border-right: var(--lightgrey) 1px solid; -} - -@media (max-width: 600px) { - .tag-list { - max-width: 100%; - width: 100%; - } - - .settings-tag .tag-edit-button.active { - border-left: none; - } -} - -/* Tag Settings (Right pane) -/* ---------------------------------------------------------- */ - -.tag-settings { - position: absolute; - top: 0; - right: 0; - bottom: 0; - overflow-x: hidden; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - min-width: 350px; - width: 34%; - border: none; - transform: none; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - box-shadow: none; - background: var(--white); -} - -.tag-settings .no-posts { - padding: 1em; -} - -.tag-settings .no-posts h3 { - text-align: center; -} - -.tag-settings .settings-menu-pane { - transition: transform 0.4s cubic-bezier(0.1, 0.7, 0.1, 1); -} - -.tag-settings .gh-image-uploader { - background: var(--whitegrey-l2); -} - -@media (max-width: 600px) { - .tag-settings { - min-width: 0; - width: 100%; - transition: transform 0.4s cubic-bezier(0.1, 0.7, 0.1, 1); - transform: translate3d(100%, 0px, 0px); - - transform-style: preserve-3d; - } - - .tag-settings-in { - transform: translate3d(0px, 0px, 0px); - } -} - -/* New tag list -/* ---------------------------------------------------------- */ \ No newline at end of file +} \ No newline at end of file diff --git a/app/styles/patterns/buttons.css b/app/styles/patterns/buttons.css index 20f1c8968..01b32f231 100644 --- a/app/styles/patterns/buttons.css +++ b/app/styles/patterns/buttons.css @@ -370,8 +370,25 @@ Usage: CTA buttons grouped together horizontally. margin-left: 15px; } +.gh-btn-group .gh-btn { + margin: 0; + border-radius: 0 0 0 0; + border: none; + border-left: 1px solid var(--whitegrey); +} + .gh-btn-group .gh-btn:first-of-type { - margin-left: 0; + border-radius: 5px 0 0 5px; + border: none; +} + +.gh-btn-group .gh-btn:last-of-type { + border-radius: 0 5px 5px 0; +} + +.gh-btn-group .gh-btn-group-selected span { + color: var(--blue); + font-weight: 500; } .gh-btn-block + .gh-btn-block { diff --git a/app/styles/patterns/forms.css b/app/styles/patterns/forms.css index 20e09ba2a..2889f209e 100644 --- a/app/styles/patterns/forms.css +++ b/app/styles/patterns/forms.css @@ -10,7 +10,6 @@ form label { } form .word-count { - float: right; font-weight: bold; } @@ -169,7 +168,8 @@ select:focus { background: color-mod(var(--input-bg-color) l(-3%)); } -textarea { +textarea, +textarea.gh-input { width: 100%; height: auto; min-width: 250px; diff --git a/app/styles/spirit/_animations.css b/app/styles/spirit/_animations.css index 75a3905d4..1454c5887 100644 --- a/app/styles/spirit/_animations.css +++ b/app/styles/spirit/_animations.css @@ -49,6 +49,13 @@ } +/* Underline */ + +.underline:hover { + text-decoration: underline; +} + + /* Lighter variation diff --git a/app/templates/components/gh-tag-settings-form.hbs b/app/templates/components/gh-tag-settings-form.hbs index de66d3f97..ec22601a4 100644 --- a/app/templates/components/gh-tag-settings-form.hbs +++ b/app/templates/components/gh-tag-settings-form.hbs @@ -1,116 +1,102 @@ -