#84: language switching on frontend

This commit is contained in:
SleepWalker
2016-05-19 22:41:43 +03:00
parent f496f87a8e
commit ecf41dd725
29 changed files with 480 additions and 41 deletions

View File

@@ -3,6 +3,7 @@ import React, { Component, PropTypes } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Button } from 'components/ui/form';
import { LangMenu } from 'components/langMenu';
import styles from './appInfo.scss';
import messages from './AppInfo.intl.json';
@@ -36,6 +37,10 @@ export default class AppInfo extends Component {
<div className={styles.goToAuth}>
<Button onClick={onGoToAuth} label={messages.goToAuth} />
</div>
<div className={styles.langMenu}>
<LangMenu />
</div>
</div>
);
}

View File

@@ -50,3 +50,10 @@
display: none;
}
}
.langMenu {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
}

View File

@@ -0,0 +1,23 @@
import React, { Component, PropTypes } from 'react';
import { IntlProvider as OrigIntlProvider } from 'react-intl';
class IntlProvider extends Component {
static displayName = 'IntlProvider';
static propTypes = {
locale: PropTypes.string.isRequired,
messages: PropTypes.objectOf(PropTypes.string).isRequired,
children: PropTypes.element
};
render() {
return (
<OrigIntlProvider {...this.props} />
);
}
}
import { connect } from 'react-redux';
export default connect(({i18n}) => i18n)(IntlProvider);

View File

@@ -0,0 +1,18 @@
import i18n from 'services/i18n';
export const SET_LOCALE = 'SET_LOCALE';
export function setLocale(locale) {
return (dispatch) => i18n.require(
i18n.detectLanguage(locale)
).then(({locale, messages}) => {
dispatch({
type: SET_LOCALE,
payload: {
locale,
messages
}
});
return locale;
});
}

View File

@@ -0,0 +1,5 @@
import IntlProvider from './IntlProvider';
export {
IntlProvider
};

View File

@@ -0,0 +1,9 @@
import { SET_LOCALE } from './actions';
export default function(state = {}, {type, payload}) {
if (type === SET_LOCALE) {
return payload;
}
return state;
}

View File

