From ecf41dd725723c2ff8fb17df567e5dd7d7898a16 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Thu, 19 May 2016 22:41:43 +0300 Subject: [PATCH] #84: language switching on frontend --- src/components/auth/appInfo/AppInfo.jsx | 5 + src/components/auth/appInfo/appInfo.scss | 7 + src/components/i18n/IntlProvider.jsx | 23 +++ src/components/i18n/actions.js | 18 +++ src/components/i18n/index.js | 5 + src/components/i18n/reducer.js | 9 ++ src/components/langMenu/LangMenu.jsx | 140 ++++++++++++++++++ src/components/langMenu/index.js | 5 + src/components/langMenu/langMenu.intl.json | 3 + src/components/langMenu/langMenu.scss | 119 +++++++++++++++ src/components/profile/Profile.jsx | 9 +- src/components/profile/ProfileField.jsx | 28 ++-- src/components/profile/profile.scss | 1 + src/components/ui/form/form.scss | 2 +- src/components/ui/icons.scss | 10 ++ src/components/user/actions.js | 23 +++ src/components/user/factory.js | 16 +- src/components/user/reducer.js | 12 +- src/i18n/en.json | 1 + src/i18n/ru.json | 1 + .../{flag_united_kingdom.svg => flag_en.svg} | 0 src/icons/{flag_russian.svg => flag_ru.svg} | 0 src/icons/webfont/globe.svg | 41 +++++ src/index.js | 19 +-- src/index.scss | 1 + src/reducers.js | 2 + src/services/api/accounts.js | 7 + src/services/i18n.js | 8 +- webpack.config.js | 6 +- 29 files changed, 480 insertions(+), 41 deletions(-) create mode 100644 src/components/i18n/IntlProvider.jsx create mode 100644 src/components/i18n/actions.js create mode 100644 src/components/i18n/index.js create mode 100644 src/components/i18n/reducer.js create mode 100644 src/components/langMenu/LangMenu.jsx create mode 100644 src/components/langMenu/index.js create mode 100644 src/components/langMenu/langMenu.intl.json create mode 100644 src/components/langMenu/langMenu.scss rename src/icons/{flag_united_kingdom.svg => flag_en.svg} (100%) rename src/icons/{flag_russian.svg => flag_ru.svg} (100%) create mode 100644 src/icons/webfont/globe.svg diff --git a/src/components/auth/appInfo/AppInfo.jsx b/src/components/auth/appInfo/AppInfo.jsx index a7157ea..7f7c92b 100644 --- a/src/components/auth/appInfo/AppInfo.jsx +++ b/src/components/auth/appInfo/AppInfo.jsx @@ -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 {
+ +
+ +
); } diff --git a/src/components/auth/appInfo/appInfo.scss b/src/components/auth/appInfo/appInfo.scss index 9bf6d03..eac2921 100644 --- a/src/components/auth/appInfo/appInfo.scss +++ b/src/components/auth/appInfo/appInfo.scss @@ -50,3 +50,10 @@ display: none; } } + +.langMenu { + position: absolute; + bottom: 10px; + left: 0; + right: 0; +} diff --git a/src/components/i18n/IntlProvider.jsx b/src/components/i18n/IntlProvider.jsx new file mode 100644 index 0000000..87bba7c --- /dev/null +++ b/src/components/i18n/IntlProvider.jsx @@ -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 ( + + ); + } +} + + +import { connect } from 'react-redux'; + +export default connect(({i18n}) => i18n)(IntlProvider); diff --git a/src/components/i18n/actions.js b/src/components/i18n/actions.js new file mode 100644 index 0000000..5b532d4 --- /dev/null +++ b/src/components/i18n/actions.js @@ -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; + }); +} diff --git a/src/components/i18n/index.js b/src/components/i18n/index.js new file mode 100644 index 0000000..58a61d5 --- /dev/null +++ b/src/components/i18n/index.js @@ -0,0 +1,5 @@ +import IntlProvider from './IntlProvider'; + +export { + IntlProvider +}; diff --git a/src/components/i18n/reducer.js b/src/components/i18n/reducer.js new file mode 100644 index 0000000..19ff6d6 --- /dev/null +++ b/src/components/i18n/reducer.js @@ -0,0 +1,9 @@ +import { SET_LOCALE } from './actions'; + +export default function(state = {}, {type, payload}) { + if (type === SET_LOCALE) { + return payload; + } + + return state; +} diff --git a/src/components/langMenu/LangMenu.jsx b/src/components/langMenu/LangMenu.jsx new file mode 100644 index 0000000..e736bbf --- /dev/null +++ b/src/components/langMenu/LangMenu.jsx @@ -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 ( +
+
+
    + {Object.keys(LANGS).map((lang) => ( +
  • + {this.renderLangLabel(lang)} +
  • + ))} +
+
+ +
+ + {showCurrentLang + ? this.renderLangLabel(userLang) : ( + + + {' '} + + {' '} + + + )} + +
+
+ ); + } + + renderLangLabel(lang) { + const langLabel = LANGS[lang]; + + return ( + + + {langLabel} + + ); + } + + 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); diff --git a/src/components/langMenu/index.js b/src/components/langMenu/index.js new file mode 100644 index 0000000..33b1b7b --- /dev/null +++ b/src/components/langMenu/index.js @@ -0,0 +1,5 @@ +import LangMenu from './LangMenu'; + +export { + LangMenu +}; diff --git a/src/components/langMenu/langMenu.intl.json b/src/components/langMenu/langMenu.intl.json new file mode 100644 index 0000000..3c26c68 --- /dev/null +++ b/src/components/langMenu/langMenu.intl.json @@ -0,0 +1,3 @@ +{ + "siteLanguage": "Site language" +} diff --git a/src/components/langMenu/langMenu.scss b/src/components/langMenu/langMenu.scss new file mode 100644 index 0000000..671d879 --- /dev/null +++ b/src/components/langMenu/langMenu.scss @@ -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'; +} diff --git a/src/components/profile/Profile.jsx b/src/components/profile/Profile.jsx index 7c81b34..71e5759 100644 --- a/src/components/profile/Profile.jsx +++ b/src/components/profile/Profile.jsx @@ -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 { ) : ''} /> + } + value={ this.langMenuToggle = toggle} showCurrentLang />} + onChange={() => this.langMenuToggle()} + /> + } value={} @@ -82,7 +90,6 @@ export default class Profile extends Component { diff --git a/src/components/profile/ProfileField.jsx b/src/components/profile/ProfileField.jsx index 41ee9a1..bc40320 100644 --- a/src/components/profile/ProfileField.jsx +++ b/src/components/profile/ProfileField.jsx @@ -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) => ; + } + + if (onChange) { + Action = (props) => ; + } return (
@@ -23,13 +33,11 @@ export default class ProfileField extends Component {
{label}:
{value}
- {readonly ? '' : ( -
- - - -
- )} + {Action ? ( + + + + ) : null}
{warningMessage ? ( diff --git a/src/components/profile/profile.scss b/src/components/profile/profile.scss index 09ad9a4..cddf30d 100644 --- a/src/components/profile/profile.scss +++ b/src/components/profile/profile.scss @@ -74,6 +74,7 @@ .paramAction { text-align: center; + cursor: pointer; } .paramEditIcon { diff --git a/src/components/ui/form/form.scss b/src/components/ui/form/form.scss index 8798cf3..a22bcd0 100644 --- a/src/components/ui/form/form.scss +++ b/src/components/ui/form/form.scss @@ -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; diff --git a/src/components/ui/icons.scss b/src/components/ui/icons.scss index 58793c8..611ba69 100644 --- a/src/components/ui/icons.scss +++ b/src/components/ui/icons.scss @@ -8,3 +8,13 @@ font-size: 24px; } + +.arrowTop { + composes: arrow; + + transform: rotate(180deg); +} + +.arrowBottom { + composes: arrow; +} diff --git a/src/components/user/actions.js b/src/components/user/actions.js index 1b07bb8..0b43a10 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -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) => { /* diff --git a/src/components/user/factory.js b/src/components/user/factory.js index 04425dc..07a6d8a 100644 --- a/src/components/user/factory.js +++ b/src/components/user/factory.js @@ -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); }); } diff --git a/src/components/user/reducer.js b/src/components/user/reducer.js index 5ba7f16..b107027 100644 --- a/src/components/user/reducer.js +++ b/src/components/user/reducer.js @@ -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'); diff --git a/src/i18n/en.json b/src/i18n/en.json index fce66f9..762b94b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -66,6 +66,7 @@ "components.auth.register.termsOfService": "terms of service", "components.auth.register.yourEmail": "Your E-mail", "components.auth.register.yourNickname": "Your nickname", + "components.langMenu.siteLanguage": "Site language", "components.profile.accountDescription": "Ely.by account allows you to get access to many Minecraft resources. Please, take care of your account safety. Use secure password and change it regularly.", "components.profile.accountPreferencesTitle": "Ely.by account preferences", "components.profile.changePassword.achievementLossWarning": "Are you cherish your game achievements, right?", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 3b818d4..e182eda 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -66,6 +66,7 @@ "components.auth.register.termsOfService": "правилами сервиса", "components.auth.register.yourEmail": "Ваш E-mail", "components.auth.register.yourNickname": "Желаемый ник", + "components.langMenu.siteLanguage": "Язык сайта", "components.profile.accountDescription": "Благодаря аккаунту Ely.by вы можете получить доступ ко многим ресурсам, связанным с Minecraft. Берегите свой аккаунт, используйте надёжный пароль и регулярно его меняйте.", "components.profile.accountPreferencesTitle": "Настройки аккаунта Ely.by", "components.profile.changePassword.achievementLossWarning": "Вы ведь дорожите своими игровыми достижениями?", diff --git a/src/icons/flag_united_kingdom.svg b/src/icons/flag_en.svg similarity index 100% rename from src/icons/flag_united_kingdom.svg rename to src/icons/flag_en.svg diff --git a/src/icons/flag_russian.svg b/src/icons/flag_ru.svg similarity index 100% rename from src/icons/flag_russian.svg rename to src/icons/flag_ru.svg diff --git a/src/icons/webfont/globe.svg b/src/icons/webfont/globe.svg new file mode 100644 index 0000000..a103006 --- /dev/null +++ b/src/icons/webfont/globe.svg @@ -0,0 +1,41 @@ + + + + + diff --git a/src/index.js b/src/index.js index c142e7e..41a6b99 100644 --- a/src/index.js +++ b/src/index.js @@ -15,10 +15,8 @@ import thunk from 'redux-thunk'; import { Router, browserHistory } from 'react-router'; import { syncHistory, routeReducer } from 'react-router-redux'; -import { IntlProvider } from 'react-intl'; - import { factory as userFactory } from 'components/user/factory'; -import i18n from 'services/i18n'; +import { IntlProvider } from 'components/i18n'; import reducers from 'reducers'; import routesFactory from 'routes'; @@ -35,20 +33,15 @@ const store = applyMiddleware( )(createStore)(reducer); userFactory(store) -.then(({lang}) => - i18n.require( - i18n.detectLanguage(lang) - ) -) -.then(({locale, messages}) => { +.then(() => { ReactDOM.render( - - + + {routesFactory(store)} - - , + + , document.getElementById('app') ); diff --git a/src/index.scss b/src/index.scss index 0ca6744..e01b582 100644 --- a/src/index.scss +++ b/src/index.scss @@ -36,4 +36,5 @@ p { line-height: 1; padding: 0; margin: 0; + list-style: none; } diff --git a/src/reducers.js b/src/reducers.js index a491740..27fd74c 100644 --- a/src/reducers.js +++ b/src/reducers.js @@ -1,9 +1,11 @@ import auth from 'components/auth/reducer'; import user from 'components/user/reducer'; +import i18n from 'components/i18n/reducer'; import popup from 'components/ui/popup/reducer'; export default { auth, user, + i18n, popup }; diff --git a/src/services/api/accounts.js b/src/services/api/accounts.js index 3319939..d47a6be 100644 --- a/src/services/api/accounts.js +++ b/src/services/api/accounts.js @@ -25,5 +25,12 @@ export default { '/api/accounts/change-username', {username, password} ); + }, + + changeLang(lang) { + return request.post( + '/api/accounts/change-lang', + {lang} + ); } }; diff --git a/src/services/i18n.js b/src/services/i18n.js index 0ed5fd0..53a301b 100644 --- a/src/services/i18n.js +++ b/src/services/i18n.js @@ -2,6 +2,10 @@ import { addLocaleData } from 'react-intl'; import enLocaleData from 'react-intl/locale-data/en'; import ruLocaleData from 'react-intl/locale-data/ru'; +// till we have not so many locales, we can require their data at once +addLocaleData(enLocaleData); +addLocaleData(ruLocaleData); + const SUPPORTED_LANGUAGES = ['ru', 'en']; const DEFAULT_LANGUAGE = 'en'; function getUserLanguages(userSelectedLang = []) { @@ -23,10 +27,6 @@ export default { }, require(locale) { - // till we have not so many locales, we can require their data at once - addLocaleData(enLocaleData); - addLocaleData(ruLocaleData); - return new Promise(require(`bundle!i18n/${locale}.json`)) .then((messages) => ({locale, messages})); } diff --git a/webpack.config.js b/webpack.config.js index d62a6b0..3a8a963 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -153,7 +153,7 @@ var webpackConfig = { loader: 'babel' }, { - test: /\.(png|gif|jpg)$/, + test: /\.(png|gif|jpg|svg)$/, loader: 'url?limit=1000' }, { // TODO: увы, эта штука пока не работает. Хеш добавляется через ./webpack/node-sass-iconfont-importer @@ -197,9 +197,9 @@ var webpackConfig = { // // Например: // file: components/ui/foo.scss - // images/foo.png -> components/ui/images/foo.png + // ./images/foo.png -> components/ui/images/foo.png - if (url[0] !== '/') { + if (url.indexOf('./') === 0) { var relativeToRoot = dirname.split(rootPath + '/')[1]; return path.join(relativeToRoot, url);