Made social/meta text and images in preview modal editable (#1850)

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

- replace titles and descriptions with text fields when clicked
    - save on blur
    - blur+save on <kbd>Enter</kbd>
    - blur+restore on <kbd>Escape</kbd>
    - create newline with <kbd>Shift+Enter</kbd> in description fields
- if there's no available image or fallback show a "+ Add image" header in the previews when hovering
- if there is an image show an upload/change image button when hovering
- show a delete image button when hovering once a custom image has been uploaded

Co-authored-by: Sanne de Vries <sannedv@protonmail.com>
This commit is contained in:
Kevin Ansfield 2021-02-23 18:37:12 +00:00 committed by GitHub
parent a16519baa9
commit ff09a20ac8
4 changed files with 410 additions and 23 deletions

View File

@ -42,8 +42,10 @@ export default Component.extend({
this.send('confirm');
});
key('escape', 'modal', () => {
this.send('closeModal');
key('escape', 'modal', (event) => {
if (!event.target.dataset.preventEscapeCloseModal) {
this.send('closeModal');
}
});
key.setScope('modal');

View File

@ -15,23 +15,95 @@
<span class="gh-social-og-desc w-100 mb2" />
<span class="gh-social-og-desc w-60" />
</div>
<div class="gh-social-og-preview">
{{#if this.facebookImage}}
<div class="gh-social-og-preview-image" style={{background-image-style this.facebookImage}}></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="w-100 ba bw2 bb-0 br4 br--top" {{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" {{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-content">
<div class="gh-social-og-preview-meta">
{{this.config.blogDomain}}
</div>
<div class="gh-social-og-preview-title">{{truncate this.facebookTitle 88}}</div>
<div class="gh-social-og-preview-desc">{{truncate this.facebookDescription}}</div>
<div class="gh-social-og-preview-title editable">
{{#if this.editingFacebookTitle}}
<input
type="text"
class="gh-input"
value={{@post.ogTitle}}
maxlength="300"
title="Facebook title"
{{on "blur" this.setFacebookTitle}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "ogTitle")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
/>
{{else}}
<span class="pointer" {{on "click" this.editFacebookTitle}}>{{truncate this.facebookTitle 88}}</span>
{{/if}}
</div>
{{#if this.editingFacebookDescription}}
<textarea
class="gh-input"
maxlength="500"
title="Facebook description"
{{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">
<span class="pointer" {{on "click" this.editFacebookDescription}}>{{truncate this.facebookDescription}}</span>
</div>
{{/if}}
</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>
@ -45,13 +117,81 @@
<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">
{{#if this.twitterImage}}
<div class="gh-social-twitter-preview-image" style={{background-image-style this.twitterImage}}></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="w-100 ba bw2 bb-0 br4 br--top" {{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" {{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">
<div class="gh-social-twitter-preview-title">{{this.twitterTitle}}</div>
<div class="gh-social-twitter-preview-desc">{{truncate this.twitterDescription}}</div>
{{#if this.editingTwitterTitle}}
<input
type="text"
class="gh-input"
value={{@post.twitterTitle}}
maxlength="300"
title="Twitter title"
{{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"
title="Twitter description"
{{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}}
@ -74,9 +214,39 @@
<div class="gh-seo-preview">
<div class="gh-seo-search-bar mb12">{{svg-jar "google-search"}}</div>
<div class="gh-seo-preview-link">{{this.config.blogDomain}} > {{#if this.ghostPaths.subdir}}{{this.ghostPaths.subdir}} > {{/if}}{{@post.slug}}</div>
<div class="gh-seo-preview-title">{{this.serpTitle}}</div>
<div class="gh-seo-preview-desc">
{{moment-format (now) "DD MMM YYYY"}}{{truncate this.serpDescription 170}}
<div class="gh-seo-preview-title editable">
{{#if this.editingMetaTitle}}
<input
type="text"
class="gh-input"
value={{@post.metaTitle}}
maxlength="300"
title="Meta title"
{{on "blur" this.setMetaTitle}}
{{on-key "Enter" this.blurElement}}
{{on-key "Escape" (fn this.cancelEdit "metaTitle")}}
{{autofocus}}
data-prevent-escape-close-modal="true"
>
{{else}}
<span class="pointer" {{on "click" this.editMetaTitle}}>{{this.serpTitle}}</span>
{{/if}}
</div>
<div class="gh-seo-preview-desc editable">
{{#if this.editingMetaDescription}}
<textarea
class="gh-input"
maxlength="500"
title="Meta description"
{{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}}
<span class="pointer" {{on "click" this.editMetaDescription}}>{{moment-format (now) "DD MMM YYYY"}}{{truncate this.serpDescription 170}}</span>
{{/if}}
</div>
</div>
</div>

View File

@ -1,17 +1,48 @@
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() {
@ -45,6 +76,32 @@ export default class ModalPostPreviewSocialComponent extends Component {
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() {
@ -59,6 +116,51 @@ export default class ModalPostPreviewSocialComponent extends Component {
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() {
@ -72,4 +174,42 @@ export default class ModalPostPreviewSocialComponent extends Component {
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

@ -304,6 +304,17 @@
word-wrap: break-word;
}
.gh-social-og-preview-title.editable:hover {
margin: 2px -1px -1px -1px;
border: 1px solid var(--lightgrey-l1);
background: var(--white);
border-radius: 3px;
}
.gh-social-og-preview-title .gh-input {
margin-top: -2px !important;
}
.gh-social-og-preview-desc {
max-height: 20px;
overflow: hidden;
@ -317,6 +328,15 @@
word-break: break-word;
}
.gh-social-og-preview-desc.editable:hover {
max-height: 24px;
margin: 2px 0 -3px -1px;
padding-bottom: 2px;
border: 1px solid var(--lightgrey-l1);
background: var(--white);
border-radius: 3px;
}
.gh-social-og-reactions {
display: flex;
align-items: center;
@ -384,32 +404,50 @@
.gh-social-twitter-preview-title {
width: 487px;
max-height: 1.3em;
max-height: 20px;
overflow: hidden;
overflow-wrap: break-word;
margin: 0 0 0.15em;
margin: 0 0 2px;
color: #0f1419;
font-size: 15px;
line-height: 1.3125;
line-height: 20px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.gh-social-twitter-preview-title.editable:hover {
max-height: 25px;
margin: -2px 0 -3px -2px;
padding: 1px 0 4px 1px;
border: 1px solid var(--lightgrey);
background: var(--white);
border-radius: 3px;
}
.gh-social-twitter-preview-desc {
width: 487px;
max-height: 40px;
overflow: hidden;
overflow-wrap: break-word;
margin-top: 0.32333em;
margin-top: 5px;
color: #5b7083;
font-size: 15px;
line-height: 1.3125;
line-height: 20px;
font-weight: 400;
text-overflow: ellipsis;
white-space: pre-wrap;
}
.gh-social-twitter-preview-desc.editable:hover {
max-height: 42px;
margin: 4px 0 -1px -2px;
padding: 0 0 1px 1px;
border: 1px solid var(--lightgrey);
background: var(--white);
border-radius: 3px;
}
.gh-social-twitter-preview-meta {
display: flex;
overflow: hidden;
@ -458,6 +496,18 @@
fill: #5b7083;
}
.gh-social-preview-img-delete {
margin-left: 1.2rem;
}
.gh-social-preview-img-delete:hover {
background: var(--red) !important;
}
.gh-social-preview-img-delete svg {
margin: 0 !important;
}
.gh-seo-preview-container {
display: flex;
width: 100%;
@ -534,13 +584,38 @@
-webkit-text-overflow: ellipsis;
}
.gh-seo-preview-title.editable:hover {
margin: 0 0 2px -1px;
padding: 3px 0 0;
background: var(--white);
border: 1px solid var(--lightgrey);
border-radius: 3px;
}
.gh-seo-preview-title .gh-input {
margin-top: -3px;
}
.gh-seo-preview-desc {
max-height: 45px;
overflow: hidden;
color: #4d5156;
font-family: Arial, sans-serif;
font-size: 14px;
line-height: 1.58;
line-height: 22px;
font-weight: 400;
text-overflow: ellipsis;
}
.gh-seo-preview-desc.editable:hover {
max-height: 47px;
margin: -1px 0 -2px -1px;
padding-bottom: 2px;
background: var(--white);
border: 1px solid var(--lightgrey);
border-radius: 3px;
}
.gh-seo-preview-desc .gh-input {
max-width: 100%;
}