diff --git a/apps/admin-x-design-system/src/assets/icons/share.svg b/apps/admin-x-design-system/src/assets/icons/share.svg new file mode 100644 index 0000000000..6feac81448 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/share.svg @@ -0,0 +1 @@ +Share 1 Streamline Icon: https://streamlinehq.comshare-1 \ No newline at end of file diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index f23705753d..39dd01a525 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -59,6 +59,14 @@ const features = [{ title: 'Content Visibility', description: 'Enables content visibility in Emails', flag: 'contentVisibility' +},{ + title: 'Publish Flow — End Screen', + description: 'Enables improved publish flow', + flag: 'publishFlowEndScreen' +},{ + title: 'Post Analytics — Refresh', + description: 'Adds a refresh button to the post analytics screen', + flag: 'postAnalyticsRefresh' }]; const AlphaFeatures: React.FC = () => { diff --git a/ghost/admin/app/components/editor/modals/publish-flow.hbs b/ghost/admin/app/components/editor/modals/publish-flow.hbs index e8264e4746..e202eb7bff 100644 --- a/ghost/admin/app/components/editor/modals/publish-flow.hbs +++ b/ghost/admin/app/components/editor/modals/publish-flow.hbs @@ -45,12 +45,14 @@ @close={{@close}} /> {{else if this.isComplete}} - + {{#unless (feature "publishFlowEndScreen")}} + + {{/unless}} {{else}} + {{#if this.post.featureImage}} + + {{else if this.post.twitterImage}} + + {{else if this.post.ogImage}} + + {{/if}} + + + + + + + +
+ {{#if (and this.post.isPublished (not this.post.emailOnly))}} + + + + + + {{else}} + {{#if (and this.post.isScheduled (not this.post.emailOnly))}} + + {{/if}} + + + {{/if}} +
+ diff --git a/ghost/admin/app/components/modal-post-success.js b/ghost/admin/app/components/modal-post-success.js new file mode 100644 index 0000000000..32ed28216c --- /dev/null +++ b/ghost/admin/app/components/modal-post-success.js @@ -0,0 +1,95 @@ +import Component from '@glimmer/component'; +import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; +import {action} from '@ember/object'; +import {capitalize} from '@ember/string'; +import {inject as service} from '@ember/service'; +import {task, timeout} from 'ember-concurrency'; + +export default class PostSuccessModal extends Component { + @service store; + @service router; + @service notifications; + + static modalOptions = { + className: 'fullscreen-modal-wide fullscreen-modal-action modal-post-success' + }; + + get post() { + return this.args.data.post; + } + + get postCount() { + return this.args.data.postCount; + } + + get showPostCount() { + return this.args.data.showPostCount; + } + + @action + handleTwitter() { + window.open(`https://twitter.com/intent/tweet?url=${encodeURI(this.post.url)}`, '_blank'); + } + + @action + handleThreads() { + window.open(`https://threads.net/intent/post?text=${encodeURI(this.post.url)}`, '_blank'); + } + + @action + handleFacebook() { + window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURI(this.post.url)}`, '_blank'); + } + + @action + handleLinkedIn() { + window.open(`http://www.linkedin.com/shareArticle?mini=true&url=${encodeURI(this.post.url)}`, '_blank'); + } + + @action + viewInBrowser() { + window.open(this.post.url, '_blank'); + } + + @task + *handleCopyLink() { + copyTextToClipboard(this.post.url); + yield timeout(1000); + return true; + } + + @task + *handleCopyPreviewLink() { + copyTextToClipboard(this.post.previewUrl); + yield timeout(1000); + return true; + } + + @task + *revertToDraftTask() { + const currentPost = this.post; + const originalStatus = currentPost.status; + const originalPublishedAtUTC = currentPost.publishedAtUTC; + + try { + if (currentPost.isScheduled) { + currentPost.publishedAtUTC = null; + } + + currentPost.status = 'draft'; + currentPost.emailOnly = false; + + yield currentPost.save(); + this.router.transitionTo('lexical-editor.edit', 'post', currentPost.id); + + const postType = capitalize(currentPost.displayName); + this.notifications.showNotification(`${postType} reverted to a draft.`, {type: 'success'}); + + return true; + } catch (e) { + currentPost.status = originalStatus; + currentPost.publishedAtUTC = originalPublishedAtUTC; + throw e; + } + } +} diff --git a/ghost/admin/app/components/posts-list/list.hbs b/ghost/admin/app/components/posts-list/list.hbs index 99e0dcf7d5..c9cf59e331 100644 --- a/ghost/admin/app/components/posts-list/list.hbs +++ b/ghost/admin/app/components/posts-list/list.hbs @@ -1,5 +1,5 @@ - {{!-- always order as scheduled, draft, remainder --}} + {{!-- always order as scheduled, draft, remainder --}} {{#if (or @model.scheduledInfinityModel (or @model.draftInfinityModel @model.publishedAndSentInfinityModel))}} {{#if @model.scheduledInfinityModel}} {{#each @model.scheduledInfinityModel as |post|}} @@ -42,4 +42,4 @@ as |menu| > - + \ No newline at end of file diff --git a/ghost/admin/app/components/posts-list/list.js b/ghost/admin/app/components/posts-list/list.js index acf7d30687..d0ecbb7a42 100644 --- a/ghost/admin/app/components/posts-list/list.js +++ b/ghost/admin/app/components/posts-list/list.js @@ -1,7 +1,39 @@ import Component from '@glimmer/component'; +import PostSuccessModal from '../modal-post-success'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; export default class PostsList extends Component { + @service store; + @service modals; + @service feature; + + latestScheduledPost = null; + + constructor() { + super(...arguments); + if (this.feature.publishFlowEndScreen) { + this.checkPublishFlowModal(); + } + } + + async checkPublishFlowModal() { + if (localStorage.getItem('ghost-last-scheduled-post')) { + await this.getLatestScheduledPost.perform(); + this.modals.open(PostSuccessModal, { + post: this.latestScheduledPost + }); + localStorage.removeItem('ghost-last-scheduled-post'); + } + } + get list() { return this.args.list; } + + @task + *getLatestScheduledPost() { + const result = yield this.store.query('post', {filter: `id:${localStorage.getItem('ghost-last-scheduled-post')}`, limit: 1}); + this.latestScheduledPost = result.toArray()[0]; + } } diff --git a/ghost/admin/app/components/posts/analytics.hbs b/ghost/admin/app/components/posts/analytics.hbs index c08e6aeb71..6f20b826af 100644 --- a/ghost/admin/app/components/posts/analytics.hbs +++ b/ghost/admin/app/components/posts/analytics.hbs @@ -34,9 +34,61 @@ {{moment-format publishedAt "HH:mm"}} {{/let}} - - {{svg-jar "pen" title=""}}Edit post - + {{#if (feature "publishFlowEndScreen")}} +
+ {{#if (feature "postAnalyticsRefresh")}} + + {{/if}} + {{#unless this.post.emailOnly}} + + {{/unless}} + + + + + {{svg-jar "dotdotdot"}} + + + + +
  • + Edit post +
  • +
  • + View in browser +
  • +
  • + +
  • +
    +
    +
    + {{else}} + + {{svg-jar "pen" title=""}}Edit post + + {{/if}} @@ -201,4 +253,4 @@ {{/if}} - + \ No newline at end of file diff --git a/ghost/admin/app/components/posts/analytics.js b/ghost/admin/app/components/posts/analytics.js index 0f81f7454d..3fe7e136d3 100644 --- a/ghost/admin/app/components/posts/analytics.js +++ b/ghost/admin/app/components/posts/analytics.js @@ -1,4 +1,6 @@ import Component from '@glimmer/component'; +import DeletePostModal from '../modals/delete-post'; +import PostSuccessModal from '../modal-post-success'; import {action} from '@ember/object'; import {didCancel, task} from 'ember-concurrency'; import {inject as service} from '@ember/service'; @@ -24,6 +26,9 @@ export default class Analytics extends Component { @service utils; @service feature; @service store; + @service router; + @service modals; + @service notifications; @tracked sources = null; @tracked links = null; @@ -31,12 +36,47 @@ export default class Analytics extends Component { @tracked sortColumn = 'signups'; @tracked showSuccess; @tracked updateLinkId; + @tracked _post = null; + @tracked postCount = null; + @tracked showPostCount = false; displayOptions = DISPLAY_OPTIONS; + constructor() { + super(...arguments); + if (this.feature.publishFlowEndScreen) { + this.checkPublishFlowModal(); + } + } + + openPublishFlowModal() { + this.modals.open(PostSuccessModal, { + post: this.post, + postCount: this.postCount, + showPostCount: this.showPostCount + }); + } + + async checkPublishFlowModal() { + if (localStorage.getItem('ghost-last-published-post')) { + await this.fetchPostCountTask.perform(); + this.showPostCount = true; + this.openPublishFlowModal(); + localStorage.removeItem('ghost-last-published-post'); + } + } + get post() { + if (this.feature.publishFlowEndScreen) { + return this._post ?? this.args.post; + } + return this.args.post; } + set post(value) { + this._post = value; + } + get allowedDisplayOptions() { if (!this.hasPaidConversionData) { return this.displayOptions.filter(d => d.value === 'signups'); @@ -142,6 +182,19 @@ export default class Analytics extends Component { } } + @action + togglePublishFlowModal() { + this.showPostCount = false; + this.openPublishFlowModal(); + } + + @action + confirmDeleteMember() { + this.modals.open(DeletePostModal, { + post: this.post + }); + } + updateLinkData(linksData) { let updatedLinks; if (this.links?.length) { @@ -302,6 +355,29 @@ export default class Analytics extends Component { this.mentions = yield this.store.query('mention', {limit: 5, order: 'created_at desc', filter}); } + @task + *fetchPostCountTask() { + if (!this.post.emailOnly) { + const result = yield this.store.query('post', {filter: 'status:published', limit: 1}); + let count = result.meta.pagination.total; + + this.postCount = count; + } + } + + @task + *fetchPostTask() { + const result = yield this.store.query('post', {filter: `id:${this.post.id}`, limit: 1}); + this.post = result.toArray()[0]; + + if (this.post.email) { + this.notifications.showNotification('Post analytics refreshing', { + description: 'It can take up to five minutes for all data to show.', + type: 'success' + }); + } + } + get showLinks() { return this.post.showEmailClickAnalytics; } diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 09f4042a45..14be59d768 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -78,6 +78,8 @@ export default class FeatureService extends Service { @feature('ActivityPub') ActivityPub; @feature('editorExcerpt') editorExcerpt; @feature('contentVisibility') contentVisibility; + @feature('publishFlowEndScreen') publishFlowEndScreen; + @feature('postAnalyticsRefresh') postAnalyticsRefresh; _user = null; diff --git a/ghost/admin/app/styles/components/dropdowns.css b/ghost/admin/app/styles/components/dropdowns.css index 099644e81d..96a1b808a1 100644 --- a/ghost/admin/app/styles/components/dropdowns.css +++ b/ghost/admin/app/styles/components/dropdowns.css @@ -393,7 +393,8 @@ Post context menu stroke-width: 1.8px; } -.gh-posts-context-menu li:last-child::before { +.gh-posts-context-menu li:last-child::before, +.gh-analytics-actions-menu li:last-child::before { display: block; position: relative; content: ""; diff --git a/ghost/admin/app/styles/components/publishmenu.css b/ghost/admin/app/styles/components/publishmenu.css index 6a781a9d63..d1e164c81e 100644 --- a/ghost/admin/app/styles/components/publishmenu.css +++ b/ghost/admin/app/styles/components/publishmenu.css @@ -880,3 +880,123 @@ height: 20px; margin-right: 6px; } + +/* Publish flow modal +/* ---------------------------------------------------------- */ + +.modal-post-success { + max-width: 640px; + --padding: 40px; + --radius: 12px; +} + +.modal-post-success .modal-content { + padding: var(--padding); + border-radius: var(--radius); +} + +.modal-post-success .modal-image { + aspect-ratio: 16 / 7.55; + overflow: hidden; + margin: calc(var(--padding) * -1) calc(var(--padding) * -1) var(--padding); +} + +.modal-post-success .modal-image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--radius) var(--radius) 0 0; +} + +.modal-post-success .modal-header { + margin: 0; +} + +.modal-post-success .modal-header h1 { + display: flex; + flex-direction: column; + margin: 0; + font-size: 3.6rem; + font-weight: 700; + letter-spacing: -0.03em; +} + +.modal-post-success .modal-header h1 span:has(+ span) { + color: var(--green); +} + +.modal-post-success .modal-body { + margin-top: 16px; + font-size: 1.8rem; + line-height: 1.4; + letter-spacing: -0.002em; +} + +.modal-post-success .modal-footer { + gap: 16px; + margin-top: var(--padding); +} + +.modal-post-success .modal-footer .gh-btn { + min-width: 64px; + height: 44px; + border-radius: 4px; +} + +.modal-post-success .modal-footer .gh-btn:not(:first-child) { + margin: 0; +} + +.modal-post-success .modal-footer .gh-btn span { + padding-inline: 18px; + font-size: 1.6rem; +} + +.modal-post-success .modal-footer .gh-btn-primary { + min-width: 80px; +} + +.modal-post-success .modal-footer:has(.twitter) .gh-btn-primary { + flex-grow: 1; +} + +.modal-post-success .modal-footer .gh-btn:is(.twitter, .threads, .facebook, .linkedin) { + width: 56px; +} + +.modal-post-success .modal-footer .gh-btn:is(.twitter, .threads, .facebook, .linkedin) span { + font-size: 0; +} + +.modal-post-success .modal-footer .gh-btn svg { + width: 18px; + height: 18px; +} + +.modal-post-success .modal-footer .gh-btn.twitter svg path { + fill: black; +} + +.modal-post-success:has(.modal-image) .close { + display: flex; + justify-content: center; + align-items: center; + width: 32px; + height: 32px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 50%; +} + +.modal-post-success:has(.modal-image) .close:hover { + background-color: rgba(0, 0, 0, 0.25); +} + +.modal-post-success:has(.modal-image) .close svg { + width: 14px; + height: 14px; +} + +.modal-post-success:has(.modal-image) .close svg path { + fill: white; +} diff --git a/ghost/admin/app/styles/layouts/content.css b/ghost/admin/app/styles/layouts/content.css index a4e6f6aa5c..3964ce4374 100644 --- a/ghost/admin/app/styles/layouts/content.css +++ b/ghost/admin/app/styles/layouts/content.css @@ -776,6 +776,17 @@ border-radius: var(--border-radius); } +.gh-analytics-actions-menu { + top: calc(100% + 6px); + left: auto; + right: 0; +} + +.gh-analytics-actions-menu.fade-out { + animation-duration: .001s; + pointer-events: none; +} + .feature-audienceFeedback .gh-post-analytics-box.gh-post-analytics-newsletter-clicks, .feature-audienceFeedback .gh-post-analytics-box.gh-post-analytics-source-attribution, .gh-post-analytics-box.gh-post-analytics-mentions { @@ -1523,6 +1534,10 @@ transition: all .1s linear; } +span.dropdown .gh-post-list-cta > span { + padding: 0; +} + .gh-post-list-cta.edit.is-hovered > *, .gh-post-list-cta.edit.is-hovered:hover > *, .gh-post-list-cta.edit:not(.is-hovered):hover > * { diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index 3d99d9ee66..dc3e26361d 100644 --- a/ghost/admin/app/templates/posts.hbs +++ b/ghost/admin/app/templates/posts.hbs @@ -73,4 +73,4 @@ {{outlet}} - + \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/reload.svg b/ghost/admin/public/assets/icons/reload.svg index a094276a30..5d17641a8a 100644 --- a/ghost/admin/public/assets/icons/reload.svg +++ b/ghost/admin/public/assets/icons/reload.svg @@ -1,6 +1,6 @@ reload - + diff --git a/ghost/admin/public/assets/icons/share.svg b/ghost/admin/public/assets/icons/share.svg new file mode 100644 index 0000000000..6feac81448 --- /dev/null +++ b/ghost/admin/public/assets/icons/share.svg @@ -0,0 +1 @@ +Share 1 Streamline Icon: https://streamlinehq.comshare-1 \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/social-threads.svg b/ghost/admin/public/assets/icons/social-threads.svg new file mode 100644 index 0000000000..dc95af6271 --- /dev/null +++ b/ghost/admin/public/assets/icons/social-threads.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index e9a1a7ad68..4f242c4ccc 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -45,7 +45,9 @@ const ALPHA_FEATURES = [ 'importMemberTier', 'lexicalIndicators', 'adminXDemo', - 'contentVisibility' + 'contentVisibility', + 'publishFlowEndScreen', + 'postAnalyticsRefresh' ]; module.exports.GA_KEYS = [...GA_FEATURES];