Ghost/ghost/admin/app/services/theme-management.js
Elena Baidakova f69674ff9a
Fixed announcement bar preview (#16715)
refs TryGhost/Team#3122
- Fixed that preview takes data from user input before saving on
backend.

---

<!-- Leave the line below if you'd like GitHub Copilot to generate a
summary from your commit -->
<!--
copilot:summary
-->
### <samp>🤖 Generated by Copilot at 54d5b2d</samp>

This pull request adds the ability to preview the announcement bar in
the Ghost admin panel and the theme settings. It also adds a
confirmation dialog to discard or save unsaved changes before leaving
the announcement bar settings. It refactors some components and methods
to remove unnecessary or redundant calls to save the settings. It
modifies the `ghost_head` helper, the `theme-management` service, and
the `announcement-bar/src` files to support the preview feature.
2023-04-27 16:40:11 +04:00

238 lines
8.2 KiB
JavaScript

import Service, {inject as service} from '@ember/service';
import config from 'ghost-admin/config/environment';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {isEmpty} from '@ember/utils';
import {isThemeValidationError} from 'ghost-admin/services/ajax';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class ThemeManagementService extends Service {
@service ajax;
@service customThemeSettings;
@service limit;
@service modals;
@service settings;
@service store;
@service frontend;
@service session;
@inject config;
@tracked isUploading;
@tracked previewType = 'homepage';
@tracked previewHtml;
/**
* Contains the active theme object (includes warnings and errors)
*/
@tracked activeTheme;
allPosts = this.store.peekAll('post');
availablePreviewTypes = [{
name: 'homepage',
label: 'Homepage'
}, {
name: 'post',
label: 'Post'
}];
get latestPublishedPost() {
return this.allPosts.toArray().filterBy('status', 'published').sort((a, b) => {
return b.publishedAtUTC.valueOf() - a.publishedAtUTC.valueOf();
}).lastObject;
}
async fetch() {
// contributors don't have permissions to fetch active theme
if (this.session.user && !this.session.user.isContributor) {
try {
const adapter = this.store.adapterFor('theme');
const activeTheme = await adapter.active();
this.activeTheme = activeTheme;
} catch (e) {
// We ignore these errors and log them because we don't want to block loading the app for this
console.error('Failed to fetch active theme', e); // eslint-disable-line no-console
}
}
}
@action
setPreviewType(type) {
if (type !== this.previewType) {
this.previewType = type;
this.updatePreviewHtmlTask.perform();
}
}
@action
async upload(options = {}) {
try {
// Sending a bad string to make sure it fails (empty string isn't valid)
await this.limit.limiter.errorIfWouldGoOverLimit('customThemes', {value: '.'});
} catch (error) {
if (error.errorType === 'HostLimitError') {
return this.modals.open('modals/limits/custom-theme', {
message: error.message
});
}
throw error;
}
return this.modals.open('modals/design/upload-theme', options);
}
@task
*activateTask(theme, options = {}) {
let resultModal = null;
try {
const isOverLimit = yield this.limit.checkWouldGoOverLimit('customThemes', {value: theme.name});
if (isOverLimit) {
try {
yield this.limit.limiter.errorIfWouldGoOverLimit('customThemes', {value: theme.name});
} catch (error) {
if (error.errorType !== 'HostLimitError') {
throw error;
}
resultModal = this.modals.open('modals/limits/custom-theme', {
message: error.message
});
yield resultModal;
return;
}
}
try {
const activatedTheme = yield theme.activate();
this.activeTheme = activatedTheme;
yield this.customThemeSettings.reload();
// must come after settings reload has finished otherwise we'll preview previous theme settings
this.updatePreviewHtmlTask.perform();
if (!options.skipErrors) {
const {warnings, errors} = activatedTheme;
if (!isEmpty(warnings) || !isEmpty(errors)) {
resultModal = this.modals.open('modals/design/theme-errors', {
title: 'Activation <span class="green">successful</span>',
canActivate: true,
warnings,
errors
});
yield resultModal;
}
}
} catch (error) {
if (!options.skipErrors) {
if (isThemeValidationError(error)) {
let errors = error.payload.errors[0].details.errors;
let fatalErrors = [];
let normalErrors = [];
// to have a proper grouping of fatal errors and none fatal, we need to check
// our errors for the fatal property
if (errors.length > 0) {
for (let i = 0; i < errors.length; i += 1) {
if (errors[i].fatal) {
fatalErrors.push(errors[i]);
} else {
normalErrors.push(errors[i]);
}
}
}
resultModal = this.modals.open('modals/design/theme-errors', {
title: 'Activation failed',
canActivate: false,
errors: normalErrors,
fatalErrors
});
yield resultModal;
}
}
throw error;
}
} finally {
// finally is always called even if the task is cancelled which gives
// consumers the ability to cancel the task to clear any opened modals
resultModal?.close();
}
}
@task
*updatePreviewHtmlTask() {
// skip during testing because we don't have mocks for the front-end
if (config.environment === 'test') {
return;
}
let frontendUrl = '/';
if (this.previewType === 'post') {
// in case we haven't loaded any posts so far
if (!this.latestPublishedPost) {
yield this.store.query('post', {filter: 'status:published', order: 'published_at DESC', limit: 1});
}
frontendUrl = this.latestPublishedPost.url;
}
const previewResponse = yield this.frontend.fetch(frontendUrl, {
method: 'POST',
headers: {
'Content-Type': 'text/html;charset=utf-8',
'x-ghost-preview': this.previewData,
Accept: 'text/plain'
}
});
const previewContents = yield previewResponse.text();
// 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
const doctype = new XMLSerializer().serializeToString(htmlDoc.doctype);
this.previewHtml = doctype + htmlDoc.documentElement.outerHTML;
}
get previewData() {
const params = new URLSearchParams();
params.append('c', this.settings.accentColor || '#ffffff');
params.append('d', this.settings.description);
params.append('icon', this.settings.icon);
params.append('logo', this.settings.logo);
params.append('cover', this.settings.coverImage);
if (this.settings.announcementContent) {
params.append('announcement', this.settings.announcementContent);
}
params.append('announcement_bg', this.settings.announcementBackground);
if (this.settings.announcementVisibility.length) {
params.append('announcement_vis', this.settings.announcementVisibility);
}
params.append('custom', JSON.stringify(this.customThemeSettings.keyValueObject));
return params.toString();
}
}