Upgrade of modules and webpack.

Support for localization in progress.
This commit is contained in:
Tomas Bures 2018-11-17 23:26:45 +01:00
parent d8b56fff0d
commit 4862d6cac4
52 changed files with 5870 additions and 23064 deletions

View file

@ -1,10 +1,10 @@
'use strict';
import React, {Component} from 'react';
import {translate} from 'react-i18next';
import { withNamespaces } from 'react-i18next';
import { requiresAuthenticatedUser } from './lib/page';
@translate()
@withNamespaces()
@requiresAuthenticatedUser
export default class List extends Component {
constructor(props) {
@ -16,7 +16,7 @@ export default class List extends Component {
return (
<div>
<h2>{t('Welcome to Mailtrain...')}</h2>
<h2>{t('home.welcome')}</h2>
<div>TODO: some dashboard</div>
</div>
);

View file

@ -53,13 +53,13 @@ export default class Account extends Component {
const emailServerValidation = state.getIn(['email', 'serverValidation']);
if (!email) {
state.setIn(['email', 'error'], t('Email must not be empty.'));
state.setIn(['email', 'error'], t('emailMustNotBeEmpty'));
} else if (emailServerValidation && emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('Invalid email address.'));
state.setIn(['email', 'error'], t('invalidEmailAddress'));
} else if (emailServerValidation && emailServerValidation.exists) {
state.setIn(['email', 'error'], t('The email is already associated with another user in the system.'));
state.setIn(['email', 'error'], t('account.emailAlreadyRegistered'));
} else if (!emailServerValidation) {
state.setIn(['email', 'error'], t('Validation is in progress...'));
state.setIn(['email', 'error'], t('validationInProgress'));
} else {
state.setIn(['email', 'error'], null);
}
@ -68,7 +68,7 @@ export default class Account extends Component {
const name = state.getIn(['name', 'value']);
if (!name) {
state.setIn(['name', 'error'], t('Full name must not be empty'));
state.setIn(['name', 'error'], t('account.fullNameMustNotBeEmpty'));
} else {
state.setIn(['name', 'error'], null);
}
@ -88,11 +88,11 @@ export default class Account extends Component {
const currentPasswordServerValidation = state.getIn(['currentPassword', 'serverValidation']);
if (!currentPassword) {
state.setIn(['currentPassword', 'error'], t('Current password must not be empty.'));
state.setIn(['currentPassword', 'error'], t('account.currentPasswordMustNotBeEmpty'));
} else if (currentPasswordServerValidation && currentPasswordServerValidation.incorrect) {
state.setIn(['currentPassword', 'error'], t('Incorrect password.'));
state.setIn(['currentPassword', 'error'], t('account.incorrectPassword'));
} else if (!currentPasswordServerValidation) {
state.setIn(['email', 'error'], t('Validation is in progress...'));
state.setIn(['email', 'error'], t('validationInProgress'));
} else {
state.setIn(['currentPassword', 'error'], null);
}
@ -104,7 +104,7 @@ export default class Account extends Component {
}
state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null);
state.setIn(['password2', 'error'], password !== password2 ? t('Passwords must match') : null);
state.setIn(['password2', 'error'], password !== password2 ? t('account.passwordsMustMatch') : null);
}
async submitHandler() {
@ -112,14 +112,14 @@ export default class Account extends Component {
try {
this.disableForm();
this.setFormStatusMessage('info', t('Updating user profile ...'));
this.setFormStatusMessage('info', t('account.updatingUserProfile'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, 'rest/account', data => {
delete data.password2;
});
if (submitSuccessful) {
this.setFlashMessage('success', t('User profile updated'));
this.setFlashMessage('success', t('account.userProfileUpdated'));
this.hideFormValidation();
this.updateFormValue('password', '');
this.updateFormValue('password2', '');
@ -130,7 +130,7 @@ export default class Account extends Component {
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
this.setFormStatusMessage('warning', t('errorsInForm'));
}
} catch (error) {
if (error instanceof interoperableErrors.IncorrectPasswordError) {
@ -138,8 +138,8 @@ export default class Account extends Component {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('The password is incorrect (possibly just changed in another window / session). Enter correct password and try again.')}
<strong>{t('updatesCannotBeSaved')}</strong>{' '}
{t('account.passwordPossiblyChanged')}
</span>
);
@ -152,8 +152,8 @@ export default class Account extends Component {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('The email is already assigned to another user. Enter another email and try again.')}
<strong>{t('updatesCannotBeSaved')}</strong>{' '}
{t('account.emailAlreadyRegisteredTryAgain')}
</span>
);
@ -171,23 +171,23 @@ export default class Account extends Component {
if (mailtrainConfig.isAuthMethodLocal) {
return (
<div>
<Title>{t('Account')}</Title>
<Title>{t('root.account')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<Fieldset label={t('General Settings')}>
<InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')} help={t('This address is used for account recovery in case you loose your password')}/>
<Fieldset label={t('account.generalSettings')}>
<InputField id="name" label={t('account.fullName')}/>
<InputField id="email" label={t('email')} help={t('account.addressUsedForAccountRecovery')}/>
</Fieldset>
<Fieldset label={t('Password Change')}>
<p>{t('You only need to fill out this form if you want to change your current password')}</p>
<InputField id="currentPassword" label={t('Current Password')} type="password" />
<InputField id="password" label={t('New Password')} type="password" />
<InputField id="password2" label={t('Confirm Password')} type="password" />
<Fieldset label={t('account.passwordChange')}>
<p>{t('account.fillOnlyForPasswordChange')}</p>
<InputField id="currentPassword" label={t('account.currentPassword')} type="password" />
<InputField id="password" label={t('account.newPassword')} type="password" />
<InputField id="password2" label={t('account.confirmPassword')} type="password" />
</Fieldset>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
<Button type="submit" className="btn-primary" icon="ok" label={t('update')}/>
</ButtonRow>
</Form>
</div>
@ -195,11 +195,11 @@ export default class Account extends Component {
} else {
return (
<div>
<Title>{t('Account')}</Title>
<Title>{t('root.account')}</Title>
<p>{t('Account management is not possible because Mailtrain is configured to use externally managed users.')}</p>
<p>{t('account.accountManagementNotPossible')}</p>
{mailtrainConfig.externalPasswordResetLink && <p><Trans>If you want to change the password, use <a href={mailtrainConfig.externalPasswordResetLink}>this link</a>.</Trans></p>}
{mailtrainConfig.externalPasswordResetLink && <p><Trans i18nKey="useThisLinkToChangePassword">If you want to change the password, use <a href={mailtrainConfig.externalPasswordResetLink}>this link</a>.</Trans></p>}
</div>
);
}

View file

@ -229,7 +229,6 @@ class SendControls extends Component {
const t = this.props.t;
const entity = this.props.entity;
console.log(entity);
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers')})`;

View file

@ -25,7 +25,7 @@ class DismissibleAlert extends Component {
return (
<div className={`alert alert-${this.props.severity} alert-dismissible`} role="alert">
<button type="button" className="close" aria-label={t('Close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
{this.props.children}
</div>
)
@ -201,7 +201,7 @@ class ModalDialog extends Component {
const t = props.t;
this.state = {
buttons: this.props.buttons || [ { label: t('Close'), className: 'btn-default', onClickAsync: null } ]
buttons: this.props.buttons || [ { label: t('close'), className: 'btn-default', onClickAsync: null } ]
};
}
@ -290,7 +290,7 @@ class ModalDialog extends Component {
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" aria-label={t('Close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
<h4 className="modal-title">{this.props.title}</h4>
</div>
<div className="modal-body">{this.props.children}</div>

View file

@ -50,22 +50,22 @@ export default class Files extends Component {
const t = this.props.t;
const details = [];
if (response.data.added) {
details.push(t('{{count}} file(s) added', {count: response.data.added}));
details.push(t('files.filesAdded', {count: response.data.added}));
}
if (response.data.replaced) {
details.push(t('{{count}} file(s) replaced', {count: response.data.replaced}));
details.push(t('files.filesReplaced', {count: response.data.replaced}));
}
if (response.data.ignored) {
details.push(t('{{count}} file(s) ignored', {count: response.data.ignored}));
details.push(t('files.filesIgnored', {count: response.data.ignored}));
}
const detailsMessage = details ? ' (' + details.join(', ') + ')' : '';
return t('{{count}} file(s) uploaded', {count: response.data.uploaded}) + detailsMessage;
return t('files.filesUploaded', {count: response.data.uploaded}) + detailsMessage;
}
onDrop(files){
const t = this.props.t;
if (files.length > 0) {
this.setFlashMessage('info', t('Uploading {{count}} file(s)', files.length));
this.setFlashMessage('info', t('files.uploadingFiles', {count: files.length}));
const data = new FormData();
for (const file of files) {
data.append('files[]', file)
@ -76,10 +76,10 @@ export default class Files extends Component {
const message = this.getFilesUploadedMessage(res);
this.setFlashMessage('info', message);
})
.catch(res => this.setFlashMessage('danger', t('File upload failed: ') + res.message));
.catch(res => this.setFlashMessage('danger', t('files.fileUploadFailed') + ' ' + res.message));
}
else{
this.setFlashMessage('info', t('No files to upload'));
this.setFlashMessage('info', t('files.noFilesToUpload'));
}
}
@ -97,13 +97,13 @@ export default class Files extends Component {
await this.hideDeleteFile();
try {
this.setFlashMessage('info', t('Deleting file ...'));
this.setFlashMessage('info', t('files.deletingFile'));
await axios.delete(getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${fileToDeleteId}`));
this.filesTable.refresh();
this.setFlashMessage('info', t('File deleted'));
this.setFlashMessage('info', t('files.fileDeleted'));
} catch (err) {
this.filesTable.refresh();
this.setFlashMessage('danger', t('Delete file failed: ') + err.message);
this.setFlashMessage('danger', t('files.deleteFileFailed') + ' ' + err.message);
}
}
@ -111,8 +111,8 @@ export default class Files extends Component {
const t = this.props.t;
const columns = [
{ data: 1, title: "Name" },
{ data: 3, title: "Size" },
{ data: 1, title: t('name') },
{ data: 3, title: t('size') },
{
actions: data => {
const actions = [];
@ -125,13 +125,13 @@ export default class Files extends Component {
}
actions.push({
label: <Icon icon="download" title={t('Download')}/>,
label: <Icon icon="download" title={t('download')}/>,
href: downloadUrl
});
if (this.props.entity.permissions.includes(this.props.managePermission)) {
actions.push({
label: <Icon icon="remove" title={t('Delete')}/>,
label: <Icon icon="remove" title={t('delete')}/>,
action: () => this.deleteFile(data[0], data[1])
});
}
@ -145,13 +145,13 @@ export default class Files extends Component {
<div>
<ModalDialog
hidden={this.state.fileToDeleteId === null}
title={t('Confirm file deletion')}
title={t('files.confirmFileDeletion')}
onCloseAsync={::this.hideDeleteFile}
buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteFile },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDeleteFile }
{ label: t('no'), className: 'btn-primary', onClickAsync: ::this.hideDeleteFile },
{ label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performDeleteFile }
]}>
{t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})}
{t('files:areYouSureToDeleteFile', {name: this.state.fileToDeleteName})}
</ModalDialog>
{this.props.title && <Title>{this.props.title}</Title>}
@ -161,7 +161,7 @@ export default class Files extends Component {
{
this.props.entity.permissions.includes(this.props.managePermission) &&
<Dropzone onDrop={::this.onDrop} className={styles.dropZone} activeClassName={styles.dropZoneActive}>
{state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')}
{state => state.isDragActive ? t('files.dropFiles', {count: state.draggedFiles.length}) : t('files.dropFilesHere')}
</Dropzone>
}

View file

@ -86,7 +86,7 @@ class Form extends Component {
if (!owner.isFormReady()) {
if (owner.isFormWithLoadingNotice()) {
return <p className={`alert alert-info ${styles.formStatus}`} role="alert">{t('Loading ...')}</p>
return <p className={`alert alert-info ${styles.formStatus}`} role="alert">{t('loading')}</p>
} else {
return <div></div>;
}
@ -568,7 +568,7 @@ class DatePicker extends Component {
<div>
<div className="input-group">
<input type="text" value={selectedDateStr} placeholder={placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
<span className="input-group-addon" onClick={::this.toggleDayPicker}><Icon icon="calendar" title={t('Open calendar')}/></span>
<span className="input-group-addon" onClick={::this.toggleDayPicker}><Icon icon="calendar" title={t('form.openCalendar')}/></span>
</div>
{this.state.opened &&
<div className={styles.dayPickerWrapper}>
@ -803,7 +803,7 @@ class TableSelect extends Component {
<input type="text" className="form-control" value={this.state.selectedLabel} onClick={::this.toggleOpen} readOnly={!props.disabled} disabled={props.disabled}/>
{!props.disabled &&
<span className="input-group-btn">
<Button label={t('Select')} className="btn-default" onClickAsync={::this.toggleOpen}/>
<Button label={t('form.select')} className="btn-default" onClickAsync={::this.toggleOpen}/>
</span>
}
</div>
@ -1305,8 +1305,8 @@ function withForm(target) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
<strong>{t('form.yourUpdatesCannotBeSaved')}</strong>{' '}
{t('form.modificationsInTheMeantime')}
</span>
);
return;
@ -1316,8 +1316,8 @@ function withForm(target) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the target namespace in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
<strong>{t('form.yourUpdatesCannotBeSaved')}</strong>{' '}
{t('form.namespaceDeletedInTheMeantime')}
</span>
);
return;
@ -1327,8 +1327,8 @@ function withForm(target) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the entity in the meantime.')}
<strong>{t('form.yourUpdatesCannotBeSaved')}</strong>{' '}
{t('form.deletionInTheMeantime')}
</span>
);
return;

View file

@ -1,35 +1,34 @@
import i18n from 'i18next';
import XHR from 'i18next-xhr-backend';
// import Cache from 'i18next-localstorage-cache';
import { reactI18nextModule } from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector';
import mailtrainConfig from 'mailtrainConfig';
import {getUrl} from "./urls";
import commonEn from "../../../locales/common/en";
i18n
.use(XHR)
// .use(Cache)
.use(LanguageDetector)
.init({
lng: mailtrainConfig.language,
resources: {
en: {
common: commonEn
}
},
wait: true, // globally set to wait for loaded translations in translate hoc
// have a common namespace used around the full app
ns: ['common'],
fallbackLng: "en",
defaultNS: 'common',
debug: false,
// cache: {
// enabled: true
// },
interpolation: {
escapeValue: false // not needed for react
},
backend: {
loadPath: getUrl('locales/{{lng}}/{{ns}}.json')
}
});
react: {
wait: true
},
debug: true
})
export default i18n;

View file

@ -87,8 +87,8 @@ export class RestActionModalDialog extends Component {
return (
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={() => this.hideModal(true)} buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: () => this.hideModal(true) },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.hideModal(true) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
]}>
{this.props.message}
</ModalDialog>
@ -105,15 +105,15 @@ export class DeleteModalDialog extends Component {
const t = props.t;
this.entityTypeLabels = {
'namespace': t('Namespace'),
'list': t('List'),
'customForm': t('Custom forms'),
'campaign': t('Campaign'),
'template': t('Template'),
'sendConfiguration': t('Send configuration'),
'report': t('Report'),
'reportTemplate': t('Report template'),
'mosaicoTemplate': t('Mosaico template')
'namespace': t('namespace'),
'list': t('list'),
'customForm': t('customForms'),
'campaign': t('campaign'),
'template': t('template'),
'sendConfiguration': t('sendConfiguration'),
'report': t('report'),
'reportTemplate': t('reportTemplate'),
'mosaicoTemplate': t('mosaicoTemplate')
};
}
@ -145,7 +145,7 @@ export class DeleteModalDialog extends Component {
const name = this.props.name !== undefined ? this.props.name : (owner ? owner.getFormValue('name') : '');
this.setFlashMessage('danger',
<div>
<p>{t('Cannote delete "{{name}}" due to the following dependencies:', {name, nsSeparator: '|'})}</p>
<p>{t('deleteDialog.cannotDeleteDueToDependencies', {name})}</p>
<ul className={styles.dependenciesList}>
{err.data.dependencies.map(dep =>
dep.link ?
@ -153,7 +153,7 @@ export class DeleteModalDialog extends Component {
: // if no dep.link is present, it means the user has no permission to view the entity, thus only id without the link is shown
<li key={dep.id}>{this.entityTypeLabels[dep.entityTypeId]}: [{dep.id}]</li>
)}
{err.data.andMore && <li>{t('... and more')}</li>}
{err.data.andMore && <li>{t('deleteDialog.andMore')}</li>}
</ul>
</div>
);
@ -180,8 +180,8 @@ export class DeleteModalDialog extends Component {
const name = this.props.name !== undefined ? this.props.name : (owner ? owner.getFormValue('name') : '');
return <RestActionModalDialog
title={t('Confirm deletion')}
message={t('Are you sure you want to delete "{{name}}"?', {name})}
title={t('deleteDialog.confirmDeletion')}
message={t('deleteDialog.areYouSureToDelete', {name})}
stateOwner={this.props.stateOwner}
visible={this.props.visible}
actionMethod={HTTPMethod.DELETE}
@ -209,11 +209,11 @@ export function tableDeleteDialogAddDeleteButton(actions, owner, perms, id, name
if (!perms || perms.includes('delete')) {
if (owner.deleteDialogData.id) {
actions.push({
label: <Icon className={styles.iconDisabled} icon="remove" title={t('Delete')}/>
label: <Icon className={styles.iconDisabled} icon="remove" title={t('delete')}/>
});
} else {
actions.push({
label: <Icon icon="remove" title={t('Delete')}/>,
label: <Icon icon="remove" title={t('delete')}/>,
action: () => {
owner.deleteDialogData = {name, id};
owner.setState({

View file

@ -11,14 +11,14 @@ class NamespaceSelect extends Component {
const t = this.props.t;
return (
<TreeTableSelect id="namespace" label={t('Namespace')} dataUrl="rest/namespaces-tree"/>
<TreeTableSelect id="namespace" label={t('namespace')} dataUrl="rest/namespaces-tree"/>
);
}
}
function validateNamespace(t, state) {
if (!state.getIn(['namespace', 'value'])) {
state.setIn(['namespace', 'error'], t('Namespace must be selected'));
state.setIn(['namespace', 'error'], t('namespace.mustBeSelected'));
} else {
state.setIn(['namespace', 'error'], null);
}

View file

@ -260,7 +260,7 @@ class RouteContent extends Component {
<div>
{primaryMenuComponent}
<div className="container-fluid">
{t('Loading...')}
{t('loading')}
</div>
</div>
);

View file

@ -6,6 +6,6 @@ export const CodeEditorSourceType = {
};
export const getCodeEditorSourceTypeOptions = t => [
{key: CodeEditorSourceType.MJML, label: t('MJML')},
{key: CodeEditorSourceType.HTML, label: t('HTML')}
{key: CodeEditorSourceType.MJML, label: t('mjml')},
{key: CodeEditorSourceType.HTML, label: t('html')}
];

View file

@ -6,6 +6,6 @@ export const GrapesJSSourceType = {
};
export const getGrapesJSSourceTypeOptions = t => [
{key: GrapesJSSourceType.MJML, label: t('MJML')},
{key: GrapesJSSourceType.HTML, label: t('HTML')}
{key: GrapesJSSourceType.MJML, label: t('mjml')},
{key: GrapesJSSourceType.HTML, label: t('html')}
];

View file

@ -77,7 +77,7 @@ export class UntrustedContentHost extends Component {
}
sendMessage(type, data) {
if (this.contentNodeIsLoaded) { // This is to avoid errors "common.js:45744 Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:8081') does not match the recipient window's origin ('http://localhost:3000')"
if (this.contentNodeIsLoaded) { // This is to avoid errors: Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:8081') does not match the recipient window's origin ('http://localhost:3000')"
this.contentNode.contentWindow.postMessage({type, data}, getSandboxUrl());
}
}

View file

@ -6,7 +6,7 @@ import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {
I18nextProvider,
translate
withNamespaces
} from 'react-i18next';
import i18n from './lib/i18n';
@ -38,7 +38,7 @@ import axios from './lib/axios';
import {getUrl} from "./lib/urls";
@translate()
@withNamespaces()
class Root extends Component {
constructor(props) {
super(props);
@ -58,7 +58,7 @@ class Root extends Component {
const link = entry.link || entry.externalLink;
if (link && path.startsWith(link)) {
topLevelMenu.push(<MenuLink key={entryKey} className="active" to={link}>{entry.title} <span className="sr-only">{t('(current)')}</span></MenuLink>);
topLevelMenu.push(<MenuLink key={entryKey} className="active" to={link}>{entry.title} <span className="sr-only">{t('root.current')}</span></MenuLink>);
} else {
topLevelMenu.push(<MenuLink key={entryKey} to={link}>{entry.title}</MenuLink>);
}
@ -69,7 +69,7 @@ class Root extends Component {
<div className="container-fluid">
<div className="navbar-header">
<button type="button" className="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
<span className="sr-only">{t('Toggle navigation')}</span>
<span className="sr-only">{t('root.toggleNavigation')}</span>
<span className="icon-bar"></span>
<span className="icon-bar"></span>
<span className="icon-bar"></span>
@ -81,22 +81,22 @@ class Root extends Component {
<div className="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul className="nav navbar-nav">
{topLevelMenu}
<DropdownMenuItem label={t('Administration')}>
<MenuLink to="/users"><Icon icon='cog'/> {t('Users')}</MenuLink>
<MenuLink to="/namespaces"><Icon icon='cog'/> {t('Namespaces')}</MenuLink>
{mailtrainConfig.globalPermissions.manageSettings && <MenuLink to="/settings"><Icon icon='cog'/> {t('Global Settings')}</MenuLink>}
<MenuLink to="/send-configurations"><Icon icon='cog'/> {t('Send Configurations')}</MenuLink>
{mailtrainConfig.globalPermissions.manageBlacklist && <MenuLink to="/blacklist"><Icon icon='ban-circle'/> {t('Blacklist')}</MenuLink>}
<MenuLink to="/account/api"><Icon icon='retweet'/> {t('API')}</MenuLink>
<DropdownMenuItem label={t('root.administration')}>
<MenuLink to="/users"><Icon icon='cog'/> {t('user_plural')}</MenuLink>
<MenuLink to="/namespaces"><Icon icon='cog'/> {t('namespace_plural')}</MenuLink>
{mailtrainConfig.globalPermissions.manageSettings && <MenuLink to="/settings"><Icon icon='cog'/> {t('globalSetting_plural')}</MenuLink>}
<MenuLink to="/send-configurations"><Icon icon='cog'/> {t('sendConfiguration_plural')}</MenuLink>
{mailtrainConfig.globalPermissions.manageBlacklist && <MenuLink to="/blacklist"><Icon icon='ban-circle'/> {t('blacklist')}</MenuLink>}
<MenuLink to="/account/api"><Icon icon='retweet'/> {t('api')}</MenuLink>
</DropdownMenuItem>
</ul>
<ul className="nav navbar-nav navbar-right">
<DropdownMenuItem label={mailtrainConfig.user.username} icon="user">
<MenuLink to="/account"><Icon icon='user'/> {t('Account')}</MenuLink>
<MenuLink to="/account"><Icon icon='user'/> {t('root.account')}</MenuLink>
<li>
<ActionLink onClickAsync={::self.logout}><Icon icon='log-out'/> {t('Log out')}</ActionLink>
<ActionLink onClickAsync={::self.logout}><Icon icon='log-out'/> {t('logout')}</ActionLink>
</li>
</DropdownMenuItem>
</ul>
@ -145,7 +145,7 @@ class Root extends Component {
<footer className="footer">
<div className="container-fluid">
<p className="text-muted">&copy; 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{t('Source on GitHub')}</a></p>
<p className="text-muted">&copy; 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{t('sourceOnGithub')}</a></p>
</div>
</footer>
</div>