0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Added fullscreen preview with toggle to publish flow

refs https://github.com/TryGhost/Team/issues/1621

- copied existing preview modal over to `editor-labs/modals` directory
  - old modal will be deleted in cleanup
- moved "Preview" button from editor template to the `<PublishManagement>` component
  - allows for preview modal to be controlled alongside the publish flow modal
- added `togglePreviewPublish()` action to `<PublishManagement>`
  - opens whichever of preview/publish is not currently open, this opens the new modal on top of the old modal
  - waits for the modal animation duration to pass then closes the modal that's now underneath, this prevents the flashing that occurs when modals are both opening and closing at the same time because that results in a 50% opacity of both modals during the middle of the animation
- updated preview modal and publish-flow modals to have "Publish" and "Preview" buttons respectively that call the `togglePreviewPublish` action
- updated preview modal to be fullscreen to better match the publish modal
This commit is contained in:
Kevin Ansfield 2022-05-11 23:46:01 +01:00
parent b285205c53
commit cf8b372fed
17 changed files with 887 additions and 5 deletions

View file

@ -0,0 +1,50 @@
<div class="flex flex-column h-100">
<header class="modal-header gh-post-preview-header gh-post-preview-header-border" data-test-modal="preview-email">
<div>
<button class="gh-editor-back-button" title="Close" type="button" {{on "click" @close}}>
<span>{{svg-jar "arrow-left"}} Editor</span>
</button>
</div>
<div class="gh-post-preview-btn-group">
<div class="gh-contentfilter gh-btn-group">
<button type="button" class="gh-btn {{if (eq this.tab "browser") "gh-btn-group-selected"}} gh-post-preview-mode" {{on "click" (fn this.changeTab "browser")}}><span>{{svg-jar "desktop"}}</span></button>
<button type="button" class="gh-btn {{if (eq this.tab "mobile") "gh-btn-group-selected"}} gh-post-preview-mode" {{on "click" (fn this.changeTab "mobile")}}><span>{{svg-jar "mobile-phone"}}</span></button>
{{#if (and (not-eq this.settings.membersSignupAccess "none") (not-eq this.settings.editorDefaultEmailRecipients "disabled"))}}
{{#if @data.post.isPost}}
<button type="button" class="gh-btn {{if (eq this.tab "email") "gh-btn-group-selected"}} gh-post-preview-mode" {{on "click" (fn this.changeTab "email")}}><span>{{svg-jar "email-unread"}}</span></button>
{{/if}}
{{/if}}
<button type="button" class="gh-btn {{if (eq this.tab "social") "gh-btn-group-selected"}} gh-post-preview-mode" {{on "click" (fn this.changeTab "social")}}><span>{{svg-jar "twitter"}}</span></button>
</div>
</div>
<div>
<button
type="button"
class="gh-btn gh-btn-editor darkgrey gh-publishmenu-trigger"
{{on "click" @data.togglePreviewPublish}}
>
<span>Publish</span>
</button>
</div>
</header>
{{#if this.saveFirstTask.isRunning}}
<GhLoadingSpinner />
{{else}}
{{#if (eq this.tab "browser")}}
<Modals::PostPreview::Browser @post={{@data.post}} />
{{/if}}
{{#if (and (eq this.tab "mobile"))}}
<Modals::PostPreview::Mobile @post={{@data.post}} />
{{/if}}
{{#if (and (eq this.tab "email") @data.post.isPost)}}
<Modals::PostPreview::Email @post={{@data.post}} />
{{/if}}
{{#if (eq this.tab "social")}}
<Modals::PostPreview::Social @post={{@data.post}} />
{{/if}}
{{/if}}
</div>

View file

@ -0,0 +1,41 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class EditorPostPreviewModal extends Component {
@service settings;
@service session;
static modalOptions = {
className: 'fullscreen-modal-total-overlay',
omitBackdrop: true,
ignoreBackdropClick: true
};
@tracked tab = 'browser';
constructor() {
super(...arguments);
this.saveFirstTask.perform();
}
@action
changeTab(tab) {
this.tab = tab;
}
@task
*saveFirstTask() {
const {saveTask, post, hasDirtyAttributes} = this.args.data;
if (saveTask.isRunning) {
return yield saveTask.last;
}
if (post.isDraft && hasDirtyAttributes) {
yield saveTask.perform();
}
}
}

View file

@ -0,0 +1,23 @@
<div class="gh-post-preview-browser-container">
<iframe class="gh-pe-iframe" src={{@post.previewUrl}}></iframe>
</div>
<div class="gh-post-preview-browser-footer">
<span class="mr3 nowrap fw6 f8 darkgrey">Share preview privately</span>
<div class="gh-post-preview-url-container truncate">
<span class="db truncate w-90">{{@post.previewUrl}}</span>
</div>
<button type="button" {{on "click" (perform this.copyPreviewUrl)}} class="gh-btn gh-btn-green gh-btn-icon gh-post-preview-copy-url-trigger">
<span>
{{#if this.copyPreviewUrl.isRunning}}
Copied!
{{else}}
copy
{{/if}}
</span>
</button>
<div>
<a href={{@post.previewUrl}} target="_blank" rel="noopener noreferrer" class="gh-btn gh-btn-editor gh-btn-icon gh-btn-icon-right gh-btn-external">
<span>Open in new tab {{svg-jar "external"}}</span>
</a>
</div>
</div>

View file

@ -0,0 +1,11 @@
import Component from '@glimmer/component';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import {task, timeout} from 'ember-concurrency';
export default class ModalPostPreviewBrowserComponent extends Component {
@task
*copyPreviewUrl() {
copyTextToClipboard(this.args.post.previewUrl);
yield timeout(this.isTesting ? 50 : 3000);
}
}

View file

@ -0,0 +1,39 @@
<div class="gh-post-preview-email-container">
<div class="gh-post-preview-email-mockup">
<div class="gh-pe-emailclient-sender">
<p>
<span class="strong">{{or this.newsletter.senderName this.settings.title}}</span> &lt;{{full-email-address (or this.newsletter.senderEmail "noreply")}}&gt;
</p>
<p><span class="dark">To:</span> Jamie Larson &lt;jamie@example.com&gt;</p>
</div>
<iframe class="gh-pe-iframe" {{did-insert this.renderEmailPreview}} sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>
</div>
</div>
<div class="gh-post-preview-email-footer">
<div class="gh-btn-group mr3">
<button type="button" class="gh-btn {{if (eq this.memberSegment "status:free") "gh-btn-group-selected"}}" {{on "click" (fn this.changeMemberSegment "status:free")}}><span>Free member</span></button>
<button type="button" class="gh-btn {{if (eq this.memberSegment "status:-free") "gh-btn-group-selected"}}" {{on "click" (fn this.changeMemberSegment "status:-free")}}><span>Paid member</span></button>
</div>
<div class="gh-post-preview-email-input {{if this.sendPreviewEmailError "error"}}">
<Input
@value={{this.previewEmailAddress}}
class="gh-input gh-post-preview-email-input"
placeholder="you@yoursite.com"
aria-invalid={{if this.sendPreviewEmailError "true"}}
aria-describedby={{if this.sendPreviewEmailError "sendError"}}
{{on-key "Enter" (perform this.sendPreviewEmailTask)}}
/>
{{#if this.sendPreviewEmailError}}
<div class="error fixed nowrap f8 lh-heading"><span class="response" id="sendError">{{this.sendPreviewEmailError}}</span></div>
{{/if}}
</div>
<GhTaskButton
@task={{this.sendPreviewEmailTask}}
@buttonText="Send test email"
@successText="Sent"
@runningText="Sending..."
@class="gh-btn gh-btn-green gh-btn-icon gh-post-preview-email-trigger"
/>
</div>

View file

@ -0,0 +1,161 @@
import Component from '@glimmer/component';
import validator from 'validator';
import {action} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {inject as service} from '@ember/service';
import {task, timeout} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
const INJECTED_CSS = `
html::-webkit-scrollbar {
display: none;
width: 0;
background: transparent
}
html {
scrollbar-width: none;
}
`;
// TODO: remove duplication with <ModalPostEmailPreview>
export default class ModalPostPreviewEmailComponent extends Component {
@service ajax;
@service config;
@service feature;
@service ghostPaths;
@service session;
@service settings;
@service store;
@tracked html = '';
@tracked subject = '';
@tracked memberSegment = 'status:free';
@tracked previewEmailAddress = this.session.user.email;
@tracked sendPreviewEmailError = '';
@tracked newsletter = null;
get mailgunIsEnabled() {
return this.config.get('mailgunIsConfigured') ||
!!(this.settings.get('mailgunApiKey') && this.settings.get('mailgunDomain') && this.settings.get('mailgunBaseUrl'));
}
@action
async renderEmailPreview(iframe) {
this._previewIframe = iframe;
await this._fetchEmailData();
// avoid timing issues when _fetchEmailData didn't perform any async ops
await timeout(100);
if (iframe) {
iframe.contentWindow.document.open();
iframe.contentWindow.document.write(this.html);
iframe.contentWindow.document.close();
}
}
@action
changeMemberSegment(segment) {
this.memberSegment = segment;
if (this._previewIframe) {
this.renderEmailPreview(this._previewIframe);
}
}
@task({drop: true})
*sendPreviewEmailTask() {
try {
const resourceId = this.args.post.id;
const testEmail = this.previewEmailAddress.trim();
if (!validator.isEmail(testEmail)) {
this.sendPreviewEmailError = 'Please enter a valid email';
return false;
}
if (!this.mailgunIsEnabled) {
this.sendPreviewEmailError = 'Please verify your email settings';
return false;
}
this.sendPreviewEmailError = '';
const url = this.ghostPaths.url.api('/email_previews/posts', resourceId);
const data = {emails: [testEmail], memberSegment: this.memberSegment};
const options = {
data,
dataType: 'json'
};
yield this.ajax.post(url, options);
return true;
} catch (error) {
if (error) {
let message = 'Email could not be sent, verify mail settings';
// grab custom error message if present
if (
error.payload && error.payload.errors
&& error.payload.errors[0] && error.payload.errors[0].message) {
message = htmlSafe(error.payload.errors[0].message);
}
this.sendPreviewEmailError = message;
throw error;
}
}
}
async _fetchEmailData() {
let {html, subject, memberSegment} = this;
let {post} = this.args;
// Fetch newsletter
if (!this.newsletter && post.newsletter) {
this.newsletter = post.newsletter;
}
if (!this.newsletter) {
const newsletters = (await this.store.query('newsletter', {filter: 'status:active', limit: 1})).toArray();
const defaultNewsletter = newsletters[0];
this.newsletter = defaultNewsletter;
}
if (html && subject && memberSegment === this._lastMemberSegment) {
return {html, subject};
}
this._lastMemberSegment = memberSegment;
// model is an email
if (post.html && post.subject) {
html = post.html;
subject = post.subject;
// model is a post with an existing email
} else if (post.email) {
html = post.email.html;
subject = post.email.subject;
// model is a post, fetch email preview
} else {
let url = new URL(this.ghostPaths.url.api('/email_previews/posts', post.id), window.location.href);
url.searchParams.set('memberSegment', this.memberSegment);
let response = await this.ajax.request(url.href);
let [emailPreview] = response.email_previews;
html = emailPreview.html;
subject = emailPreview.subject;
}
// inject extra CSS into the html for disabling links and scrollbars etc
let domParser = new DOMParser();
let htmlDoc = domParser.parseFromString(html, 'text/html');
let stylesheet = htmlDoc.querySelector('style');
let originalCss = stylesheet.innerHTML;
stylesheet.innerHTML = `${originalCss}\n\n${INJECTED_CSS}`;
const doctype = new XMLSerializer().serializeToString(htmlDoc.doctype);
html = doctype + htmlDoc.documentElement.outerHTML;
this.html = html;
this.subject = subject;
}
}

View file

@ -0,0 +1,27 @@
<div class="modal-body modal-preview-email-content gh-pe-mobile-container h-auto overflow-auto">
<div class="gh-pe-mobile-bezel">
<div class="gh-pe-mobile-screen">
<iframe class="gh-post-preview-iframe" src={{@post.previewUrl}}></iframe>
</div>
</div>
</div>
<div class="gh-post-preview-browser-footer">
<span class="mr3 nowrap fw6 f8 darkgrey">Share preview privately</span>
<div class="gh-post-preview-url-container truncate">
<span class="db truncate w-90">{{@post.previewUrl}}</span>
</div>
<button type="button" {{on "click" (perform this.copyPreviewUrl)}} class="gh-btn gh-btn-green gh-btn-icon gh-post-preview-copy-url-trigger">
<span>
{{#if this.copyPreviewUrl.isRunning}}
Copied!
{{else}}
copy
{{/if}}
</span>
</button>
<div>
<a href={{@post.previewUrl}} target="_blank" rel="noopener noreferrer" class="gh-btn gh-btn-editor gh-btn-icon gh-btn-icon-right gh-btn-external">
<span>Open in new tab {{svg-jar "external"}}</span>
</a>
</div>
</div>

View file

@ -0,0 +1,11 @@
import Component from '@glimmer/component';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import {task, timeout} from 'ember-concurrency';
export default class ModalPostPreviewBrowserComponent extends Component {
@task
*copyPreviewUrl() {
copyTextToClipboard(this.args.post.previewUrl);
yield timeout(this.isTesting ? 50 : 3000);
}
}

View file

@ -0,0 +1,258 @@
<div class="gh-post-preview-social-container">
<p class="mb4">This is how your content will look when shared, you can click on any elements youd like to edit.</p>
<div class="flex flex-column">
<div class="flex gh-social-container-responsive">
<div class="gh-social-og-container">
<div class="flex ma3 mb2">
<span>{{svg-jar "social-facebook" class="social-icon"}}</span>
<div>
<div class="gh-social-og-title">{{or this.settings.metaTitle this.settings.title}}</div>
<div class="gh-social-og-time">12 hrs</div>
</div>
</div>
<div class="flex flex-column ma3 mt2">
<span class="gh-social-og-desc w-100 mb2" />
<span class="gh-social-og-desc w-100 mb2" />
<span class="gh-social-og-desc w-60" />
</div>
<div
class="gh-social-og-preview"
{{on "mouseenter" (action (mut this.facebookHovered) true)}}
{{on "mouseleave" (action (mut this.facebookHovered) false)}}
>
{{#if (and this.facebookHovered (not this.facebookImage))}}
{{!-- only shown on hover when there's no image or fallback --}}
<button class="gh-social-og-preview-img-add" type="button" {{on "click" (fn this.triggerFileDialog "facebook")}}>+ Add image</button>
{{/if}}
<GhUploader
@extensions={{this.imageExtensions}}
@onComplete={{this.setFacebookImage}}
as |uploader|
>
{{#each uploader.errors as |error|}}
<div class="error pa2"><span class="response">{{or error.context error.message}}</span></div>
{{/each}}
{{#if (or this.facebookImage uploader.isUploading)}}
<div class="gh-social-og-preview-image relative" style={{background-image-style this.facebookImage}}>
<div class="flex h-100 items-center justify-center">
{{#if (or this.facebookHovered uploader.isUploading)}}
{{#if uploader.isUploading}}
{{uploader.progressBar}}
{{else}}
<button type="button" class="gh-btn gh-btn-white" {{on "click" (fn this.triggerFileDialog "facebook")}}><span>{{if @post.ogImage "Change" "Upload"}} image</span></button>
{{/if}}
{{/if}}
{{#if (and this.facebookHovered @post.ogImage)}}
<button type="button" class="gh-btn gh-btn-black gh-btn-icon gh-social-preview-img-delete" title="Remove custom Facebook image" {{on "click" this.clearFacebookImage}}>
<span>{{svg-jar "trash"}}</span>
<span class="hidden">Remove custom Facebook image</span>
</button>
{{/if}}
</div>
</div>
{{/if}}
<div style="display:none">
<GhFileInput id="facebookFileInput" @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.imageMimeTypes}} />
</div>
</GhUploader>
<div class="gh-social-og-preview-bookmark">
{{!-- Ensures description is hidden if title exceeds one line --}}
<div class="gh-social-og-preview-content {{if this.editingFacebookTitle 'edit-mode'}} {{if this.editingFacebookDescription 'edit-mode'}}">
<div class="gh-social-og-preview-meta">
{{this.config.blogDomain}}
</div>
{{#if this.editingFacebookTitle}}
<input
type="text"
class="gh-input"
placeholder={{this.facebookTitle}}
value={{@post.ogTitle}}
maxlength="300"
{{on "blur" this.setFacebookTitle}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "ogTitle")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
/>
{{else}}
<div class="gh-social-og-preview-title editable pointer" {{on "click" this.editFacebookTitle}}>
{{truncate this.facebookTitle}}
</div>
{{/if}}
{{#if this.editingFacebookDescription}}
<textarea
class="gh-input"
maxlength="500"
placeholder={{truncate this.facebookDescription 160}}
{{on "blur" this.setFacebookDescription}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "ogDescription")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
>{{@post.ogDescription}}</textarea>
{{else}}
<div class="gh-social-og-preview-desc editable pointer" {{on "click" this.editFacebookDescription}}>
{{truncate this.facebookDescription}}
</div>
{{/if}}
</div>
</div>
</div>
<div class="gh-social-og-reactions">
<span class="gh-social-og-likes">{{svg-jar "facebook-like" class="z-999"}}{{svg-jar "facebook-heart" class="nl1"}}182</span>
<span class="gh-social-og-comments">7 comments</span>
<span class="gh-social-og-comments ml2">2 shares</span>
</div>
</div>
<div class="gh-social-twitter-container">
<div class="flex ma4">
<span>{{svg-jar "social-twitter" class="social-icon"}}</span>
<div>
<span class="gh-social-og-title">{{or this.settings.metaTitle this.settings.title}}</span>
<span class="gh-social-og-time">12 hrs</span>
<div class="flex flex-column mt2 mb3">
<span class="gh-social-og-desc w-100 mb2" />
<span class="gh-social-og-desc w-60" />
</div>
<div class="gh-social-twitter-post-preview"
{{on "mouseenter" (action (mut this.twitterHovered) true)}}
{{on "mouseleave" (action (mut this.twitterHovered) false)}}
>
{{#if (and this.twitterHovered (not this.twitterImage))}}
{{!-- only shown on hover when there's no image or fallback --}}
<button class="gh-social-twitter-preview-img-add" type="button" {{on "click" (fn this.triggerFileDialog "twitter")}}>+ Add image</button>
{{/if}}
<GhUploader
@extensions={{this.imageExtensions}}
@onComplete={{this.setTwitterImage}}
as |uploader|
>
{{#each uploader.errors as |error|}}
<div class="error pa2"><span class="response">{{or error.context error.message}}</span></div>
{{/each}}
{{#if (or this.twitterImage uploader.isUploading)}}
<div class="gh-social-twitter-preview-image relative" style={{background-image-style this.twitterImage}}>
<div class="flex h-100 items-center justify-center">
{{#if (or this.twitterHovered uploader.isUploading)}}
{{#if uploader.isUploading}}
{{uploader.progressBar}}
{{else}}
<button type="button" class="gh-btn gh-btn-white" {{on "click" (fn this.triggerFileDialog "twitter")}}><span>{{if @post.twitterImage "Change" "Upload"}} image</span></button>
{{/if}}
{{/if}}
{{#if (and this.twitterHovered @post.twitterImage)}}
<button type="button" class="gh-btn gh-btn-black gh-btn-icon gh-social-preview-img-delete" title="Remove custom Twitter image" {{on "click" this.clearTwitterImage}}>
<span>{{svg-jar "trash"}}</span>
<span class="hidden">Remove custom Twitter image</span>
</button>
{{/if}}
</div>
</div>
{{/if}}
<div style="display:none">
<GhFileInput id="twitterFileInput" @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.imageMimeTypes}} />
</div>
</GhUploader>
<div class="gh-social-twitter-preview-content">
{{#if this.editingTwitterTitle}}
<input
type="text"
class="gh-input"
placeholder={{this.twitterTitle}}
value={{@post.twitterTitle}}
maxlength="300"
{{on "blur" this.setTwitterTitle}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "twitterTitle")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
/>
{{else}}
<div class="gh-social-twitter-preview-title editable pointer" {{on "click" this.editTwitterTitle}}>{{this.twitterTitle}}</div>
{{/if}}
{{#if this.editingTwitterDescription}}
<textarea
class="gh-input"
maxlength="500"
placeholder={{truncate this.twitterDescription 160}}
{{on "blur" this.setTwitterDescription}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "twitterDescription")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
>{{@post.twitterDescription}}</textarea>
{{else}}
<div class="gh-social-twitter-preview-desc editable pointer" {{on "click" this.editTwitterDescription}}>{{truncate this.twitterDescription}}</div>
{{/if}}
<div class="gh-social-twitter-preview-meta">
{{svg-jar "twitter-link"}}
{{this.config.blogDomain}}
</div>
</div>
</div>
<div class="gh-social-twitter-reactions">
<div class="flex items-center">{{svg-jar "twitter-comment"}}2</div>
<div class="flex items-center">{{svg-jar "twitter-retweet"}}11</div>
<div class="flex items-center">{{svg-jar "twitter-like"}}32</div>
<div class="flex items-center">{{svg-jar "twitter-share"}}</div>
</div>
</div>
</div>
</div>
</div>
<div class="gh-seo-preview-container">
{{svg-jar "google"}}
<div class="gh-seo-preview">
<div class="gh-seo-search-bar mb12">{{svg-jar "google-search"}}</div>
<div class="gh-seo-preview-link">{{this.serpURL}}</div>
{{#if this.editingMetaTitle}}
<input
type="text"
class="gh-input"
placeholder={{this.serpTitle}}
value={{@post.metaTitle}}
maxlength="300"
{{on "blur" this.setMetaTitle}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "metaTitle")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
>
{{else}}
<div class="gh-seo-preview-title editable pointer" {{on "click" this.editMetaTitle}}>
{{this.serpTitle}}
</div>
{{/if}}
{{#if this.editingMetaDescription}}
<textarea
class="gh-input"
placeholder={{this.serpDescription}}
maxlength="500"
{{on "blur" this.setMetaDescription}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "metaDescription")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
>{{@post.metaDescription}}</textarea>
{{else}}
<div class="gh-seo-preview-desc editable pointer" {{on "click" this.editMetaDescription}}>
{{moment-format (now) "DD MMM YYYY"}}{{truncate this.serpDescription 149}}
</div>
{{/if}}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,209 @@
import Component from '@glimmer/component';
import {
IMAGE_EXTENSIONS,
IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class ModalPostPreviewSocialComponent extends Component {
@service config;
@service settings;
@service ghostPaths;
@tracked editingFacebookTitle = false;
@tracked editingFacebookDescription = false;
@tracked editingTwitterTitle = false;
@tracked editingTwitterDescription = false;
@tracked editingMetaTitle = false;
@tracked editingMetaDescription = false;
imageExtensions = IMAGE_EXTENSIONS;
imageMimeTypes = IMAGE_MIME_TYPES;
get _fallbackDescription() {
return this.args.post.customExcerpt ||
this.serpDescription ||
this.settings.get('description');
}
@action
blurElement(event) {
if (!event.shiftKey) {
event.preventDefault();
event.target.blur();
}
}
@action
triggerFileDialog(name) {
const input = document.querySelector(`#${name}FileInput input`);
if (input) {
input.click();
}
}
// SERP
get serpTitle() {
return this.args.post.metaTitle || this.args.post.title || '(Untitled)';
}
get serpURL() {
const urlParts = [];
if (this.args.post.canonicalUrl) {
const canonicalUrl = new URL(this.args.post.canonicalUrl);
urlParts.push(canonicalUrl.host);
urlParts.push(...canonicalUrl.pathname.split('/').reject(p => !p));
} else {
const blogUrl = new URL(this.config.get('blogUrl'));
urlParts.push(blogUrl.host);
urlParts.push(...blogUrl.pathname.split('/').reject(p => !p));
urlParts.push(this.args.post.slug);
}
return urlParts.join(' > ');
}
get serpDescription() {
return this.args.post.metaDescription || this.args.post.excerpt;
}
@action
editMetaTitle() {
this.editingMetaTitle = true;
}
@action
setMetaTitle(event) {
const title = event.target.value;
this.args.post.metaTitle = title.trim();
this.args.post.save();
this.editingMetaTitle = false;
}
@action
editMetaDescription() {
this.editingMetaDescription = true;
}
@action
setMetaDescription(event) {
const description = event.target.value;
this.args.post.metaDescription = description.trim();
this.args.post.save();
this.editingMetaDescription = false;
}
// Facebook
get facebookTitle() {
return this.args.post.ogTitle || this.serpTitle;
}
get facebookDescription() {
return this.args.post.ogDescription || this._fallbackDescription;
}
get facebookImage() {
return this.args.post.ogImage || this.args.post.featureImage || this.settings.get('ogImage') || this.settings.get('coverImage');
}
@action
editFacebookTitle() {
this.editingFacebookTitle = true;
}
@action
cancelEdit(property, event) {
event.preventDefault();
event.target.value = this.args.post[property];
event.target.blur();
}
@action
setFacebookTitle(event) {
const title = event.target.value;
this.args.post.ogTitle = title.trim();
this.args.post.save();
this.editingFacebookTitle = false;
}
@action
editFacebookDescription() {
this.editingFacebookDescription = true;
}
@action
setFacebookDescription() {
const description = event.target.value;
this.args.post.ogDescription = description.trim();
this.args.post.save();
this.editingFacebookDescription = false;
}
@action
setFacebookImage([image]) {
this.args.post.ogImage = image.url;
this.args.post.save();
}
@action
clearFacebookImage() {
this.args.post.ogImage = null;
this.args.post.save();
}
// Twitter
get twitterTitle() {
return this.args.post.twitterTitle || this.serpTitle;
}
get twitterDescription() {
return this.args.post.twitterDescription || this._fallbackDescription;
}
get twitterImage() {
return this.args.post.twitterImage || this.args.post.featureImage || this.settings.get('twitterImage') || this.settings.get('coverImage');
}
@action
editTwitterTitle() {
this.editingTwitterTitle = true;
}
@action
setTwitterTitle(event) {
const title = event.target.value;
this.args.post.twitterTitle = title.trim();
this.args.post.save();
this.editingTwitterTitle = false;
}
@action
editTwitterDescription() {
this.editingTwitterDescription = true;
}
@action
setTwitterDescription() {
const description = event.target.value;
this.args.post.twitterDescription = description.trim();
this.args.post.save();
this.editingTwitterDescription = false;
}
@action
setTwitterImage([image]) {
this.args.post.twitterImage = image.url;
this.args.post.save();
}
@action
clearTwitterImage() {
this.args.post.twitterImage = null;
this.args.post.save();
}
}

View file

@ -3,6 +3,16 @@
<button class="gh-publish-back-button" title="Close" type="button" {{on "click" @close}}>
<span>{{svg-jar "arrow-left"}} Editor</span>
</button>
{{#unless this.isComplete}}
<button
type="button"
class="gh-btn gh-btn-editor darkgrey gh-publishmenu-trigger"
{{on "click" @data.togglePreviewPublish}}
>
<span>Preview</span>
</button>
{{/unless}}
</header>
<div class="gh-publish-settings-container">

View file

@ -68,7 +68,7 @@ export default class PublishFlowOptions extends Component {
let errorMessage = '';
if (isServerUnreachableError(e)) {
errorMessage = 'Unable to connect, please check your connection and try again';
errorMessage = 'Unable to connect, please check your internet connection and try again';
} else if (e && isString(e)) {
errorMessage = e;
} else if (e && isArray(e)) {

View file

@ -1,4 +1,10 @@
{{#if @post.isDraft}}
<div>
<button type="button" class="gh-btn gh-editor-preview-trigger" {{on "click" this.openPreview}}>
<span>Preview</span>
</button>
</div>
<button
type="button"
class="gh-btn gh-btn-editor darkgrey gh-publishmenu-trigger"

View file

@ -1,5 +1,6 @@
import Component from '@glimmer/component';
import EmailFailedError from 'ghost-admin/errors/email-failed-error';
import PreviewModal from './modals/preview';
import PublishFlowModal from './modals/publish-flow';
import PublishOptionsResource from 'ghost-admin/helpers/publish-options';
import UpdateFlowModal from './modals/update-flow';
@ -46,7 +47,8 @@ export default class PublishManagement extends Component {
this.publishFlowModal = this.modals.open(PublishFlowModal, {
publishOptions: this.publishOptions,
saveTask: this.publishTask
saveTask: this.publishTask,
togglePreviewPublish: this.togglePreviewPublish
});
}
}
@ -74,6 +76,38 @@ export default class PublishManagement extends Component {
}
}
@action
openPreview(event) {
event?.preventDefault();
if (!this.previewModal || this.previewModal.isClosing) {
// open publish flow modal underneath to offer quick switching
// without restarting the flow or causing flicker
this.previewModal = this.modals.open(PreviewModal, {
post: this.publishOptions.post,
hasDirtyAttributes: this.args.hasUnsavedChanges,
saveTask: this.saveTask,
togglePreviewPublish: this.togglePreviewPublish
});
}
}
@action
async togglePreviewPublish(event) {
event?.preventDefault();
if (this.previewModal && !this.previewModal.isClosing) {
this.openPublishFlow();
await timeout(160);
this.previewModal.close();
} else if (this.publishFlowModal && !this.publishFlowModal.isClosing) {
this.openPreview();
await timeout(160);
this.publishFlowModal.close();
}
}
async _validatePost() {
this.notifications.closeAlerts('post.save');

View file

@ -1109,7 +1109,7 @@ export default class EditorController extends Controller {
}
if (isServerUnreachableError(error)) {
errorMessage = 'Unable to connect, please check your connection and try again';
errorMessage = 'Unable to connect, please check your internet connection and try again';
} else if (error && isString(error)) {
errorMessage = error;
} else if (error && isEmberArray(error)) {

View file

@ -264,7 +264,7 @@
height: 14px;
}
.gh-date-time-picker-date svg path,
.gh-date-time-picker-date svg path,
.gh-date-time-picker-date svg rect {
fill: none;
stroke: var(--midgrey);
@ -398,8 +398,10 @@
position: absolute;
top: 0;
left: 4.2rem;
width: calc(100% - 4.2rem * 2);
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0;
margin: 30px 0;

View file

@ -30,7 +30,7 @@
<section class="flex" style="pointer-events: auto">
{{#unless this.post.isNew}}
{{#if this.post.isDraft}}
{{#if (and this.post.isDraft (not (feature "publishingFlow")))}}
<div>
<button type="button" class="gh-btn gh-editor-preview-trigger" {{on "click" (action "openPostPreviewModal")}}>
<span>Preview</span>