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:
parent
b285205c53
commit
cf8b372fed
17 changed files with 887 additions and 5 deletions
ghost/admin/app
components/editor-labs
modals
publish-management.hbspublish-management.jscontrollers
styles/components
templates
50
ghost/admin/app/components/editor-labs/modals/preview.hbs
Normal file
50
ghost/admin/app/components/editor-labs/modals/preview.hbs
Normal 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>
|
41
ghost/admin/app/components/editor-labs/modals/preview.js
Normal file
41
ghost/admin/app/components/editor-labs/modals/preview.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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> <{{full-email-address (or this.newsletter.senderEmail "noreply")}}>
|
||||
</p>
|
||||
<p><span class="dark">To:</span> Jamie Larson <jamie@example.com></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>
|
161
ghost/admin/app/components/editor-labs/modals/preview/email.js
Normal file
161
ghost/admin/app/components/editor-labs/modals/preview/email.js
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
258
ghost/admin/app/components/editor-labs/modals/preview/social.hbs
Normal file
258
ghost/admin/app/components/editor-labs/modals/preview/social.hbs
Normal 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 you’d 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>
|
209
ghost/admin/app/components/editor-labs/modals/preview/social.js
Normal file
209
ghost/admin/app/components/editor-labs/modals/preview/social.js
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue