diff --git a/client/.babelrc b/client/.babelrc
index 712064f2..5a460ec4 100644
--- a/client/.babelrc
+++ b/client/.babelrc
@@ -1,3 +1,4 @@
{
- "plugins": ["transform-react-jsx", "transform-decorators-legacy"]
+ "presets": ["stage-1"],
+ "plugins": ["transform-react-jsx", "transform-decorators-legacy", "transform-function-bind"]
}
\ No newline at end of file
diff --git a/client/package.json b/client/package.json
index 87eafd6d..15f2547d 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,8 +15,10 @@
"license": "GPL-3.0",
"homepage": "https://mailtrain.org/",
"dependencies": {
+ "axios": "^0.16.1",
"i18next": "^8.3.0",
"i18next-xhr-backend": "^1.4.1",
+ "immutable": "^3.8.1",
"prop-types": "^15.5.10",
"react": "^15.5.4",
"react-dom": "^15.5.4",
@@ -27,8 +29,10 @@
"babel-cli": "^6.24.1",
"babel-loader": "^7.0.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
+ "babel-plugin-transform-function-bind": "^6.22.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
+ "babel-preset-stage-1": "^6.24.1",
"css-loader": "^0.28.3",
"i18next-conv": "^3.0.3",
"style-loader": "^0.18.1",
diff --git a/client/src/lib/form.js b/client/src/lib/form.js
new file mode 100644
index 00000000..c0188dab
--- /dev/null
+++ b/client/src/lib/form.js
@@ -0,0 +1,247 @@
+'use strict';
+
+import React, { Component } from 'react';
+import axios from 'axios';
+import Immutable from 'immutable';
+import { translate } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { Button } from './page.js';
+
+const FormState = {
+ Loading: 0,
+ LoadingWithNotice: 1,
+ Ready: 2
+};
+
+
+@translate()
+class Form extends Component {
+ static propTypes = {
+ stateOwner: PropTypes.object.isRequired,
+ onSubmit: PropTypes.func
+ }
+
+ static childContextTypes = {
+ stateOwner: PropTypes.object
+ }
+
+ getChildContext() {
+ return {
+ stateOwner: this.props.stateOwner
+ };
+ }
+
+ render() {
+ const t = this.props.t;
+ const owner = this.props.stateOwner;
+ const props = this.props;
+
+ if (!owner.isFormReady()) {
+ if (owner.isFormWithLoadingNotice()) {
+ return
{t('Loading ...')}
+ } else {
+ return ;
+ }
+ } else {
+ return (
+
+ );
+ }
+ }
+}
+
+function wrapInput(id, htmlId, owner, label, input) {
+ return (
+
+
+
+
+
+ {input}
+
+
{owner.getFormValidationMessage(id)}
+
+ );
+}
+
+class InputField extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ placeholder: PropTypes.string
+ }
+
+ static contextTypes = {
+ stateOwner: PropTypes.object.isRequired
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.context.stateOwner;
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ return wrapInput(id, htmlId, owner, props.label,
+
+ );
+ }
+}
+
+class TextArea extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ placeholder: PropTypes.string
+ }
+
+ static contextTypes = {
+ stateOwner: PropTypes.object.isRequired
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.context.stateOwner;
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ return wrapInput(id, htmlId, owner, props.label,
+
+ );
+ }
+}
+
+class ButtonRow extends Component {
+ render() {
+ return (
+
+
+ {this.props.children}
+
+
+ );
+ }
+}
+
+
+
+
+function withForm(target) {
+ const inst = target.prototype;
+
+ inst.initFormState = function() {
+ const state = this.state || {};
+
+ state.formState = Immutable.Map({
+ _state: FormState.Loading,
+ _isValidationShown: false
+ });
+
+ this.state = state;
+ };
+
+ inst.populateFormStateFromURL = function(url) {
+ setTimeout(() => {
+ this.setState(previousState => {
+ if (previousState.formState.get('_state') === FormState.Loading) {
+ return {
+ formState: previousState.formState.set('_state', FormState.LoadingWithNotice)
+ };
+ }
+ });
+ }, 500);
+
+ axios.get(url).then(response => {
+ this.populateFormState(response.data);
+ });
+ };
+
+ inst.populateFormState = function(data) {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(state => {
+ state.set('_state', FormState.Ready);
+
+ for (const key in data) {
+ state.set(key, Immutable.Map({
+ value: data[key]
+ }));
+ }
+
+ this.validateFormState(state);
+ })
+ }));
+ };
+
+ inst.updateFormState = function(key, value) {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(state => {
+ state.setIn([key, 'value'], value);
+ this.validateFormState(state);
+ })
+ }));
+ };
+
+ inst.bindToFormState = function(name) {
+ return evt => this.updateFormState(name, evt.target.value);
+ };
+
+ inst.getFormState = function(name) {
+ return this.state.formState.getIn([name, 'value']);
+ };
+
+ inst.getFormError = function(name) {
+ return this.state.formState.getIn([name, 'error']);
+ };
+
+ inst.isFormWithLoadingNotice = function() {
+ return this.state.formState.get('_state') === FormState.LoadingWithNotice;
+ };
+
+ inst.isFormReady = function() {
+ return this.state.formState.get('_state') === FormState.Ready;
+ };
+
+ inst.isFormValidationShown = function() {
+ return this.state.formState.get('_isValidationShown');
+ };
+
+ inst.addFormValidationClass = function(className, name) {
+ if (this.isFormValidationShown()) {
+ const error = this.getFormError(name);
+ if (error) {
+ return className + ' has-error';
+ } else {
+ return className + ' has-success';
+ }
+ } else {
+ return className;
+ }
+ };
+
+ inst.getFormValidationMessage = function(name) {
+ if (this.isFormValidationShown()) {
+ return this.getFormError(name);
+ } else {
+ return '';
+ }
+ };
+
+ inst.showFormValidation = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('_isValidationShown', true)}));
+ };
+
+ inst.hideFormValidation = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('_isValidationShown', false)}));
+ };
+}
+
+
+export {
+ withForm,
+ Form,
+ InputField,
+ TextArea,
+ ButtonRow,
+ Button
+}
diff --git a/client/src/i18n.js b/client/src/lib/i18n.js
similarity index 100%
rename from client/src/i18n.js
rename to client/src/lib/i18n.js
diff --git a/client/src/lib/page.css b/client/src/lib/page.css
new file mode 100644
index 00000000..8f338941
--- /dev/null
+++ b/client/src/lib/page.css
@@ -0,0 +1,7 @@
+.mt-button-row > button{
+ margin-right: 15px;
+}
+
+.mt-button-row > button:last-child {
+ margin-right: 0px;
+}
\ No newline at end of file
diff --git a/client/src/lib/page.js b/client/src/lib/page.js
new file mode 100644
index 00000000..d00c636a
--- /dev/null
+++ b/client/src/lib/page.js
@@ -0,0 +1,200 @@
+'use strict';
+
+import React, { Component } from 'react';
+import { translate } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { withRouter } from 'react-router';
+import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
+
+import './page.css';
+
+
+class PageContent extends Component {
+ getRoutes(urlPrefix, children) {
+ let routes = [];
+ for (let routeKey in children) {
+ const structure = children[routeKey];
+
+ let path = urlPrefix + routeKey;
+
+ if (structure.params) {
+ path = path + '/' + structure.params.join('/');
+ }
+
+ if (structure.component) {
+ const route = {
+ component: structure.component,
+ path: (path === '' ? '/' : path)
+ };
+
+ routes.push(route);
+ }
+
+ if (structure.children) {
+ routes = routes.concat(this.getRoutes(path + '/', structure.children));
+ }
+ }
+
+ return routes;
+ }
+
+ renderRoute(route) {
+ return ;
+ }
+
+ render() {
+ let routes = this.getRoutes('', this.props.structure);
+ return {routes.map(x => this.renderRoute(x))};
+ }
+}
+
+@withRouter
+class Breadcrumb extends Component {
+ renderElement(breadcrumbElem) {
+ if (breadcrumbElem.isActive) {
+ return {breadcrumbElem.title};
+ } else if (breadcrumbElem.externalLink) {
+ return {breadcrumbElem.title};
+ } else if (breadcrumbElem.link) {
+ return {breadcrumbElem.title};
+ } else {
+ return {breadcrumbElem.title};
+ }
+ }
+
+ render() {
+ const location = this.props.location.pathname;
+ const locationElems = location.split('/');
+
+ let breadcrumbElems = [];
+ let children = this.props.structure;
+
+ for (let idx = 0; idx < locationElems.length; idx++) {
+ const breadcrumbElem = children[locationElems[idx]];
+ if (!breadcrumbElem) {
+ break;
+ }
+
+ breadcrumbElem.isActive = (idx === locationElems.length - 1);
+ breadcrumbElem.idx = idx;
+
+ breadcrumbElems.push(breadcrumbElem);
+ children = breadcrumbElem.children;
+
+ if (!children) {
+ break;
+ }
+ }
+
+ const renderedElems = breadcrumbElems.map(x => this.renderElement(x));
+
+ return {renderedElems}
;
+ }
+}
+
+@translate()
+class Section extends Component {
+ constructor(props) {
+ super(props);
+
+ let structure = props.structure;
+ if (typeof structure === 'function') {
+ structure = structure(props.t);
+ }
+
+ this.structure = structure;
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+}
+
+class Title extends Component {
+ render() {
+ return (
+
+
{this.props.children}
+
+
+ );
+ }
+}
+
+class Toolbar extends Component {
+ render() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+
+class Button extends Component {
+ static propTypes = {
+ onClick: PropTypes.func,
+ label: PropTypes.string,
+ icon: PropTypes.string,
+ className: PropTypes.string,
+ type: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+
+ let className = 'btn';
+ if (props.className) {
+ className = className + ' ' + props.className;
+ }
+
+ let type = props.type || 'button';
+
+ let icon;
+ if (props.icon) {
+ icon =
+ }
+
+ let iconSpacer;
+ if (props.icon && props.label) {
+ iconSpacer = ' ';
+ }
+
+ return (
+
+ );
+ }
+}
+
+class NavButton extends Component {
+ static propTypes = {
+ label: PropTypes.string,
+ icon: PropTypes.string,
+ className: PropTypes.string,
+ linkTo: PropTypes.string
+ };
+
+ render() {
+ const props = this.props;
+
+ return (
+