@@ -0,0 +1,140 @@
import React, { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { FormattedMessage as Message } from 'react-intl';
import icons from 'components/ui/icons.scss';
import styles from './langMenu.scss';
import messages from './langMenu.intl.json';
const LANGS = {
en: 'English',
ru: 'Русский'
};
export default class LangMenu extends Component {
static displayName = 'LangMenu';
static propTypes = {
showCurrentLang: PropTypes.bool,
toggleRef: PropTypes.func,
userLang: PropTypes.string,
changeLang: PropTypes.func
};
static defaultProps = {
toggleRef: () => {},
showCurrentLang: false
};
state = {
isActive: false
};
componentDidMount() {
document.addEventListener('click', this.onBodyClick);
this.props.toggleRef(this.toggle.bind(this));
}
componentWillUnmount() {
document.removeEventListener('click', this.onBodyClick);
this.props.toggleRef(null);
}
render() {
const {userLang, showCurrentLang} = this.props;
const {isActive} = this.state;
return (
<div className={classNames(styles.container, {
[styles.withCurrentLang]: showCurrentLang
})}>
<div className={styles.menuContainer}>
<ul className={classNames(styles.menu, {
[styles.menuActive]: isActive
})}>
{Object.keys(LANGS).map((lang) => (
<li className={classNames(styles.menuItem, {
[styles.activeMenuItem]: lang === userLang
})} onClick={this.onChangeLang(lang)} key={lang}>
{this.renderLangLabel(lang)}
</li>
))}
</ul>
</div>
<div className={styles.triggerContainer} onClick={this.onToggle}>
<a className={styles.trigger} href="#">
{showCurrentLang
? this.renderLangLabel(userLang) : (
<span>
<span className={icons.globe} />
{' '}
<Message {...messages.siteLanguage} />
{' '}
<span className={isActive ? styles.triggerArrowBottom : styles.triggerArrowTop} />
</span>
)}
</a>
</div>
</div>
);
}
renderLangLabel(lang) {
const langLabel = LANGS[lang];
return (
<span>
<span className={styles[`lang${lang[0].toUpperCase() + lang.slice(1)}`]} />
{langLabel}
</span>
);
}
onChangeLang(lang) {
return (event) => {
event.preventDefault();
this.props.changeLang(lang);
this.setState({
isActive: false
});
};
}
onBodyClick = (event) => {
if (this.state.isActive) {
const el = ReactDOM.findDOMNode(this);
if (!el.contains(event.target) && el !== event.taget) {
event.preventDefault();
// add a small delay for the case someone have alredy called toggle
setTimeout(() => this.state.isActive && this.toggle(), 0);
}
}
};
onToggle = (event) => {
event.preventDefault();
this.toggle();
};
toggle = () => {
// add small delay to skip click event on body
setTimeout(() => this.setState({
isActive: !this.state.isActive
}), 0);
};
}
import { connect } from 'react-redux';
import { changeLang } from 'components/user/actions';
export default connect((state) => ({
userLang: state.user.lang
}), {
changeLang
})(LangMenu);

View File

@@ -0,0 +1,5 @@
import LangMenu from './LangMenu';
export {
LangMenu
};

View File

@@ -0,0 +1,3 @@
{
"siteLanguage": "Site language"
}

View File

@@ -0,0 +1,119 @@
.container {
position: relative;
}
.menuContainer {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
width: 150px;
margin: 0 auto 10px;
}
.menu {
background: #fff;
border: 5px solid #ddd8ce;
border-left: 0;
border-right: 0;
text-align: left;
visibility: hidden;
opacity: 0;
transition: 0.2s ease;
transform: scale(0.1);
transform-origin: center bottom;
}
.menuActive {
visibility: visible;
opacity: 1;
transform: scale(1);
}
.withCurrentLang {
.triggerContainer {
text-align: left;
}
.menuContainer {
width: 100%;
}
}
.menuItem {
padding: 10px;
font-size: 13px;
border-bottom: 1px solid #eee;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
&:last-child {
border-bottom: none;
}
}
.activeMenuItem {
background: #efffef;
}
.langIco {
display: inline-block;
margin-right: 5px;
width: 20px;
height: 10px;
background: no-repeat;
background-size: cover;
}
.langEn {
composes: langIco;
background-image: url('icons/flag_en.svg');
}
.langRu {
composes: langIco;
background-image: url('icons/flag_ru.svg');
}
.trigger {
color: #666;
border-bottom: 1px dotted #666;
text-decoration: none;
transition: .25s;
&:hover {
border-bottom-color: #777;
color: #777;
}
}
.triggerContainer {
text-align: center;
}
.trigger {
font-size: 12px;
}
.triggerArrow {
font-size: 8px;
transition: 0.2s;
}
.triggerArrowTop {
composes: triggerArrow;
composes: arrowTop from 'components/ui/icons.scss';
}
.triggerArrowBottom {
composes: triggerArrow;
composes: arrowBottom from 'components/ui/icons.scss';
}

View File

@@ -4,6 +4,8 @@ import { FormattedMessage as Message, FormattedRelative as Relative, FormattedHT
import Helmet from 'react-helmet';
import { userShape } from 'components/user/User';
import { LangMenu } from 'components/langMenu';
import langMenuMessages from 'components/langMenu/langMenu.intl.json';
import ProfileField from './ProfileField';
import styles from './profile.scss';
@@ -74,6 +76,12 @@ export default class Profile extends Component {
) : ''}
/>
<ProfileField
label={<Message {...langMenuMessages.siteLanguage} />}
value={<LangMenu toggleRef={(toggle) => this.langMenuToggle = toggle} showCurrentLang />}
onChange={() => this.langMenuToggle()}
/>
<ProfileField
label={<Message {...messages.twoFactorAuth} />}
value={<Message {...messages.disabled} />}
@@ -82,7 +90,6 @@ export default class Profile extends Component {
<ProfileField
label={'UUID'}
value={user.uuid}
readonly
/>
</div>
</div>

View File

@@ -9,13 +9,23 @@ export default class ProfileField extends Component {
static propTypes = {
label: React.PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
link: PropTypes.string,
onChange: PropTypes.func,
value: React.PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
warningMessage: React.PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
readonly: PropTypes.bool
warningMessage: React.PropTypes.oneOfType([PropTypes.string, PropTypes.element])
};
render() {
const {label, value, warningMessage, readonly, link = '#'} = this.props;
const {label, value, warningMessage, link, onChange} = this.props;
let Action = null;
if (link) {
Action = (props) => <Link to={link} {...props} />;
}
if (onChange) {
Action = (props) => <a onClick={onChange} {...props} href="#" />;
}
return (
<div className={styles.paramItem}>
@@ -23,13 +33,11 @@ export default class ProfileField extends Component {
<div className={styles.paramName}>{label}:</div>
<div className={styles.paramValue}>{value}</div>
{readonly ? '' : (
<div className={styles.paramAction}>
<Link to={link}>
<span className={styles.paramEditIcon} />
</Link>
</div>
)}
{Action ? (
<Action to={link} className={styles.paramAction}>
<span className={styles.paramEditIcon} />
</Action>
) : null}
</div>
{warningMessage ? (

View File

@@ -74,6 +74,7 @@
.paramAction {
text-align: center;
cursor: pointer;
}
.paramEditIcon {

View File

@@ -261,7 +261,7 @@
}
[type="submit"] {
background: url('images/loader_button.gif') #95a5a6 center center;
background: url('./images/loader_button.gif') #95a5a6 center center;
cursor: default;
color: #fff;

View File

@@ -8,3 +8,13 @@
font-size: 24px;
}
.arrowTop {
composes: arrow;
transform: rotate(180deg);
}
.arrowBottom {
composes: arrow;
}

View File

@@ -2,6 +2,7 @@ import { routeActions } from 'react-router-redux';
import request from 'services/request';
import accounts from 'services/api/accounts';
import { setLocale } from 'components/i18n/actions';
export const UPDATE = 'USER_UPDATE';
/**
@@ -15,6 +16,25 @@ export function updateUser(payload) {
};
}
export const CHANGE_LANG = 'USER_CHANGE_LANG';
export function changeLang(lang) {
return (dispatch, getState) => dispatch(setLocale(lang))
.then((lang) => {
const {user: {isGuest, lang: oldLang}} = getState();
if (!isGuest && oldLang !== lang) {
accounts.changeLang(lang);
}
dispatch({
type: CHANGE_LANG,
payload: {
lang
}
});
});
}
export const SET = 'USER_SET';
export function setUser(payload) {
return {
@@ -26,6 +46,7 @@ export function setUser(payload) {
export function logout() {
return (dispatch) => {
dispatch(setUser({isGuest: true}));
dispatch(changeLang());
dispatch(routeActions.push('/login'));
};
}
@@ -35,6 +56,8 @@ export function fetchUserData() {
accounts.current()
.then((resp) => {
dispatch(updateUser(resp));
return dispatch(changeLang(resp.lang));
})
.catch((resp) => {
/*

View File

@@ -1,4 +1,4 @@
import { authenticate } from 'components/user/actions';
import { authenticate, changeLang } from 'components/user/actions';
/**
* Initializes User state with the fresh data
@@ -8,15 +8,15 @@ import { authenticate } from 'components/user/actions';
* @return {Promise} a promise, that resolves in User state
*/
export function factory(store) {
const state = store.getState();
return new Promise((resolve, reject) => {
if (state.user.token) {
const {user} = store.getState();
if (user.token) {
// authorizing user if it is possible
store.dispatch(authenticate(state.user.token))
.then(() => resolve(store.getState().user), reject);
} else {
resolve(state.user);
return store.dispatch(authenticate(user.token)).then(resolve, reject);
}
// auto-detect guests language
store.dispatch(changeLang()).then(resolve, reject);
});
}

View File

@@ -1,4 +1,4 @@
import { UPDATE, SET } from './actions';
import { UPDATE, SET, CHANGE_LANG } from './actions';
import User from './User';
@@ -8,6 +8,16 @@ export default function user(
{type, payload = null}
) {
switch (type) {
case CHANGE_LANG:
if (!payload || !payload.lang) {
throw new Error('payload.lang is required for user reducer');
}
return new User({
...state,
lang: payload.lang
});
case UPDATE:
if (!payload) {
throw new Error('payload is required for user reducer');