Added Pintura integration page in Admin (#16686)

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

- adds new integration page for Pintura in Admin
- allows site owners to enable/disable the image editor integration
- allows self-hosters to upload the files for enabling Pintura image
editor

---------

Co-authored-by: Sodbileg Gansukh <sodbileg.gansukh@gmail.com>
This commit is contained in:
Rishabh Garg 2023-04-20 21:20:07 +05:30 committed by GitHub
parent e3fbab9dad
commit d3c6d8ad13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 554 additions and 17 deletions

View File

@ -7,6 +7,7 @@ import {tracked} from '@glimmer/tracking';
export default class KoenigImageEditor extends Component {
@service ajax;
@service feature;
@service settings;
@service ghostPaths;
@tracked scriptLoaded = false;
@tracked cssLoaded = false;
@ -17,11 +18,20 @@ export default class KoenigImageEditor extends Component {
return this.scriptLoaded && this.cssLoaded;
}
get pinturaJsUrl() {
return this.config.pintura?.js || this.settings.pinturaJsUrl;
}
get pinturaCSSUrl() {
return this.config.pintura?.css || this.settings.pinturaCssUrl;
}
getImageEditorJSUrl() {
if (!this.config.pintura?.js) {
let importUrl = this.pinturaJsUrl;
if (!importUrl) {
return null;
}
let importUrl = this.config.pintura.js;
// load the script from admin root if relative
if (importUrl.startsWith('/')) {
@ -31,10 +41,11 @@ export default class KoenigImageEditor extends Component {
}
getImageEditorCSSUrl() {
if (!this.config.pintura?.css) {
let cssImportUrl = this.pinturaCSSUrl;
if (!cssImportUrl) {
return null;
}
let cssImportUrl = this.config.pintura.css;
// load the css from admin root if relative
if (cssImportUrl.startsWith('/')) {
@ -112,8 +123,15 @@ export default class KoenigImageEditor extends Component {
@action
async handleClick() {
if (window.pintura) {
const imageSrc = `${this.args.imageSrc}?v=${Date.now()}`;
if (this.isEditorEnabled && this.args.imageSrc) {
// add a timestamp to the image src to bypass cache
// avoids cors issues with cached images
const imageUrl = new URL(this.args.imageSrc);
if (!imageUrl.searchParams.has('v')) {
imageUrl.searchParams.set('v', Date.now());
}
const imageSrc = imageUrl.href;
const editor = window.pintura.openDefaultEditor({
src: imageSrc,
util: 'crop',
@ -121,11 +139,17 @@ export default class KoenigImageEditor extends Component {
'crop',
'filter',
'finetune',
'redact'
'redact',
'annotate',
'trim',
'frame',
'sticker'
],
locale: {
labelButtonExport: 'Save and close'
}
},
cropEnableButtonToggleCropLimit: true,
cropSelectPresetFilter: true
});
editor.on('loaderror', () => {

View File

@ -0,0 +1,97 @@
import Controller from '@ember/controller';
import config from 'ghost-admin/config/environment';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
const JS_EXTENSION = ['js'];
const JS_MIME_TYPE = ['application/javascript'];
const CSS_EXTENSION = ['css'];
const CSS_MIME_TYPE = ['text/css'];
export default class PinturaController extends Controller {
@service notifications;
@service settings;
@service utils;
@inject config;
@tracked jsSuccess;
@tracked jsFailure;
@tracked cssSuccess;
@tracked cssFailure;
get showUploadSettings() {
return this.settings.pintura && !this.config.pintura;
}
constructor() {
super(...arguments);
this.jsExtension = JS_EXTENSION;
this.jsMimeType = JS_MIME_TYPE;
this.jsAccept = [...this.jsMimeType, ...Array.from(this.jsExtension, extension => '.' + extension)];
this.cssExtension = CSS_EXTENSION;
this.cssMimeType = CSS_MIME_TYPE;
this.cssAccept = [...this.cssMimeType, ...Array.from(this.cssExtension, extension => '.' + extension)];
}
/**
* Opens a file selection dialog - Triggered by "Upload x" buttons,
* searches for the hidden file input within the .gh-setting element
* containing the clicked button then simulates a click
* @param {MouseEvent} event - MouseEvent fired by the button click
*/
@action
triggerFileDialog(event) {
// simulate click to open file dialog
event?.target.closest('.gh-setting-action')?.querySelector('input[type="file"]')?.click();
}
@action
async fileUploadCompleted(fileType, [uploadedFile]) {
let successKey = `${fileType}Success`;
let failureKey = `${fileType}Failure`;
if (!uploadedFile || !uploadedFile.url && !uploadedFile.fileName) {
this[successKey] = false;
this[failureKey] = true;
return; // upload failed
}
this[successKey] = true;
this[failureKey] = false;
window.setTimeout(() => {
this[successKey] = null;
this[failureKey] = null;
}, config.environment === 'test' ? 100 : 5000);
// Save the uploaded file url to the settings
if (fileType === 'js') {
this.settings.pinturaJsUrl = uploadedFile.url;
} else if (fileType === 'css') {
this.settings.pinturaCssUrl = uploadedFile.url;
}
}
@action
update(event) {
this.settings.pintura = event.target.checked;
}
@action
save() {
this.saveTask.perform();
}
@task({drop: true})
*saveTask() {
try {
yield this.settings.validate();
return yield this.settings.save();
} catch (error) {
this.notifications.showAPIError(error);
throw error;
}
}
}

View File

@ -91,6 +91,12 @@ export default Model.extend(ValidationEngine, {
editorDefaultEmailRecipients: attr('string'),
editorDefaultEmailRecipientsFilter: attr('members-segment-string'),
emailVerificationRequired: attr('boolean'),
/**
* Pintura settings
*/
pintura: attr('boolean'),
pinturaJsUrl: attr('string'),
pinturaCssUrl: attr('string'),
// HACK - not a real model attribute but a workaround for Ember Data not
// exposing meta from save responses

View File

@ -103,6 +103,7 @@ Router.map(function () {
this.route('settings.integrations.slack', {path: '/settings/integrations/slack'});
this.route('settings.integrations.amp', {path: '/settings/integrations/amp'});
this.route('settings.integrations.firstpromoter', {path: '/settings/integrations/firstpromoter'});
this.route('settings.integrations.pintura', {path: '/settings/integrations/pintura'});
this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'});
this.route('settings.integrations.zapier', {path: '/settings/integrations/zapier'});

View File

@ -0,0 +1,69 @@
import AdminRoute from 'ghost-admin/routes/admin';
import ConfirmUnsavedChangesModal from '../../../components/modals/confirm-unsaved-changes';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class PinturaIntegrationRoute extends AdminRoute {
@service modals;
@service settings;
model() {
return this.settings.reload();
}
deactivate() {
this.confirmModal = null;
this.hasConfirmed = false;
}
@action
async willTransition(transition) {
if (this.hasConfirmed) {
return true;
}
transition.abort();
// wait for any existing confirm modal to be closed before allowing transition
if (this.confirmModal) {
return;
}
if (this.controller.saveTask?.isRunning) {
await this.controller.saveTask.last;
}
const shouldLeave = await this.confirmUnsavedChanges();
if (shouldLeave) {
this.settings.rollbackAttributes();
this.hasConfirmed = true;
return transition.retry();
}
}
async confirmUnsavedChanges() {
if (this.settings.hasDirtyAttributes) {
this.confirmModal = this.modals
.open(ConfirmUnsavedChangesModal)
.finally(() => {
this.confirmModal = null;
});
return this.confirmModal;
}
return true;
}
@action
save() {
this.controller.send('save');
}
buildRouteInfoMetadata() {
return {
titleToken: 'Pintura'
};
}
}

View File

@ -55,7 +55,7 @@ export default class SettingsService extends Service.extend(ValidationEngine) {
_loadSettings() {
if (!this._loadingPromise) {
this._loadingPromise = this.store
.queryRecord('setting', {group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement'})
.queryRecord('setting', {group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement,pintura'})
.then((settings) => {
this._loadingPromise = null;
return settings;

View File

@ -616,3 +616,30 @@
.apps-card-app-orb.rot-3 {
transform: rotate(270deg);
}
/* Pintura integration
/* ---------------------------------------------------------- */
.gh-pintura-banner.gh-main-section-content {
display: grid;
grid-template-columns: 4fr 3fr;
padding: 0;
font-size: 1.5rem;
}
.gh-pintura-banner-content {
padding: 24px;
}
.gh-pintura-banner-content p {
margin-top: 0.8em;
margin-bottom: 0;
}
.gh-pintura-banner-content .gh-btn {
margin-top: 1.2em;
background-color: var(--black) !important;
}
.gh-pintura-banner-image {
border-radius: 0 3px 3px 0;
}

View File

@ -204,7 +204,31 @@
</article>
</LinkTo>
</div>
{{#if (feature 'imageEditor')}}
<div class="apps-grid-cell" data-test-app="pintura">
<LinkTo @route="settings.integrations.pintura" data-test-link="pintura">
<article class="apps-card-app">
<div class="apps-card-left">
<figure class="apps-card-app-icon id-unsplash" style="background-image:url(assets/img/pintura.png); background-size:36px;"></figure>
<div class="apps-card-meta">
<h3 class="apps-card-app-title">Pintura</h3>
<p class="apps-card-app-desc">Advanced image editing</p>
</div>
</div>
<div class="gh-card-right">
<div class="apps-configured">
{{#if this.settings.firstpromoter}}
<span class="gh-badge" data-test-app-status>Active</span>
{{else}}
<span data-test-app-status>Configure</span>
{{/if}}
{{svg-jar "arrow-right"}}
</div>
</div>
</article>
</LinkTo>
</div>
{{/if}}
</div>
</section>

View File

@ -0,0 +1,175 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header sticky">
<div class="flex flex-column">
<div class="gh-canvas-breadcrumb">
<LinkTo @route="settings">
Settings
</LinkTo>
{{svg-jar "arrow-right-small"}}
<LinkTo @route="settings.integrations" data-test-link="integrations-back">
Integrations
</LinkTo>
{{svg-jar "arrow-right-small"}} Pintura
</div>
</div>
<section class="view-actions">
<GhTaskButton @task={{this.saveTask}} @class="gh-btn gh-btn-primary gh-btn-icon" data-test-save-button={{true}} />
</section>
</GhCanvasHeader>
<section class="view-container">
<section class="gh-main-section app-grid">
<div class="gh-main-section-block app-detail-heading app-grid">
<div class="app-cell">
<img class="app-icon" src="assets/img/pintura.png" alt="Pintura icon"/>
</div>
<div class="app-cell">
<h3>Pintura</h3>
<p>Advanced image editing</p>
</div>
</div>
</section>
{{#unless this.config.pintura}}
<div class="gh-main-section">
<div class="gh-pintura-banner gh-main-section-content grey">
<div class="gh-pintura-banner-content">
<strong>Add advanced image editing to Ghost, with Pintura</strong>
<p>Pintura is a powerful JavaScript image editor that allows you to crop, rotate, annotate and modify images directly inside Ghost.</p>
<p>Try a demo, purchase a license, and download the required CSS/JS files from pqina.nl/pintura/ to activate this feature.</p>
<button type="button" class="gh-btn gh-btn-primary"><span>Find out more →</span></button>
</div>
<img class="gh-pintura-banner-image" src="assets/img/pintura-screenshot.png" alt="Pintura banner">
</div>
</div>
{{/unless}}
<div class="gh-main-section">
<h4 class="gh-main-section-header small bn">Pintura configuration</h4>
<section class="gh-main-section-block">
<div class="gh-main-section-content grey">
<div>
<div class="gh-setting-first {{unless this.showUploadSettings "gh-setting-last"}}">
<div class="gh-setting-content">
<div class="gh-setting-title">Enable Pintura</div>
<div class="gh-setting-desc mb0">Enable <a href="#">Pintura</a> for editing your images in Ghost</div>
</div>
<div class="gh-setting-action">
<div class="for-checkbox">
<label for="pintura" class="checkbox">
<input
type="checkbox"
checked={{this.settings.pintura}}
id="pintura"
name="pintura"
{{on "click" this.update}}
{{!-- onclick={{action "update" value="target.checked"}} --}}
data-test-pintura-checkbox
>
<span class="input-toggle-component"></span>
</label>
</div>
</div>
</div>
{{#unless this.config.pintura}}
{{#liquid-if this.settings.pintura class=""}}
<div class="gh-setting-last gh-setting-firstpromoter-liquid">
<GhUploader
@extensions={{this.jsExtension}}
@uploadUrl="/files/upload/"
@resourceName="files"
@onComplete={{fn this.fileUploadCompleted 'js'}}
as |uploader|
>
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Upload Pintura script</h4>
<p class="gh-expandable-description">Upload the <code>pintura-umd.js</code> file from the Pintura package</p>
</div>
<div class="gh-setting-action flex flex-column items-end">
{{#if uploader.isUploading}}
{{uploader.progressBar}}
{{else}}
<button
type="button"
class="gh-btn gh-btn-icon {{if this.jsSuccess "gh-btn-green"}} {{if this.jsFailure "gh-btn-red"}}"
onclick={{this.triggerFileDialog}}
data-test-button="upload-pintura-js"
>
<span>
{{#if this.jsSuccess}}
{{svg-jar "check-circle"}} Uploaded
{{else if this.jsFailure}}
{{svg-jar "retry"}} Upload Failed
{{else}}
Upload
{{/if}}
</span>
</button>
{{/if}}
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="pintura">{{or error.context error.message}}</div>
{{/each}}
<div style="display:none">
<GhFileInput @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.jsAccept}} data-test-file-input="pintura" />
</div>
</div>
</div>
</GhUploader>
</div>
<div class="gh-setting-last gh-setting-firstpromoter-liquid">
<GhUploader
@extensions={{this.cssExtension}}
@uploadUrl="/files/upload/"
@resourceName="files"
@onComplete={{fn this.fileUploadCompleted 'css'}}
as |uploader|
>
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Upload Pintura styles</h4>
<p class="gh-expandable-description">Upload the <code>pintura.css</code> file from the Pintura package</p>
</div>
<div class="gh-setting-action flex flex-column items-end">
{{#if uploader.isUploading}}
{{uploader.progressBar}}
{{else}}
<button
type="button"
class="gh-btn gh-btn-icon {{if this.cssSuccess "gh-btn-green"}} {{if this.cssFailure "gh-btn-red"}}"
onclick={{this.triggerFileDialog}}
data-test-button="upload-pintura-css"
>
<span>
{{#if this.cssSuccess}}
{{svg-jar "check-circle"}} Uploaded
{{else if this.cssFailure}}
{{svg-jar "retry"}} Upload Failed
{{else}}
Upload
{{/if}}
</span>
</button>
{{/if}}
{{#each uploader.errors as |error|}}
<div class="gh-setting-error" data-test-error="routes">{{or error.context error.message}}</div>
{{/each}}
<div style="display:none">
<GhFileInput @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.cssAccept}} data-test-file-input="pintura" />
</div>
</div>
</div>
</GhUploader>
</div>
{{/liquid-if}}
{{/unless}}
</div>
</div>
</section>
</div>
</section>
</section>

View File

@ -103,6 +103,11 @@ export default [
setting('firstpromoter', 'firstpromoter', false),
setting('firstpromoter', 'firstpromoter_id', null),
// PINTURA
setting('pintura', 'pintura', false),
setting('pintura', 'pintura_js_url', null),
setting('pintura', 'pintura_css_url', null),
// LABS
setting('labs', 'labs', JSON.stringify({
// Keep the GA flags that are not yet cleaned up in frontend code here

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -65,7 +65,10 @@ const EDITABLE_SETTINGS = [
'outbound_link_tagging',
'announcement_content',
'announcement_background',
'announcement_visibility'
'announcement_visibility',
'pintura',
'pintura_js_url',
'pintura_css_url'
];
module.exports = {

View File

@ -58,7 +58,7 @@ const forTag = (attrs) => {
};
const forSetting = (attrs) => {
if (attrs.value && ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image'].includes(attrs.key)) {
if (attrs.value && ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url'].includes(attrs.key)) {
attrs.value = urlUtils.relativeToAbsolute(attrs.value);
}

View File

@ -161,7 +161,7 @@ Settings = ghostBookshelf.Model.extend({
},
formatOnWrite(attrs) {
if (attrs.value && ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image'].includes(attrs.key)) {
if (attrs.value && ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url'].includes(attrs.key)) {
attrs.value = urlUtils.toTransformReady(attrs.value);
}
@ -183,7 +183,7 @@ Settings = ghostBookshelf.Model.extend({
}
// transform URLs from __GHOST_URL__ to absolute
if (['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image'].includes(attrs.key)) {
if (['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url'].includes(attrs.key)) {
attrs.value = urlUtils.transformReadyToAbsolute(attrs.value);
}

View File

@ -292,6 +292,18 @@ Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": false,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "members_enabled",
"value": true,
@ -670,6 +682,18 @@ Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": false,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "members_enabled",
"value": true,
@ -694,7 +718,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3934",
"content-length": "4043",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -996,6 +1020,18 @@ Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": false,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "members_enabled",
"value": true,
@ -1321,6 +1357,18 @@ Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": false,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "members_enabled",
"value": true,
@ -1651,6 +1699,18 @@ Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": false,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "members_enabled",
"value": true,
@ -2069,6 +2129,18 @@ Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": false,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "members_enabled",
"value": true,
@ -2459,6 +2531,18 @@ Object {
"key": "outbound_link_tagging",
"value": true,
},
Object {
"key": "pintura",
"value": false,
},
Object {
"key": "pintura_js_url",
"value": null,
},
Object {
"key": "pintura_css_url",
"value": null,
},
Object {
"key": "members_enabled",
"value": true,

View File

@ -8,7 +8,7 @@ const {stringMatching, anyEtag, anyUuid, anyContentLength, anyContentVersion} =
const models = require('../../../core/server/models');
const {anyErrorId} = matchers;
const CURRENT_SETTINGS_COUNT = 76;
const CURRENT_SETTINGS_COUNT = 79;
const settingsMatcher = {};

View File

@ -5,7 +5,7 @@ const db = require('../../../core/server/data/db');
// Stuff we are testing
const models = require('../../../core/server/models');
const SETTINGS_LENGTH = 87;
const SETTINGS_LENGTH = 90;
describe('Settings Model', function () {
before(models.init);

View File

@ -541,5 +541,27 @@
},
"type": "boolean"
}
},
"pintura": {
"pintura": {
"defaultValue": "false",
"validations": {
"isIn": [
[
"true",
"false"
]
]
},
"type": "boolean"
},
"pintura_js_url": {
"defaultValue": null,
"type": "string"
},
"pintura_css_url": {
"defaultValue": null,
"type": "string"
}
}
}