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:
parent
bdd3a4ac0f
commit
73139ea6dd
8 changed files with 427 additions and 3 deletions
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
Theme
|
25
ghost/admin/app/controllers/settings/design/customize.js
Normal file
25
ghost/admin/app/controllers/settings/design/customize.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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']
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue