Some fixes and optimizations in sandboxes.
Start of a sandbox for GrapeJS
This commit is contained in:
parent
02a7275ae4
commit
e2093e22fe
22 changed files with 742 additions and 294 deletions
|
@ -24,6 +24,7 @@ const reports = require('./routes/reports');
|
|||
const subscription = require('./routes/subscription');
|
||||
const sandboxedMosaico = require('./routes/sandboxed-mosaico');
|
||||
const sandboxedCKEditor = require('./routes/sandboxed-ckeditor');
|
||||
const sandboxedGrapesJS = require('./routes/sandboxed-grapesjs');
|
||||
const files = require('./routes/files');
|
||||
const links = require('./routes/links');
|
||||
const archive = require('./routes/archive');
|
||||
|
@ -224,6 +225,7 @@ function createApp(appType) {
|
|||
|
||||
useWith404Fallback('/mosaico', sandboxedMosaico.getRouter(appType));
|
||||
useWith404Fallback('/ckeditor', sandboxedCKEditor.getRouter(appType));
|
||||
useWith404Fallback('/grapesjs', sandboxedGrapesJS.getRouter(appType));
|
||||
|
||||
if (appType === AppType.TRUSTED || appType === AppType.SANDBOXED) {
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
|
|
10
client/package-lock.json
generated
10
client/package-lock.json
generated
|
@ -3837,6 +3837,16 @@
|
|||
"ua-parser-js": "0.7.17"
|
||||
}
|
||||
},
|
||||
"file-loader": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-2.0.0.tgz",
|
||||
"integrity": "sha512-YCsBfd1ZGCyonOKLxPiKPdu+8ld9HAaMEvJewzz+b2eTF7uL5Zm/HdBF6FjCrpCMRq25Mi0U1gl4pwn2TlH7hQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"loader-utils": "1.1.0",
|
||||
"schema-utils": "1.0.0"
|
||||
}
|
||||
},
|
||||
"filename-regex": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz",
|
||||
|
|
|
@ -71,6 +71,7 @@
|
|||
"babel-preset-react": "^6.24.1",
|
||||
"babel-preset-stage-1": "^6.24.1",
|
||||
"css-loader": "^0.28.4",
|
||||
"file-loader": "^2.0.0",
|
||||
"i18next-conv": "^3.0.3",
|
||||
"node-sass": "^4.5.3",
|
||||
"postcss-loader": "^3.0.0",
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
|
||||
import 'grapesjs/dist/css/grapes.min.css';
|
||||
import grapesjs from 'grapesjs';
|
||||
import grapesjsMjml from 'grapesjs-mjml';
|
||||
|
||||
export default class GrapesJs extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = grapesjs.init({
|
||||
container: this.canvasNode,
|
||||
height: '100%',
|
||||
width: 'auto',
|
||||
noticeOnUnload: 0,
|
||||
storageManager:{autoload: 0},
|
||||
fromElement: false,
|
||||
components:
|
||||
' <mj-container>\n' +
|
||||
' <mj-section>\n' +
|
||||
' <mj-column>\n' +
|
||||
' <mj-text>My Company</mj-text>\n' +
|
||||
' </mj-column>\n' +
|
||||
' </mj-section>\n' +
|
||||
' <mj-container>',
|
||||
plugins: ['gjs-mjml'],
|
||||
pluginsOpts: {
|
||||
'gjs-mjml': {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div ref={node => this.canvasNode = node}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
.gjs-cv-canvas {
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
import {getUrl} from "./urls";
|
||||
|
||||
__webpack_public_path__ = getUrl();
|
||||
__webpack_public_path__ = getUrl('mailtrain/');
|
||||
|
|
|
@ -2,12 +2,133 @@
|
|||
|
||||
import './public-path';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {I18nextProvider,} from 'react-i18next';
|
||||
import i18n from './i18n';
|
||||
import {CKEditorSandbox} from './sandboxed-ckeditor';
|
||||
import {UntrustedContentRoot, parentRPC} from './untrusted';
|
||||
import React, {Component} from 'react';
|
||||
import ReactDOM
|
||||
from 'react-dom';
|
||||
import {
|
||||
I18nextProvider,
|
||||
translate,
|
||||
} from 'react-i18next';
|
||||
import i18n
|
||||
from './i18n';
|
||||
import {
|
||||
parentRPC,
|
||||
UntrustedContentRoot
|
||||
} from './untrusted';
|
||||
import PropTypes
|
||||
from "prop-types";
|
||||
import styles
|
||||
from "./sandboxed-ckeditor.scss";
|
||||
import {
|
||||
getPublicUrl,
|
||||
getSandboxUrl,
|
||||
getTrustedUrl
|
||||
} from "./urls";
|
||||
import {
|
||||
base,
|
||||
unbase
|
||||
} from "../../../shared/templates";
|
||||
|
||||
import CKEditor
|
||||
from "react-ckeditor-component";
|
||||
|
||||
import { initialHeight } from "./sandboxed-ckeditor-shared";
|
||||
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
class CKEditorSandbox extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
const html = this.props.initialHtml && base(this.props.initialHtml, trustedUrlBase, sandboxUrlBase, publicUrlBase);
|
||||
|
||||
this.state = {
|
||||
html
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
entityTypeId: PropTypes.string,
|
||||
entityId: PropTypes.number,
|
||||
initialHtml: PropTypes.string
|
||||
}
|
||||
|
||||
async exportState(method, params) {
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
return {
|
||||
html: unbase(this.state.html, trustedUrlBase, sandboxUrlBase, publicUrlBase, true)
|
||||
};
|
||||
}
|
||||
|
||||
async setHeight(methods, params) {
|
||||
this.node.editorInstance.resize('100%', params);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
parentRPC.setMethodHandler('exportState', ::this.exportState);
|
||||
parentRPC.setMethodHandler('setHeight', ::this.setHeight);
|
||||
}
|
||||
|
||||
render() {
|
||||
const config = {
|
||||
toolbarGroups: [
|
||||
{
|
||||
name: "document",
|
||||
groups: ["document", "doctools"]
|
||||
},
|
||||
{
|
||||
name: "clipboard",
|
||||
groups: ["clipboard", "undo"]
|
||||
},
|
||||
{name: "styles"},
|
||||
{
|
||||
name: "basicstyles",
|
||||
groups: ["basicstyles", "cleanup"]
|
||||
},
|
||||
{
|
||||
name: "editing",
|
||||
groups: ["find", "selection", "spellchecker"]
|
||||
},
|
||||
{name: "forms"},
|
||||
{
|
||||
name: "paragraph",
|
||||
groups: ["list",
|
||||
"indent", "blocks", "align", "bidi"]
|
||||
},
|
||||
{name: "links"},
|
||||
{name: "insert"},
|
||||
{name: "colors"},
|
||||
{name: "tools"},
|
||||
{name: "others"},
|
||||
{
|
||||
name: "document-mode",
|
||||
groups: ["mode"]
|
||||
}
|
||||
],
|
||||
|
||||
removeButtons: 'Underline,Subscript,Superscript,Maximize',
|
||||
resize_enabled: false,
|
||||
height: initialHeight
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.sandbox}>
|
||||
<CKEditor ref={node => this.node = node}
|
||||
content={this.state.html}
|
||||
events={{
|
||||
change: evt => this.setState({html: evt.editor.getData()}),
|
||||
}}
|
||||
config={config}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function() {
|
||||
parentRPC.init();
|
||||
|
|
3
client/src/lib/sandboxed-ckeditor-shared.js
Normal file
3
client/src/lib/sandboxed-ckeditor-shared.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
'use strict';
|
||||
|
||||
export const initialHeight = 600;
|
|
@ -2,23 +2,16 @@
|
|||
|
||||
import React, {Component} from 'react';
|
||||
import {translate} from 'react-i18next';
|
||||
import PropTypes from "prop-types";
|
||||
import styles from "./sandboxed-ckeditor.scss";
|
||||
import PropTypes
|
||||
from "prop-types";
|
||||
import styles
|
||||
from "./sandboxed-ckeditor.scss";
|
||||
|
||||
import {UntrustedContentHost, parentRPC} from './untrusted';
|
||||
import {UntrustedContentHost} from './untrusted';
|
||||
import {Icon} from "./bootstrap-components";
|
||||
import {
|
||||
getPublicUrl,
|
||||
getSandboxUrl,
|
||||
getTrustedUrl
|
||||
} from "./urls";
|
||||
import {
|
||||
base,
|
||||
unbase
|
||||
} from "../../../shared/templates";
|
||||
import CKEditor from "react-ckeditor-component";
|
||||
import {getTrustedUrl} from "./urls";
|
||||
|
||||
const initialHeight = 600;
|
||||
import { initialHeight } from "./sandboxed-ckeditor-shared";
|
||||
const navbarHeight = 34; // Sync this with navbarheight in sandboxed-ckeditor.scss
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
|
@ -108,62 +101,3 @@ CKEditorHost.prototype.exportState = async function() {
|
|||
};
|
||||
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
export class CKEditorSandbox extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
const html = this.props.initialHtml && base(this.props.initialHtml, trustedUrlBase, sandboxUrlBase, publicUrlBase);
|
||||
|
||||
this.state = {
|
||||
html
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
entityTypeId: PropTypes.string,
|
||||
entityId: PropTypes.number,
|
||||
initialHtml: PropTypes.string
|
||||
}
|
||||
|
||||
async exportState(method, params) {
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
return {
|
||||
html: unbase(this.state.html, trustedUrlBase, sandboxUrlBase, publicUrlBase, true)
|
||||
};
|
||||
}
|
||||
|
||||
async setHeight(methods, params) {
|
||||
this.node.editorInstance.resize('100%', params);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
parentRPC.setMethodHandler('exportState', ::this.exportState);
|
||||
parentRPC.setMethodHandler('setHeight', ::this.setHeight);
|
||||
}
|
||||
|
||||
render() {
|
||||
const config = {
|
||||
removeButtons: 'Underline,Subscript,Superscript,Maximize',
|
||||
resize_enabled: false,
|
||||
height: initialHeight
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.sandbox}>
|
||||
<CKEditor ref={node => this.node = node}
|
||||
content={this.state.html}
|
||||
events={{
|
||||
change: evt => this.setState({html: evt.editor.getData()}),
|
||||
}}
|
||||
config={config}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
157
client/src/lib/sandboxed-grapesjs-root.js
Normal file
157
client/src/lib/sandboxed-grapesjs-root.js
Normal file
|
@ -0,0 +1,157 @@
|
|||
'use strict';
|
||||
|
||||
import './public-path';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import ReactDOM
|
||||
from 'react-dom';
|
||||
import {
|
||||
I18nextProvider,
|
||||
translate,
|
||||
} from 'react-i18next';
|
||||
import i18n
|
||||
from './i18n';
|
||||
import {
|
||||
parentRPC,
|
||||
UntrustedContentRoot
|
||||
} from './untrusted';
|
||||
import PropTypes
|
||||
from "prop-types";
|
||||
import {
|
||||
getPublicUrl,
|
||||
getSandboxUrl,
|
||||
getTrustedUrl
|
||||
} from "./urls";
|
||||
import {
|
||||
base,
|
||||
unbase
|
||||
} from "../../../shared/templates";
|
||||
|
||||
import 'grapesjs/dist/css/grapes.min.css';
|
||||
import grapesjs from 'grapesjs';
|
||||
import "grapesjs-mjml";
|
||||
|
||||
import "./sandboxed-grapesjs.scss";
|
||||
|
||||
grapesjs.plugins.add('mailtrain', (editor, opts = {}) => {
|
||||
const panelManager = editor.Panels;
|
||||
panelManager.removeButton('options','fullscreen')
|
||||
});
|
||||
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
export class GrapesJSSandbox extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
entityTypeId: PropTypes.string,
|
||||
entityId: PropTypes.number,
|
||||
initialModel: PropTypes.object
|
||||
}
|
||||
|
||||
async exportState(method, params) {
|
||||
const editor = this.editor;
|
||||
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
|
||||
let html;
|
||||
html = unbase(editor.getHtml(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
|
||||
|
||||
const model = {
|
||||
css: editor.getCss(),
|
||||
source: editor.getHtml(),
|
||||
};
|
||||
|
||||
console.log(model.css);
|
||||
console.log(model.source);
|
||||
|
||||
return {
|
||||
html,
|
||||
model
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
parentRPC.setMethodHandler('exportState', ::this.exportState);
|
||||
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
|
||||
const model = this.props.initialModel || {}
|
||||
|
||||
const source = model.source && base(model.source, trustedUrlBase, sandboxUrlBase, publicUrlBase);
|
||||
const css = model.css && base(model.css, trustedUrlBase, sandboxUrlBase, publicUrlBase);
|
||||
|
||||
/*
|
||||
' <mj-container>\n' +
|
||||
' <mj-section>\n' +
|
||||
' <mj-column>\n' +
|
||||
' <mj-text>My Company</mj-text>\n' +
|
||||
' </mj-column>\n' +
|
||||
' </mj-section>\n' +
|
||||
' <mj-container>',
|
||||
*/
|
||||
|
||||
this.editor = grapesjs.init({
|
||||
container: this.canvasNode,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
storageManager:{
|
||||
type: 'none'
|
||||
},
|
||||
assetManager: {
|
||||
assets: [],
|
||||
upload: '/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{editor.name}}',
|
||||
uploadText: 'Drop images here or click to upload',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{csrfToken}}',
|
||||
},
|
||||
},
|
||||
styleManager: {
|
||||
clearProperties: true,
|
||||
},
|
||||
fromElement: false,
|
||||
components: source,
|
||||
style: css,
|
||||
plugins: [
|
||||
'mailtrain',
|
||||
'gjs-mjml'
|
||||
],
|
||||
pluginsOpts: {
|
||||
'gjs-mjml': {}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div ref={node => this.canvasNode = node}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function() {
|
||||
parentRPC.init();
|
||||
|
||||
ReactDOM.render(
|
||||
<I18nextProvider i18n={ i18n }>
|
||||
<UntrustedContentRoot render={props => <GrapesJSSandbox {...props} />} />
|
||||
</I18nextProvider>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
};
|
||||
|
||||
|
73
client/src/lib/sandboxed-grapesjs.js
Normal file
73
client/src/lib/sandboxed-grapesjs.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import {translate} from 'react-i18next';
|
||||
import PropTypes
|
||||
from "prop-types";
|
||||
import styles
|
||||
from "./sandboxed-grapesjs.scss";
|
||||
|
||||
import {UntrustedContentHost} from './untrusted';
|
||||
import {Icon} from "./bootstrap-components";
|
||||
import {getTrustedUrl} from "./urls";
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
export class GrapesJSHost extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
fullscreen: false
|
||||
}
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
entityTypeId: PropTypes.string,
|
||||
entity: PropTypes.object,
|
||||
initialModel: PropTypes.object,
|
||||
title: PropTypes.string,
|
||||
onFullscreenAsync: PropTypes.func
|
||||
}
|
||||
|
||||
async toggleFullscreenAsync() {
|
||||
const fullscreen = !this.state.fullscreen;
|
||||
this.setState({
|
||||
fullscreen
|
||||
});
|
||||
await this.props.onFullscreenAsync(fullscreen);
|
||||
}
|
||||
|
||||
async exportState() {
|
||||
return await this.contentNode.ask('exportState');
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
const editorData = {
|
||||
entityTypeId: this.props.entityTypeId,
|
||||
entityId: this.props.entity.id,
|
||||
initialModel: this.props.initialModel
|
||||
};
|
||||
|
||||
const tokenData = {
|
||||
entityTypeId: this.props.entityTypeId,
|
||||
entityId: this.props.entity.id
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
|
||||
<div className={styles.navbar}>
|
||||
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
|
||||
<div className={styles.title}>{this.props.title}</div>
|
||||
<a className={styles.btn} onClick={::this.toggleFullscreenAsync}><Icon icon="fullscreen"/></a>
|
||||
</div>
|
||||
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GrapesJSHost.prototype.exportState = async function() {
|
||||
return await this.getWrappedInstance().exportState();
|
||||
};
|
86
client/src/lib/sandboxed-grapesjs.scss
Normal file
86
client/src/lib/sandboxed-grapesjs.scss
Normal file
|
@ -0,0 +1,86 @@
|
|||
$navbarHeight: 34px;
|
||||
|
||||
.editor {
|
||||
.host {
|
||||
height: 800px;
|
||||
}
|
||||
}
|
||||
|
||||
.editorFullscreen {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
margin-top: $navbarHeight;
|
||||
|
||||
.navbar {
|
||||
margin-top: -$navbarHeight;
|
||||
}
|
||||
|
||||
.host {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: #DE4320;
|
||||
width: 100%;
|
||||
height: $navbarHeight;
|
||||
}
|
||||
|
||||
.logo {
|
||||
float: left;
|
||||
height: $navbarHeight;
|
||||
padding: 5px 0 5px 10px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 5px 0 5px 10px;
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
font-family: "Ubuntu",Tahoma,"Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
font-weight: bold;
|
||||
float: left;
|
||||
color: white;
|
||||
height: $navbarHeight;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
float: right;
|
||||
padding: 0px 15px;
|
||||
line-height: $navbarHeight;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
font-family: sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #b1381e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
:global .grapesjs-body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
:global .gjs-editor-cont {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:global .gjs-devices-c .gjs-devices {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
:global .gjs-pn-devices-c, :global .gjs-pn-views {
|
||||
padding: 4px;
|
||||
}
|
||||
|
|
@ -2,12 +2,120 @@
|
|||
|
||||
import './public-path';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {I18nextProvider,} from 'react-i18next';
|
||||
import i18n from './i18n';
|
||||
import {MosaicoSandbox} from './sandboxed-mosaico';
|
||||
import {UntrustedContentRoot, parentRPC} from './untrusted';
|
||||
import React, {Component} from 'react';
|
||||
import ReactDOM
|
||||
from 'react-dom';
|
||||
import {
|
||||
I18nextProvider,
|
||||
translate,
|
||||
} from 'react-i18next';
|
||||
import i18n
|
||||
from './i18n';
|
||||
import {
|
||||
parentRPC,
|
||||
UntrustedContentRoot
|
||||
} from './untrusted';
|
||||
import PropTypes
|
||||
from "prop-types";
|
||||
import {
|
||||
getPublicUrl,
|
||||
getSandboxUrl,
|
||||
getTrustedUrl
|
||||
} from "./urls";
|
||||
import {
|
||||
base,
|
||||
unbase
|
||||
} from "../../../shared/templates";
|
||||
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
class MosaicoSandbox extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.viewModel = null;
|
||||
this.state = {
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
entityTypeId: PropTypes.string,
|
||||
entityId: PropTypes.number,
|
||||
templateId: PropTypes.number,
|
||||
templatePath: PropTypes.string,
|
||||
initialModel: PropTypes.string,
|
||||
initialMetadata: PropTypes.string
|
||||
}
|
||||
|
||||
async exportState(method, params) {
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
return {
|
||||
html: unbase(this.viewModel.exportHTML(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
|
||||
model: unbase(this.viewModel.exportJSON(), trustedUrlBase, sandboxUrlBase, publicUrlBase),
|
||||
metadata: unbase(this.viewModel.exportMetadata(), trustedUrlBase, sandboxUrlBase, publicUrlBase)
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
parentRPC.setMethodHandler('exportState', ::this.exportState);
|
||||
|
||||
if (!Mosaico.isCompatible()) {
|
||||
alert('Update your browser!');
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = [...window.mosaicoPlugins];
|
||||
|
||||
plugins.push(viewModel => {
|
||||
this.viewModel = viewModel;
|
||||
});
|
||||
|
||||
// (Custom) HTML postRenderers
|
||||
plugins.push(viewModel => {
|
||||
viewModel.originalExportHTML = viewModel.exportHTML;
|
||||
viewModel.exportHTML = () => {
|
||||
let html = viewModel.originalExportHTML();
|
||||
for (const portRender of window.mosaicoHTMLPostRenderers) {
|
||||
html = postRender(html);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
});
|
||||
|
||||
plugins.unshift(vm => {
|
||||
// This is an override of the default paths in Mosaico
|
||||
vm.logoPath = getTrustedUrl('static/mosaico/img/mosaico32.png');
|
||||
vm.logoUrl = '#';
|
||||
});
|
||||
|
||||
const config = {
|
||||
imgProcessorBackend: getTrustedUrl('mosaico/img'),
|
||||
emailProcessorBackend: getSandboxUrl('mosaico/dl'),
|
||||
fileuploadConfig: {
|
||||
url: getSandboxUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`)
|
||||
},
|
||||
strings: window.mosaicoLanguageStrings
|
||||
};
|
||||
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
const metadata = this.props.initialMetadata && JSON.parse(base(this.props.initialMetadata, trustedUrlBase, sandboxUrlBase, publicUrlBase));
|
||||
const model = this.props.initialModel && JSON.parse(base(this.props.initialModel, trustedUrlBase, sandboxUrlBase, publicUrlBase));
|
||||
const template = this.props.templateId ? getSandboxUrl(`mosaico/templates/${this.props.templateId}/index.html`) : this.props.templatePath;
|
||||
|
||||
const allPlugins = plugins.concat(window.mosaicoPlugins);
|
||||
|
||||
Mosaico.start(config, template, metadata, model, allPlugins);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function() {
|
||||
parentRPC.init();
|
||||
|
|
|
@ -2,24 +2,18 @@
|
|||
|
||||
import React, {Component} from 'react';
|
||||
import {translate} from 'react-i18next';
|
||||
import PropTypes from "prop-types";
|
||||
import styles from "./sandboxed-mosaico.scss";
|
||||
import PropTypes
|
||||
from "prop-types";
|
||||
import styles
|
||||
from "./sandboxed-mosaico.scss";
|
||||
|
||||
import {UntrustedContentHost, parentRPC} from './untrusted';
|
||||
import {UntrustedContentHost} from './untrusted';
|
||||
import {Icon} from "./bootstrap-components";
|
||||
import {
|
||||
getPublicUrl,
|
||||
getSandboxUrl,
|
||||
getTrustedUrl
|
||||
} from "./urls";
|
||||
import {
|
||||
base,
|
||||
unbase
|
||||
} from "../../../shared/templates";
|
||||
import {getTrustedUrl} from "./urls";
|
||||
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
export class MosaicoEditorHost extends Component {
|
||||
export class MosaicoHost extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -81,96 +75,7 @@ export class MosaicoEditorHost extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
MosaicoEditorHost.prototype.exportState = async function() {
|
||||
MosaicoHost.prototype.exportState = async function() {
|
||||
return await this.getWrappedInstance().exportState();
|
||||
};
|
||||
|
||||
|
||||
|
||||
@translate(null, { withRef: true })
|
||||
export class MosaicoSandbox extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.viewModel = null;
|
||||
this.state = {
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
entityTypeId: PropTypes.string,
|
||||
entityId: PropTypes.number,
|
||||
templateId: PropTypes.number,
|
||||
templatePath: PropTypes.string,
|
||||
initialModel: PropTypes.string,
|
||||
initialMetadata: PropTypes.string
|
||||
}
|
||||
|
||||
async exportState(method, params) {
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
return {
|
||||
html: unbase(this.viewModel.exportHTML(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
|
||||
model: unbase(this.viewModel.exportJSON(), trustedUrlBase, sandboxUrlBase, publicUrlBase),
|
||||
metadata: unbase(this.viewModel.exportMetadata(), trustedUrlBase, sandboxUrlBase, publicUrlBase)
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
parentRPC.setMethodHandler('exportState', ::this.exportState);
|
||||
|
||||
if (!Mosaico.isCompatible()) {
|
||||
alert('Update your browser!');
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = [...window.mosaicoPlugins];
|
||||
|
||||
plugins.push(viewModel => {
|
||||
this.viewModel = viewModel;
|
||||
});
|
||||
|
||||
// (Custom) HTML postRenderers
|
||||
plugins.push(viewModel => {
|
||||
viewModel.originalExportHTML = viewModel.exportHTML;
|
||||
viewModel.exportHTML = () => {
|
||||
let html = viewModel.originalExportHTML();
|
||||
for (const portRender of window.mosaicoHTMLPostRenderers) {
|
||||
html = postRender(html);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
});
|
||||
|
||||
plugins.unshift(vm => {
|
||||
// This is an override of the default paths in Mosaico
|
||||
vm.logoPath = getTrustedUrl('static/mosaico/img/mosaico32.png');
|
||||
vm.logoUrl = '#';
|
||||
});
|
||||
|
||||
const config = {
|
||||
imgProcessorBackend: getTrustedUrl('mosaico/img'),
|
||||
emailProcessorBackend: getSandboxUrl('mosaico/dl'),
|
||||
fileuploadConfig: {
|
||||
url: getSandboxUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`)
|
||||
},
|
||||
strings: window.mosaicoLanguageStrings
|
||||
};
|
||||
|
||||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
const metadata = this.props.initialMetadata && JSON.parse(base(this.props.initialMetadata, trustedUrlBase, sandboxUrlBase, publicUrlBase));
|
||||
const model = this.props.initialModel && JSON.parse(base(this.props.initialModel, trustedUrlBase, sandboxUrlBase, publicUrlBase));
|
||||
const template = this.props.templateId ? getSandboxUrl(`mosaico/templates/${this.props.templateId}/index.html`) : this.props.templatePath;
|
||||
|
||||
const allPlugins = plugins.concat(window.mosaicoPlugins);
|
||||
|
||||
Mosaico.start(config, template, metadata, model, allPlugins);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,9 +12,9 @@ import {
|
|||
import 'brace/mode/text';
|
||||
import 'brace/mode/html';
|
||||
|
||||
import { MosaicoEditorHost } from "../lib/sandboxed-mosaico";
|
||||
import { MosaicoHost } from "../lib/sandboxed-mosaico";
|
||||
import { CKEditorHost } from "../lib/sandboxed-ckeditor";
|
||||
import GrapesJS from "../lib/grapesjs";
|
||||
import { GrapesJSHost } from "../lib/sandboxed-grapesjs";
|
||||
|
||||
import {getTemplateTypes as getMosaicoTemplateTypes} from './mosaico/helpers';
|
||||
import {getSandboxUrl} from "../lib/urls";
|
||||
|
@ -70,7 +70,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<TableSelect id={prefix + 'mosaicoTemplate'} label={t('Mosaico template')} withHeader dropdown dataUrl='rest/mosaico-templates-table' columns={mosaicoTemplatesColumns} selectionLabelIndex={1} disabled={isEdit} />,
|
||||
getHTMLEditor: owner =>
|
||||
<AlignedRow label={t('Template content (HTML)')}>
|
||||
<MosaicoEditorHost
|
||||
<MosaicoHost
|
||||
ref={node => owner.editorNode = node}
|
||||
entity={owner.props.entity}
|
||||
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
|
||||
|
@ -132,7 +132,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
},
|
||||
getHTMLEditor: owner =>
|
||||
<AlignedRow label={t('Template content (HTML)')}>
|
||||
<MosaicoEditorHost
|
||||
<MosaicoHost
|
||||
ref={node => owner.editorNode = node}
|
||||
entity={owner.props.entity}
|
||||
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
|
||||
|
@ -175,38 +175,33 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
validate: state => {}
|
||||
};
|
||||
|
||||
templateTypes.xgrapesjs = { // FIXME
|
||||
typeName: t('GrapeJS'),
|
||||
getTypeForm: (owner, isEdit) => null,
|
||||
getHTMLEditor: owner => null,
|
||||
exportHTMLEditorData: async owner => {},
|
||||
initData: () => ({}),
|
||||
afterLoad: data => {},
|
||||
beforeSave: data => {
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {},
|
||||
validate: state => {}
|
||||
};
|
||||
|
||||
templateTypes.grapesjs = {
|
||||
typeName: t('GrapeJS (fake)'),
|
||||
typeName: t('GrapeJS'),
|
||||
getTypeForm: (owner, isEdit) => null,
|
||||
getHTMLEditor: owner =>
|
||||
<AlignedRow label={t('Template content (HTML)')}>
|
||||
<GrapesJS
|
||||
<GrapesJSHost
|
||||
ref={node => owner.editorNode = node}
|
||||
entity={owner.props.entity}
|
||||
initialHtml={owner.getFormValue(prefix + 'html')}
|
||||
entityTypeId={entityTypeId}/>
|
||||
initialModel={owner.getFormValue(prefix + 'grapesJSData')}
|
||||
entityTypeId={entityTypeId}
|
||||
title={t('GrapesJS Template Designer')}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
exportHTMLEditorData: async owner => {
|
||||
const {html} = await owner.editorNode.exportState();
|
||||
const {html, model} = await owner.editorNode.exportState();
|
||||
owner.updateFormValue(prefix + 'html', html);
|
||||
owner.updateFormValue(prefix + 'grapesJSData', model);
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'grapesJSData']: {}
|
||||
}),
|
||||
afterLoad: data => {
|
||||
data[prefix + 'grapesJSData'] = data[prefix + 'data'];
|
||||
},
|
||||
initData: () => ({}),
|
||||
afterLoad: data => {},
|
||||
beforeSave: data => {
|
||||
data[prefix + 'data'] = data[prefix + 'grapesJSData'];
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {},
|
||||
|
@ -217,7 +212,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
typeName: t('CKEditor 4'),
|
||||
getTypeForm: (owner, isEdit) => null,
|
||||
getHTMLEditor: owner =>
|
||||
<AlignedRow label={t('Template content')}>
|
||||
<AlignedRow label={t('Template content (HTML)')}>
|
||||
<CKEditorHost
|
||||
ref={node => owner.editorNode = node}
|
||||
entity={owner.props.entity}
|
||||
|
@ -243,7 +238,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
templateTypes.ckeditor5 = {
|
||||
typeName: t('CKEditor 5'),
|
||||
getTypeForm: (owner, isEdit) => null,
|
||||
getHTMLEditor: owner => <CKEditor id={prefix + 'html'} height="600px" mode="html" label={t('Template content')}/>,
|
||||
getHTMLEditor: owner => <CKEditor id={prefix + 'html'} height="600px" mode="html" label={t('Template content (HTML)')}/>,
|
||||
exportHTMLEditorData: async owner => {},
|
||||
initData: () => ({}),
|
||||
afterLoad: data => {},
|
||||
|
|
|
@ -16,6 +16,7 @@ module.exports = {
|
|||
"root": ['babel-polyfill', './src/root.js'],
|
||||
"mosaico-root": ['babel-polyfill', './src/lib/sandboxed-mosaico-root.js'],
|
||||
"ckeditor-root": ['babel-polyfill', './src/lib/sandboxed-ckeditor-root.js'],
|
||||
"grapesjs-root": ['babel-polyfill', './src/lib/sandboxed-grapesjs-root.js'],
|
||||
},
|
||||
output: {
|
||||
library: 'MailtrainReactBody',
|
||||
|
@ -51,21 +52,23 @@ module.exports = {
|
|||
test: /\.css$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'style-loader',
|
||||
options: {
|
||||
singleton: true
|
||||
}
|
||||
loader: 'style-loader'
|
||||
},
|
||||
{
|
||||
loader: 'css-loader'
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /ckeditor5-[^/]+\/theme\/[\w-/]+\.css$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
options: styles.getPostCssConfig( {
|
||||
themeImporter: {
|
||||
themePath: require.resolve( '@ckeditor/ckeditor5-theme-lark' )
|
||||
},
|
||||
minify: false
|
||||
minify: true
|
||||
} )
|
||||
}
|
||||
]
|
||||
|
@ -81,10 +84,6 @@ module.exports = {
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: [ 'raw-loader' ]
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
exclude: path.join(__dirname, 'node_modules'),
|
||||
|
@ -97,11 +96,21 @@ module.exports = {
|
|||
localIdentName: '[path][name]__[local]--[hash:base64:5]'
|
||||
}
|
||||
},
|
||||
'sass-loader' ]
|
||||
'sass-loader'
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.(otf|woff2|woff|ttf|eot)$/,
|
||||
use: [ 'raw-loader' ]
|
||||
test: /ckeditor5-[^/]+\/theme\/icons\/[^/]+\.svg|ckeditor-insert-image\.svg$/,
|
||||
use: [
|
||||
'raw-loader'
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.(svg|otf|woff2|woff|ttf|eot)$/,
|
||||
exclude: /ckeditor5-[^/]+\/theme\/icons\/[^/]+\.svg|ckeditor-insert-image\.svg$/,
|
||||
use: [
|
||||
'url-loader'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -116,6 +125,6 @@ module.exports = {
|
|||
],
|
||||
watchOptions: {
|
||||
ignored: 'node_modules/',
|
||||
poll: 1000
|
||||
poll: 2000
|
||||
}
|
||||
};
|
||||
|
|
|
@ -35,10 +35,8 @@ users.registerRestrictedAccessTokenMethod('ckeditor', async ({entityTypeId, enti
|
|||
|
||||
function getRouter(appType) {
|
||||
const router = routerFactory.create();
|
||||
|
||||
if (appType === AppType.SANDBOXED) {
|
||||
fileHelpers.installUploadHandler(router, '/upload/:type/:entityId', files.ReplacementBehavior.RENAME, null, 'file');
|
||||
|
||||
if (appType === AppType.SANDBOXED) {
|
||||
router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
|
||||
const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context, appType);
|
||||
|
||||
|
|
59
routes/sandboxed-grapesjs.js
Normal file
59
routes/sandboxed-grapesjs.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
'use strict';
|
||||
|
||||
const routerFactory = require('../lib/router-async');
|
||||
const passport = require('../lib/passport');
|
||||
const clientHelpers = require('../lib/client-helpers');
|
||||
const users = require('../models/users');
|
||||
|
||||
const files = require('../models/files');
|
||||
const fileHelpers = require('../lib/file-helpers');
|
||||
|
||||
const templates = require('../models/templates');
|
||||
|
||||
const contextHelpers = require('../lib/context-helpers');
|
||||
|
||||
const { getTrustedUrl, getSandboxUrl, getPublicUrl } = require('../lib/urls');
|
||||
const { AppType } = require('../shared/app');
|
||||
|
||||
|
||||
users.registerRestrictedAccessTokenMethod('grapesjs', async ({entityTypeId, entityId}) => {
|
||||
if (entityTypeId === 'template') {
|
||||
const tmpl = await templates.getById(contextHelpers.getAdminContext(), entityId, false);
|
||||
|
||||
if (tmpl.type === 'grapesjs') {
|
||||
return {
|
||||
permissions: {
|
||||
'template': {
|
||||
[entityId]: new Set(['manageFiles', 'view'])
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function getRouter(appType) {
|
||||
const router = routerFactory.create();
|
||||
|
||||
if (appType === AppType.SANDBOXED) {
|
||||
router.getAsync('/editor', passport.csrfProtection, async (req, res) => {
|
||||
const mailtrainConfig = await clientHelpers.getAnonymousConfig(req.context, appType);
|
||||
|
||||
res.render('grapesjs/root', {
|
||||
layout: 'grapesjs/layout',
|
||||
reactCsrfToken: req.csrfToken(),
|
||||
mailtrainConfig: JSON.stringify(mailtrainConfig),
|
||||
scriptFiles: [
|
||||
getSandboxUrl('mailtrain/common.js'),
|
||||
getSandboxUrl('mailtrain/grapesjs-root.js')
|
||||
],
|
||||
publicPath: getSandboxUrl()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports.getRouter = getRouter;
|
|
@ -10,9 +10,7 @@
|
|||
<link rel="shortcut icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="{{publicPath}}static/favicon.ico">
|
||||
|
||||
<title>Mailtrain
|
||||
{{#if title}} | {{title}}{{/if}}
|
||||
</title>
|
||||
<title>Mailtrain</title>
|
||||
|
||||
<link rel="stylesheet" href="{{publicPath}}static/bootstrap/themes/united.min.css">
|
||||
<script src="{{publicPath}}static/jquery-2.2.1.min.js"></script>
|
||||
|
|
33
views/grapesjs/layout.hbs
Normal file
33
views/grapesjs/layout.hbs
Normal file
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}">
|
||||
<link rel="shortcut icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="{{publicPath}}static/favicon.ico">
|
||||
|
||||
<title>Mailtrain</title>
|
||||
|
||||
<script src="{{publicPath}}static/jquery-2.2.1.min.js"></script>
|
||||
|
||||
{{#if mailtrainConfig}}
|
||||
<script>
|
||||
{{#if reactCsrfToken}}window.csfrToken = '{{reactCsrfToken}}';{{/if}}
|
||||
window.mailtrainConfig = {{{mailtrainConfig}}};
|
||||
</script>
|
||||
|
||||
{{#each scriptFiles}}
|
||||
<script src="{{this}}"></script>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</head>
|
||||
|
||||
<body class="grapesjs-body">
|
||||
{{{body}}}
|
||||
</body>
|
||||
|
||||
</html>
|
6
views/grapesjs/root.hbs
Normal file
6
views/grapesjs/root.hbs
Normal file
|
@ -0,0 +1,6 @@
|
|||
<div id="root"></div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
MailtrainReactBody.default();
|
||||
});
|
||||
</script>
|
|
@ -1,11 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=1024, initial-scale=1">
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<meta name="description" content="{{#translate}}Self hosted email newsletter app{{/translate}}">
|
||||
<link rel="shortcut icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="{{publicPath}}favicon.ico" type="image/x-icon" />
|
||||
<link rel="icon" href="{{publicPath}}static/favicon.ico">
|
||||
|
||||
<title>Mailtrain</title>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue