diff --git a/ghost/admin/app/components/collections/collection-form.hbs b/ghost/admin/app/components/collections/collection-form.hbs new file mode 100644 index 0000000000..101ffadede --- /dev/null +++ b/ghost/admin/app/components/collections/collection-form.hbs @@ -0,0 +1,69 @@ +
+
+

Basic settings

+
+
+
+ + + + + + + + + + + + + +

Maximum: 500 characters. You’ve used {{gh-count-down-characters @collection.description 500}}

+
+ + + + + +
+
+ +
+
+ + + +
+ + {{#if type.name}}{{type.name}}{{else}}Unknown type{{/if}} + + + {{#if (eq this.selectedType.value 'manual')}} +

Add posts to this collection one by one through post settings menu.

+ {{/if}} +
+
+
+
+
diff --git a/ghost/admin/app/components/collections/collection-form.js b/ghost/admin/app/components/collections/collection-form.js new file mode 100644 index 0000000000..bb545cd807 --- /dev/null +++ b/ghost/admin/app/components/collections/collection-form.js @@ -0,0 +1,65 @@ +import Component from '@glimmer/component'; +import {action} from '@ember/object'; +import {inject} from 'ghost-admin/decorators/inject'; +import {inject as service} from '@ember/service'; +import {slugify} from '@tryghost/string'; + +const TYPES = [{ + name: 'Manual', + value: 'manual' +}, { + name: 'Automatic', + value: 'automatic' +}]; + +export default class CollectionForm extends Component { + @service feature; + @service settings; + + @inject config; + + availableTypes = TYPES; + + get selectedType() { + const {collection} = this.args; + return this.availableTypes.findBy('value', collection.type) || {value: '!unknown'}; + } + + @action + setCollectionProperty(property, newValue) { + const {collection} = this.args; + + if (newValue) { + newValue = newValue.trim(); + } + + // Generate slug based on name for new collection when empty + if (property === 'title' && collection.isNew && !this.hasChangedSlug) { + let slugValue = slugify(newValue); + if (/^#/.test(newValue)) { + slugValue = 'hash-' + slugValue; + } + collection.slug = slugValue; + } + + // ensure manual changes of slug don't get reset when changing name + if (property === 'slug') { + this.hasChangedSlug = !!newValue; + } + + collection[property] = newValue; + + // clear validation message when typing + collection.hasValidated.addObject(property); + } + + @action + changeType(type) { + this.setCollectionProperty('type', type.value); + } + + @action + validateCollectionProperty(property) { + return this.args.collection.validate({property}); + } +} diff --git a/ghost/admin/app/components/collections/delete-collection-modal.hbs b/ghost/admin/app/components/collections/delete-collection-modal.hbs new file mode 100644 index 0000000000..c1c6abe9ca --- /dev/null +++ b/ghost/admin/app/components/collections/delete-collection-modal.hbs @@ -0,0 +1,19 @@ + diff --git a/ghost/admin/app/components/collections/delete-collection-modal.js b/ghost/admin/app/components/collections/delete-collection-modal.js new file mode 100644 index 0000000000..1e05a18813 --- /dev/null +++ b/ghost/admin/app/components/collections/delete-collection-modal.js @@ -0,0 +1,29 @@ +import Component from '@glimmer/component'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default class DeleteCollectionModal extends Component { + @service notifications; + @service router; + + @task({drop: true}) + *deleteCollectionTask() { + try { + const {collection} = this.args.data; + + if (collection.isDeleted) { + return true; + } + + yield collection.destroyRecord(); + + this.notifications.closeAlerts('collection.delete'); + this.router.transitionTo('collections'); + return true; + } catch (error) { + this.notifications.showAPIError(error, {key: 'collection.delete.failed'}); + } finally { + this.args.close(); + } + } +} diff --git a/ghost/admin/app/components/collections/list-item.hbs b/ghost/admin/app/components/collections/list-item.hbs new file mode 100644 index 0000000000..a955bb1ecf --- /dev/null +++ b/ghost/admin/app/components/collections/list-item.hbs @@ -0,0 +1,32 @@ +
  • + +

    + {{@collection.title}} +

    + {{#if @collection.description}} +

    + {{@collection.description}} +

    + {{/if}} +
    + + + {{@collection.slug}} + + + {{#if @collection.count.posts}} + + {{gh-pluralize @collection.count.posts "post"}} + + {{else}} + + {{gh-pluralize @collection.count.posts "post"}} + + {{/if}} + + +
    + {{svg-jar "arrow-right" class="w6 h6 fill-midgrey pa1"}} +
    +
    +
  • diff --git a/ghost/admin/app/components/gh-nav-menu/main.hbs b/ghost/admin/app/components/gh-nav-menu/main.hbs index c9ac677b23..52f787d9e0 100644 --- a/ghost/admin/app/components/gh-nav-menu/main.hbs +++ b/ghost/admin/app/components/gh-nav-menu/main.hbs @@ -94,6 +94,9 @@ {{#if this.showTagsNavigation}}
  • {{svg-jar "tag"}}Tags
  • {{/if}} + {{#if (and (gh-user-can-admin this.session.user) (feature "collections"))}} +
  • {{svg-jar "collections-bookmark"}}Collections
  • + {{/if}} {{#if (gh-user-can-admin this.session.user)}}
  • {{#if (eq this.router.currentRouteName "members.index")}} diff --git a/ghost/admin/app/controllers/collection.js b/ghost/admin/app/controllers/collection.js new file mode 100644 index 0000000000..d147e27df9 --- /dev/null +++ b/ghost/admin/app/controllers/collection.js @@ -0,0 +1,43 @@ +import Controller from '@ember/controller'; +import DeleteCollectionModal from '../components/collections/delete-collection-modal'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default class CollectionController extends Controller { + @service modals; + @service notifications; + @service router; + + get collection() { + return this.model; + } + + @action + confirmDeleteCollection() { + return this.modals.open(DeleteCollectionModal, { + collection: this.model + }); + } + + @task({drop: true}) + *saveTask() { + let {collection} = this; + + try { + if (collection.get('errors').length !== 0) { + return; + } + yield collection.save(); + + // replace 'new' route with 'collection' route + this.replaceRoute('collection', collection); + + return collection; + } catch (error) { + if (error) { + this.notifications.showAPIError(error, {key: 'collection.save'}); + } + } + } +} diff --git a/ghost/admin/app/controllers/collections.js b/ghost/admin/app/controllers/collections.js new file mode 100644 index 0000000000..dd1ce224c9 --- /dev/null +++ b/ghost/admin/app/controllers/collections.js @@ -0,0 +1,38 @@ +import Controller from '@ember/controller'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {tracked} from '@glimmer/tracking'; + +export default class CollectionsController extends Controller { + @service router; + + queryParams = ['type']; + @tracked type = 'public'; + + get collections() { + return this.model; + } + + get filteredCollections() { + return this.collections.filter((collection) => { + return (!collection.isNew); + }); + } + + get sortedCollections() { + return this.filteredCollections.sort((collectionA, collectionB) => { + // ignorePunctuation means the # in internal collection names is ignored + return collectionA.title.localeCompare(collectionB.title, undefined, {ignorePunctuation: true}); + }); + } + + @action + changeType(type) { + this.type = type; + } + + @action + newCollection() { + this.router.transitionTo('collection.new'); + } +} diff --git a/ghost/admin/app/mixins/validation-engine.js b/ghost/admin/app/mixins/validation-engine.js index f249db5957..ce7af01f45 100644 --- a/ghost/admin/app/mixins/validation-engine.js +++ b/ghost/admin/app/mixins/validation-engine.js @@ -1,5 +1,6 @@ // TODO: remove usage of Ember Data's private `Errors` class when refactoring validations // eslint-disable-next-line +import CollectionValidator from 'ghost-admin/validators/collection'; import CustomViewValidator from 'ghost-admin/validators/custom-view'; import DS from 'ember-data'; // eslint-disable-line import IntegrationValidator from 'ghost-admin/validators/integration'; @@ -67,6 +68,7 @@ export default Mixin.create({ signin: SigninValidator, signup: SignupValidator, tag: TagSettingsValidator, + collection: CollectionValidator, user: UserValidator, member: MemberValidator, integration: IntegrationValidator, diff --git a/ghost/admin/app/models/collection.js b/ghost/admin/app/models/collection.js new file mode 100644 index 0000000000..f998fed1db --- /dev/null +++ b/ghost/admin/app/models/collection.js @@ -0,0 +1,21 @@ +import Model, {attr} from '@ember-data/model'; +import ValidationEngine from 'ghost-admin/mixins/validation-engine'; +import {inject as service} from '@ember/service'; + +export default Model.extend(ValidationEngine, { + validationType: 'collection', + + title: attr('string'), + slug: attr('string'), + description: attr('string'), + type: attr('string', {defaultValue: 'manual'}), + filter: attr('string'), + featureImage: attr('string'), + createdAtUTC: attr('moment-utc'), + updatedAtUTC: attr('moment-utc'), + createdBy: attr('number'), + updatedBy: attr('number'), + count: attr('raw'), + + feature: service() +}); diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index aa37602339..757bb99987 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -50,6 +50,10 @@ Router.map(function () { this.route('tag.new', {path: '/tags/new'}); this.route('tag', {path: '/tags/:tag_slug'}); + this.route('collections'); + this.route('collection.new', {path: '/collection/new'}); + this.route('collection', {path: '/collection/:collection_slug'}); + this.route('settings-x'); this.route('settings'); this.route('settings.general', {path: '/settings/general'}); diff --git a/ghost/admin/app/routes/collection.js b/ghost/admin/app/routes/collection.js new file mode 100644 index 0000000000..f0a5f7d39e --- /dev/null +++ b/ghost/admin/app/routes/collection.js @@ -0,0 +1,72 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; + +export default class CollectionRoute extends AuthenticatedRoute { + @service modals; + @service router; + @service session; + + beforeModel() { + super.beforeModel(...arguments); + + if (this.session.user.isAuthorOrContributor) { + return this.transitionTo('home'); + } + } + + model(params) { + this._requiresBackgroundRefresh = false; + + if (params.collection_slug) { + return this.store.queryRecord('collection', {slug: params.collection_slug}); + } else { + return this.store.createRecord('collection'); + } + } + + serialize(collection) { + return {collection_slug: collection.get('collection')}; + } + + @action + async willTransition(transition) { + if (this.hasConfirmed) { + return true; + } + + transition.abort(); + + // wait for any existing confirm modal to be closed before allowing transition + if (this.confirmModal) { + return; + } + + if (this.controller.saveTask?.isRunning) { + await this.controller.saveTask.last; + } + + const shouldLeave = await this.confirmUnsavedChanges(); + + if (shouldLeave) { + this.controller.model.rollbackAttributes(); + this.hasConfirmed = true; + return transition.retry(); + } + } + + async confirmUnsavedChanges() { + if (this.controller.model?.hasDirtyAttributes) { + this.confirmModal = this.modals + .open(ConfirmUnsavedChangesModal) + .finally(() => { + this.confirmModal = null; + }); + + return this.confirmModal; + } + + return true; + } +} diff --git a/ghost/admin/app/routes/collection/new.js b/ghost/admin/app/routes/collection/new.js new file mode 100644 index 0000000000..bf5a125ba8 --- /dev/null +++ b/ghost/admin/app/routes/collection/new.js @@ -0,0 +1,6 @@ +import CollectionRoute from '../collection'; + +export default class NewRoute extends CollectionRoute { + controllerName = 'collection'; + templateName = 'collection'; +} diff --git a/ghost/admin/app/routes/collections.js b/ghost/admin/app/routes/collections.js new file mode 100644 index 0000000000..942a61a9f6 --- /dev/null +++ b/ghost/admin/app/routes/collections.js @@ -0,0 +1,31 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; + +export default class CollectionsRoute extends AuthenticatedRoute { + // authors aren't allowed to manage tags + beforeModel() { + super.beforeModel(...arguments); + + if (this.session.user.isAuthorOrContributor) { + return this.transitionTo('home'); + } + } + + // set model to a live array so all collections are shown and created/deleted collections + // are automatically added/removed. Also load all collections in the background, + // pausing to show the loading spinner if no collections have been loaded yet + model() { + let promise = this.store.query('collection', {limit: 'all', include: 'count.posts'}); + let collections = this.store.peekAll('collection'); + if (this.store.peekAll('collection').get('length') === 0) { + return promise.then(() => collections); + } else { + return collections; + } + } + + buildRouteInfoMetadata() { + return { + titleToken: 'Collections' + }; + } +} diff --git a/ghost/admin/app/templates/collection.hbs b/ghost/admin/app/templates/collection.hbs new file mode 100644 index 0000000000..7c55fa109c --- /dev/null +++ b/ghost/admin/app/templates/collection.hbs @@ -0,0 +1,37 @@ +
    +
    + +
    +
    + + Collections + + {{svg-jar "arrow-right-small"}} {{if this.collection.isNew "New collection" "Edit collection"}} +
    +

    + {{if this.collection.isNew "New collection" this.collection.title}} +

    +
    + +
    + +
    +
    + + + + + {{#unless this.collection.isNew}} +
    + +
    + {{/unless}} +
    diff --git a/ghost/admin/app/templates/collections.hbs b/ghost/admin/app/templates/collections.hbs new file mode 100644 index 0000000000..0ac7ec14a2 --- /dev/null +++ b/ghost/admin/app/templates/collections.hbs @@ -0,0 +1,34 @@ +
    + +

    Collections

    +
    + New collection +
    +
    + +
    +
      + {{#if this.sortedCollections}} +
    1. +
      Collection
      +
      +
    2. + + + + {{else}} +
    3. +
      + {{svg-jar "collections-placeholder" class="gh-collections-placeholder"}} +

      Start organizing your content.

      + + Create a new collection + +
      +
    4. + {{/if}} +
    +
    +
    + +{{outlet}} diff --git a/ghost/admin/app/validators/collection.js b/ghost/admin/app/validators/collection.js new file mode 100644 index 0000000000..98a89ed4a7 --- /dev/null +++ b/ghost/admin/app/validators/collection.js @@ -0,0 +1,18 @@ +import BaseValidator from './base'; +import {isBlank} from '@ember/utils'; + +export default BaseValidator.create({ + properties: ['title'], + + name(model) { + let title = model.title; + let hasValidated = model.hasValidated; + + if (isBlank(title)) { + model.errors.add('title', 'Please enter a title.'); + this.invalidate(); + } + + hasValidated.addObject('title'); + } +}); diff --git a/ghost/admin/public/assets/icons/collections-bookmark.svg b/ghost/admin/public/assets/icons/collections-bookmark.svg new file mode 100644 index 0000000000..7f7c5410cc --- /dev/null +++ b/ghost/admin/public/assets/icons/collections-bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ghost/admin/tests/unit/routes/collection-test.js b/ghost/admin/tests/unit/routes/collection-test.js new file mode 100644 index 0000000000..e44eb2134a --- /dev/null +++ b/ghost/admin/tests/unit/routes/collection-test.js @@ -0,0 +1,12 @@ +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +describe('Unit | Route | collection', function () { + setupTest(); + + it('exists', function () { + let route = this.owner.lookup('route:collection'); + expect(route).to.be.ok; + }); +}); diff --git a/ghost/admin/tests/unit/routes/collections-test.js b/ghost/admin/tests/unit/routes/collections-test.js new file mode 100644 index 0000000000..65d3e0d942 --- /dev/null +++ b/ghost/admin/tests/unit/routes/collections-test.js @@ -0,0 +1,12 @@ +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +describe('Unit | Route | collections', function () { + setupTest(); + + it('exists', function () { + let route = this.owner.lookup('route:collections'); + expect(route).to.be.ok; + }); +});