0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00

Added general/theme tabs to customize design modal

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

- added tabs to the the customise design modal's sidebar for general settings and theme settings
  - copied settings from the existing branding modal plus site description from general settings screen into the general settings tab
  - theme settings tab left blank ready for static design
- `saveTask` put on the controller so that we can access it from the route, allowing us to pause modal closing when navigating away (implementation left for later)
This commit is contained in:
Kevin Ansfield 2021-09-17 10:27:43 +01:00
parent bdd3a4ac0f
commit 73139ea6dd
8 changed files with 427 additions and 3 deletions

View file

@ -8,16 +8,34 @@
{{on "click" @close}}
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
{{on "mousedown" (optional this.noop)}}
data-test-button="cancel-custom-view-form"
data-test-button="cancel-customize"
>
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Save and close"
@successText="Saved"
@task={{@data.saveTask}}
@idleClass="gh-btn-primary"
@class="gh-btn gh-btn-icon"
data-test-button="save-customize"
/>
</div>
</div>
<div class="gh-branding-settings">
<section class="gh-branding-settings-options">
<div class="gh-btn-group">
<button type="button" class="gh-btn {{if (eq this.tab "general") "gh-btn-group-selected"}}" {{on "click" (fn this.changeTab "general")}}><span>General</span></button>
<button type="button" class="gh-btn {{if (eq this.tab "theme") "gh-btn-group-selected"}}" {{on "click" (fn this.changeTab "theme")}}><span>Theme</span></button>
</div>
{{#if (eq this.tab "general")}}
<Modals::Design::Customize::GeneralSettings @replacePreviewContents={{this.replacePreviewContents}} />
{{else if (eq this.tab "theme")}}
<Modals::Design::Customize::ThemeSettings @replacePreviewContents={{this.replacePreviewContents}} />
{{/if}}
</section>
<section class="gh-branding-settings-right">

View file

@ -1,15 +1,33 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
export default class ModalsDesignAdvancedComponent extends Component {
export default class ModalsDesignCustomizeComponent extends Component {
@service config;
@service settings;
@tracked tab = 'general';
previewIframe = null;
@action
changeTab(tab) {
this.tab = tab;
}
@action
registerPreviewIframe(iframe) {
this.previewIframe = iframe;
}
@action
replacePreviewContents(html) {
if (this.previewIframe) {
this.previewIframe.contentWindow.document.open();
this.previewIframe.contentWindow.document.write(html);
this.previewIframe.contentWindow.document.close();
}
}
}

View file

@ -0,0 +1,185 @@
<div class="gh-stack" ...attributes>
<div class="gh-stack-item">
<div class="gh-setting-content">
<div class="gh-setting-title">Site description</div>
<div class="gh-setting-desc mb3">Used in your theme, meta data and search results</div>
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="icon">{{or error.context error.message}}</div>
{{/each}}
<div class="w-100 flex flex-column flex-row-ns">
<GhErrorMessage @errors={{settings.errors}} @property="description" class="w-100 red"/>
</div>
</div>
<div class="gh-setting-action" class="flex flex-column" data-test-setting="description">
<GhFormGroup @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="description" @class="description-container">
<GhTextInput
@value={{readonly this.settings.description}}
@input={{action (mut this.settings.description) value="target.value"}}
@focus-out={{action "validate" "description" target=this.settings}}
data-test-description-input={{true}}
/>
<GhErrorMessage @errors={{this.settings.errors}} @property="description"/>
</GhFormGroup>
</div>
</div>
<div class="gh-stack-item gh-setting-first gh-accent-color">
<div class="gh-setting-content">
<div class="gh-setting-title">Accent color</div>
<div class="gh-setting-desc">Primary color used in your publication theme</div>
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="icon">{{or error.context error.message}}</div>
{{/each}}
<div class="w-100 flex flex-column flex-row-ns">
<GhErrorMessage @errors={{settings.errors}} @property="accentColor" class="w-100 red"/>
</div>
</div>
<div class="gh-setting-action" data-test-setting="accentColor">
<GhFormGroup
@errors={{this.settings.errors}}
@hasValidated={{this.settings.hasValidated}}
@property="accentColor"
@class="input-color-form-group"
>
<div class="input-color">
<input
type="text"
placeholder="15171A"
name="accent-color"
autocorrect="off"
maxlength="6"
value={{this.accentColor}}
class="gh-input"
{{on "input" (perform this.debounceUpdateAccentColor)}}
{{on "blur" this.updateAccentColor}}
{{on-key "Enter" this.blurElement}}
data-test-input="accentColor"
/>
<div class="color-picker-horizontal-divider"></div>
<div
class="color-box-container"
style={{this.accentColorBgStlye}}
>
<input
type="color"
name="accent-color"
class="color-picker"
value="{{this.accentColorPickerValue}}"
{{on "input" (perform this.debounceUpdateAccentColor)}}
>
</div>
</div>
</GhFormGroup>
</div>
</div>
<div class="gh-stack-item gh-setting" data-test-setting="icon">
<GhUploader
@extensions={{this.iconExtensions}}
@paramsHash={{hash purpose="icon"}}
@onComplete={{fn this.imageUploaded "icon"}}
as |uploader|
>
<div class="gh-setting-content">
<div class="gh-setting-title">Publication icon</div>
<div class="gh-setting-desc">A square, social icon used in the UI of your publication, at least 60x60px</div>
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="icon">{{or error.context error.message}}</div>
{{/each}}
</div>
<div class="gh-setting-action gh-uploadbutton-container gh-setting-action-smallimg flex flex-column">
{{#if uploader.isUploading}}
{{uploader.progressBar}}
{{else if this.settings.icon}}
<div class="gh-branding-image-container transparent-bg">
<img class="blog-icon" src="{{this.settings.icon}}" {{on "click" this.triggerFileDialog}} alt="icon" data-test-icon-img>
<button type="button" class="gh-setting-action-smallimg-delete" {{on "click" (fn this.removeImage "icon")}} data-test-delete-image="icon">
{{svg-jar "trash" class="w4 h4 fill-white"}}
</button>
</div>
{{else}}
<button type="button" class="gh-btn self-center" {{on "click" triggerFileDialog}} data-test-image-upload-btn="icon">
<span>Upload icon</span>
</button>
{{/if}}
<div style="display:none">
<GhFileInput @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.iconMimeTypes}} data-test-file-input="icon" />
</div>
</div>
</GhUploader>
</div>
<div class="gh-stack-item gh-setting" data-test-setting="logo">
<GhUploader
@extensions={{this.imageExtensions}}
@onComplete={{fn this.imageUploaded "logo"}}
as |uploader|
>
<div>
<div class="gh-setting-title">Publication logo</div>
<div class="gh-setting-desc mb3">The primary logo for your brand displayed across your theme, should be transparent and at least 600px x 72px</div>
<div class="gh-setting-action gh-uploadbutton-container gh-setting-action-smallimg flex flex-column">
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="logo">{{or error.context error.message}}</div>
{{/each}}
{{#if uploader.isUploading}}
{{uploader.progressBar}}
{{else if this.settings.logo}}
<div class="gh-branding-image-container largeimg justify-center transparent-bg">
<img class="blog-logo" src="{{this.settings.logo}}" {{on "click" this.triggerFileDialog}} alt="logo" data-test-logo-img>
<button type="button" class="gh-setting-action-smallimg-delete" {{on "click" (fn this.removeImage "logo")}} data-test-delete-image="logo">
{{svg-jar "trash" class="w4 h4 fill-white"}}
</button>
</div>
{{else}}
<button type="button" class="gh-btn self-start" {{on "click" this.triggerFileDialog}} data-test-image-upload-btn="logo">
<span>Upload logo</span>
</button>
{{/if}}
<div style="display:none">
<GhFileInput @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.imageMimeTypes}} data-test-file-input="logo" />
</div>
</div>
</div>
</GhUploader>
</div>
<div class="gh-stack-item gh-setting" data-test-setting="coverImage">
<GhUploader
@extensions={{this.imageExtensions}}
@onComplete={{fn this.imageUploaded "coverImage"}}
as |uploader|
>
<div>
<div class="gh-setting-title">Publication cover</div>
<div class="gh-setting-desc mb3">An optional large background image for your site</div>
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="coverImage">{{or error.context error.message}}</div>
{{/each}}
<div class="gh-setting-action gh-uploadbutton-container flex flex-column items-stretch">
{{#if uploader.isUploading}}
{{uploader.progressBar}}
{{else if this.settings.coverImage}}
<div class="gh-branding-image-container largeimg justify-start">
<img class="blog-cover" src="{{this.settings.coverImage}}" {{on "click" this.triggerFileDialog}} alt="cover photo" data-test-cover-img>
<button type="button" class="gh-setting-action-largeimg-delete" {{on "click" (fn this.removeImage "coverImage")}} data-test-delete-image="coverImage">
{{svg-jar "trash" class="w4 h4 fill-white"}}
</button>
</div>
{{else}}
<button type="button" class="gh-btn self-start" {{on "click" this.triggerFileDialog}} data-test-image-upload-btn="coverImage">
<span>Upload cover</span>
</button>
{{/if}}
<div style="display:none">
<GhFileInput @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.imageMimeTypes}} data-test-file-input="coverImage" />
</div>
</div>
</div>
</GhUploader>
</div>
</div>

View file

@ -0,0 +1,170 @@
import Component from '@glimmer/component';
import config from 'ghost-admin/config/environment';
import {
ICON_EXTENSIONS,
ICON_MIME_TYPES,
IMAGE_EXTENSIONS,
IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {action} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {timeout} from 'ember-concurrency';
export default class DesignTabGeneralSettingsComponent extends Component {
@service ajax;
@service config;
@service ghostPaths;
@service settings;
iconExtensions = ICON_EXTENSIONS;
iconMimeTypes = ICON_MIME_TYPES;
imageExtensions = IMAGE_EXTENSIONS;
imageMimeTypes = IMAGE_MIME_TYPES;
get accentColor() {
const color = this.settings.get('accentColor');
if (color && color[0] === '#') {
return color.slice(1);
}
return color;
}
get accentColorPickerValue() {
return this.settings.get('accentColor') || '#ffffff';
}
get accentColorBgStyle() {
return htmlSafe(`background-color: ${this.accentColorPickerValue}`);
}
get previewData() {
const params = new URLSearchParams();
params.append('c', this.accentColorPickerValue);
params.append('icon', this.settings.get('icon'));
params.append('logo', this.settings.get('logo'));
params.append('cover', this.settings.get('coverImage'));
return params.toString();
}
constructor() {
super(...arguments);
this.updatePreviewTask.perform();
}
willDestroy() {
super.willDestroy?.(...arguments);
this.settings.errors.remove('accentColor');
this.settings.rollbackAttributes();
}
@action
triggerFileDialog({target}) {
target.closest('.gh-setting-action')?.querySelector('input[type="file"]')?.click();
}
@action
async imageUploaded(property, results) {
if (results[0]) {
this.settings.set(property, results[0].url);
this.updatePreviewTask.perform();
}
}
@action
async removeImage(imageName) {
this.settings.set(imageName, '');
this.updatePreviewTask.perform();
}
@action
blurElement(event) {
event.preventDefault();
event.target.blur();
}
@action
async updateAccentColor(event) {
let newColor = event.target.value;
const oldColor = this.settings.get('accentColor');
// reset errors and validation
this.settings.errors.remove('accentColor');
this.settings.hasValidated.removeObject('accentColor');
if (newColor === '') {
if (newColor === oldColor) {
return;
}
// Don't allow empty accent color
this.settings.errors.add('accentColor', 'Please select an accent color');
this.settings.hasValidated.pushObject('accentColor');
return;
}
// accentColor will be null unless the user has input something
if (!newColor) {
newColor = oldColor;
}
if (newColor[0] !== '#') {
newColor = `#${newColor}`;
}
if (newColor.match(/#[0-9A-Fa-f]{6}$/)) {
if (newColor === oldColor) {
return;
}
this.settings.set('accentColor', newColor);
this.updatePreviewTask.perform();
} else {
this.settings.errors.add('accentColor', 'Please enter a color in hex format');
this.settings.hasValidated.pushObject('accentColor');
}
}
@task({restartable: true})
*debounceUpdateAccentColor(event) {
yield timeout(500);
this.updateAccentColor(event);
}
@task
*updatePreviewTask() {
// skip during testing because we don't have mocks for the front-end
if (config.environment === 'test') {
return;
}
// grab the preview html
const ajaxOptions = {
contentType: 'text/html;charset=utf-8',
dataType: 'text',
headers: {
'x-ghost-preview': this.previewData
}
};
// TODO: config.blogUrl always removes trailing slash - switch to always have trailing slash
const frontendUrl = `${this.config.get('blogUrl')}/`;
const previewContents = yield this.ajax.post(frontendUrl, ajaxOptions);
// inject extra CSS to disable navigation and prevent clicks
const injectedCss = `html { pointer-events: none; }`;
const domParser = new DOMParser();
const htmlDoc = domParser.parseFromString(previewContents, 'text/html');
const stylesheet = htmlDoc.querySelector('style');
const originalCSS = stylesheet.innerHTML;
stylesheet.innerHTML = `${originalCSS}\n\n${injectedCss}`;
// replace the iframe contents with the doctored preview html
this.args.replacePreviewContents(htmlDoc.documentElement.innerHTML);
}
}

View file

@ -0,0 +1 @@
Theme

View file

@ -0,0 +1,25 @@
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
export default class SettingsDesignCustomizeController extends Controller {
@service settings;
@service router;
@task
*saveTask() {
try {
if (this.settings.get('errors').length !== 0) {
return;
}
yield this.settings.save();
this.router.transitionTo('settings.design');
return true;
} catch (error) {
if (error) {
this.notifications.showAPIError(error);
throw error;
}
}
}
}

View file

@ -3,6 +3,7 @@ import {inject as service} from '@ember/service';
export default class DashboardRoute extends AuthenticatedRoute {
@service feature;
@service settings;
beforeModel() {
super.beforeModel(...arguments);
@ -16,6 +17,10 @@ export default class DashboardRoute extends AuthenticatedRoute {
}
}
model() {
return this.settings.reload();
}
buildRouteInfoMetadata() {
return {
mainClasses: ['gh-main-wide']

View file

@ -21,7 +21,9 @@ export default class SettingsDesignCustomizeRoute extends AuthenticatedRoute {
}
activate() {
this.customizeModal = this.modals.open('modals/design/customize', {}, {
this.customizeModal = this.modals.open('modals/design/customize', {
saveTask: this.controllerFor('settings.design.customize').saveTask
}, {
className: 'fullscreen-modal-full-overlay fullscreen-modal-branding-modal',
beforeClose: bind(this, this.beforeModalClose)
});