diff --git a/package.json b/package.json index 122e87a..23ffa7b 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "license": "UNLICENSED", "repository": "git@bitbucket.org:ErickSkrauch/ely.by-account.git", "scripts": { - "start": "webpack-dev-server --progress --colors", + "start": "rm -rf dist/ && webpack-dev-server --progress --colors", "up": "npm install", "test": "karma start ./karma.conf.js", "lint": "eslint ./src", + "i18n": "cd ./scripts && ./node_modules/.bin/babel-node i18n-collect.js", "build": "rm -rf dist/ && webpack --progress --colors -p" }, "dependencies": { @@ -44,6 +45,7 @@ "babel-preset-react-hmre": "^1.0.1", "babel-preset-stage-0": "^6.3.13", "babel-runtime": "^5.6.15", + "bundle-loader": "^0.5.4", "chai": "^3.0.0", "chokidar": "^1.2.0", "css-loader": "^0.23.0", diff --git a/scripts/.babelrc b/scripts/.babelrc new file mode 100644 index 0000000..0522a89 --- /dev/null +++ b/scripts/.babelrc @@ -0,0 +1,3 @@ +{ + "breakConfig": true +} diff --git a/scripts/i18n-collect.js b/scripts/i18n-collect.js new file mode 100644 index 0000000..b0a22b3 --- /dev/null +++ b/scripts/i18n-collect.js @@ -0,0 +1,147 @@ +import fs from 'fs'; +import {sync as globSync} from 'glob'; +import {sync as mkdirpSync} from 'mkdirp'; +import chalk from 'chalk'; +import prompt from 'prompt'; + +const MESSAGES_PATTERN = `../dist/messages/**/*.json`; +const LANG_DIR = `../src/i18n`; +const DEFAULT_LOCALE = 'en'; +const SUPPORTED_LANGS = [DEFAULT_LOCALE].concat('ru'); + +/** + * Aggregates the default messages that were extracted from the app's + * React components via the React Intl Babel plugin. An error will be thrown if + * there are messages in different components that use the same `id`. The result + * is a flat collection of `id: message` pairs for the app's default locale. + */ +let idToFileMap = {}; +let duplicateIds = []; +let defaultMessages = globSync(MESSAGES_PATTERN) + .map((filename) => [filename, JSON.parse(fs.readFileSync(filename, 'utf8'))]) + .reduce((collection, [file, descriptors]) => { + descriptors.forEach(({id, defaultMessage}) => { + if (collection.hasOwnProperty(id)) { + duplicateIds.push(id); + } + + collection[id] = defaultMessage; + idToFileMap[id] = (idToFileMap[id] || []).concat(file); + }); + + return collection; + }, {}); + +if (duplicateIds.length) { + console.log('\nFound duplicated ids:'); + duplicateIds.forEach((id) => console.log(`${chalk.yellow(id)}:\n - ${idToFileMap[id].join('\n - ')}\n`)); + console.log(chalk.red('Please correct the errors above to proceed further!')); + return; +} + +duplicateIds = null; +idToFileMap = null; + +/** + * Making a diff with the previous DEFAULT_LOCALE version + */ +const defaultMessagesPath = `${LANG_DIR}/${DEFAULT_LOCALE}.json`; +let keysToUpdate = []; +let keysToAdd = []; +let keysToRemove = []; +const isNotMarked = (value) => value.slice(0, 2) !== '--'; +try { + const prevMessages = JSON.parse(fs.readFileSync(defaultMessagesPath, 'utf8')); + keysToAdd = Object.keys(defaultMessages).filter((key) => !prevMessages[key]); + keysToRemove = Object.keys(prevMessages).filter((key) => !defaultMessages[key]).filter(isNotMarked); + keysToUpdate = Object.entries(prevMessages).reduce((acc, [key, message]) => + acc.concat(defaultMessages[key] && defaultMessages[key] !== message ? key : []) + , []); +} catch(e) { + console.log(chalk.yellow(`Can not read ${defaultMessagesPath}. The new file will be created.`), e); +} + +if (!keysToAdd.length && !keysToRemove.length && !keysToUpdate.length) { + return console.log(chalk.green('Everything is up to date!')); +} + +console.log(chalk.magenta(`The diff relative to default locale (${DEFAULT_LOCALE}) is:`)); + +if (keysToRemove.length) { + console.log('The following keys will be removed:'); + console.log(chalk.red('\n - ') + keysToRemove.join(chalk.red('\n - ')) + '\n'); +} + +if (keysToAdd.length) { + console.log('The following keys will be added:'); + console.log(chalk.green('\n + ') + keysToAdd.join(chalk.green('\n + ')) + '\n'); +} + +if (keysToUpdate.length) { + console.log('The following keys will be updated:'); + console.log(chalk.yellow('\n @ ') + keysToUpdate.join(chalk.yellow('\n @ ')) + '\n'); +} + +prompt.start(); +prompt.get({ + properties: { + apply: { + description: 'Apply changes? [Y/n]', + pattern: /^y|n$/i, + message: 'Please enter "y" or "n"', + default: 'y', + before: (value) => value.toLowerCase() === 'y' + } + } +}, (err, resp) => { + console.log('\n'); + + if (err || !resp.apply) { + return console.log(chalk.red('Aborted')); + } + + buildLocales(); + + console.log(chalk.green('All locales was successfuly built')); +}); + + +function buildLocales() { + mkdirpSync(LANG_DIR); + + SUPPORTED_LANGS.map((lang) => { + const destPath = `${LANG_DIR}/${lang}.json`; + + let newMessages = {}; + try { + newMessages = JSON.parse(fs.readFileSync(defaultMessagesPath, 'utf8')); + } catch (e) { + console.log(chalk.yellow(`Can not read ${defaultMessagesPath}. The new file will be created.`), e); + } + + keysToRemove.forEach((key) => { + delete newMessages[key]; + }); + keysToUpdate.forEach((key) => { + newMessages[`--${key}`] = newMessages[key]; + }); + keysToAdd.concat(keysToUpdate).forEach((key) => { + newMessages[key] = defaultMessages[key]; + }); + + const sortedKeys = Object.keys(newMessages).sort((a, b) => { + a = a.replace(/^\-+/, ''); + b = b.replace(/^\-+/, ''); + + return a < b || !isNotMarked(a) ? -1 : 1; + }); + + const sortedNewMessages = sortedKeys.reduce((acc, key) => { + acc[key] = newMessages[key]; + + return acc; + }, {}); + + fs.writeFileSync(destPath, JSON.stringify(sortedNewMessages, null, 4) + '\n'); + }); +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..b5b80ea --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,15 @@ +{ + "name": "scripts", + "version": "1.0.0", + "description": "", + "main": "i18n-build.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "chalk": "^1.1.3" + } +} diff --git a/src/components/auth/login/Login.jsx b/src/components/auth/login/Login.jsx index 61d482a..375607d 100644 --- a/src/components/auth/login/Login.jsx +++ b/src/components/auth/login/Login.jsx @@ -8,7 +8,7 @@ import buttons from 'components/ui/buttons.scss'; import { Input } from 'components/ui/form'; import BaseAuthBody from 'components/auth/BaseAuthBody'; -import passwordMessages from 'components/auth/password/Password.messages'; +import passwordMessages from 'components/auth/password/Password.intl.json'; import messages from './Login.messages'; class Body extends BaseAuthBody { diff --git a/src/components/auth/password/Password.intl.json b/src/components/auth/password/Password.intl.json new file mode 100644 index 0000000..2503324 --- /dev/null +++ b/src/components/auth/password/Password.intl.json @@ -0,0 +1,10 @@ +{ + "passwordTitle": "Enter password", + "signInButton": "Sign in", + "invalidPassword": "You entered wrong account password.", + "suggestResetPassword": "Are you have {link}?", + "forgotYourPassword": "forgot your password", + "forgotPassword": "Forgot password", + "accountPassword": "Account password", + "rememberMe": "Remember me on this device" +} diff --git a/src/components/auth/password/Password.jsx b/src/components/auth/password/Password.jsx index 2458ab8..d2c1587 100644 --- a/src/components/auth/password/Password.jsx +++ b/src/components/auth/password/Password.jsx @@ -10,7 +10,7 @@ import { Input, Checkbox } from 'components/ui/form'; import BaseAuthBody from 'components/auth/BaseAuthBody'; import styles from './password.scss'; -import messages from './Password.messages'; +import messages from './Password.intl.json'; class Body extends BaseAuthBody { static displayName = 'PasswordBody'; diff --git a/src/components/auth/password/Password.messages.js b/src/components/auth/password/Password.messages.js deleted file mode 100644 index 9dfc523..0000000 --- a/src/components/auth/password/Password.messages.js +++ /dev/null @@ -1,43 +0,0 @@ -import { defineMessages } from 'react-intl'; - -export default defineMessages({ - passwordTitle: { - id: 'passwordTitle', - defaultMessage: 'Enter password' - }, - - signInButton: { - id: 'signInButton', - defaultMessage: 'Sign in' - }, - - invalidPassword: { - id: 'invalidPassword', - defaultMessage: 'You entered wrong account password.' - }, - - suggestResetPassword: { - id: 'suggestResetPassword', - defaultMessage: 'Are you have {link}?' - }, - - forgotYourPassword: { - id: 'forgotYourPassword', - defaultMessage: 'forgot your password' - }, - - forgotPassword: { - id: 'forgotPassword', - defaultMessage: 'Forgot password' - }, - - accountPassword: { - id: 'accountPassword', - defaultMessage: 'Account password' - }, - - rememberMe: { - id: 'rememberMe', - defaultMessage: 'Remember me on this device' - } -}); diff --git a/src/components/auth/register/Register.jsx b/src/components/auth/register/Register.jsx index 68056eb..5f85b74 100644 --- a/src/components/auth/register/Register.jsx +++ b/src/components/auth/register/Register.jsx @@ -7,6 +7,7 @@ import buttons from 'components/ui/buttons.scss'; import { Input, Checkbox } from 'components/ui/form'; import BaseAuthBody from 'components/auth/BaseAuthBody'; +import passwordMessages from 'components/auth/password/Password.intl.json'; import activationMessages from 'components/auth/activation/Activation.messages'; import messages from './Register.messages'; @@ -44,7 +45,7 @@ class Body extends BaseAuthBody { color="blue" type="password" required - placeholder={messages.accountPassword} + placeholder={passwordMessages.accountPassword} /> Please, change password.", + "passCodeToApp": "To complete authorization process, please, provide the following code to {appName}", + "password": "Password", + "passwordChangeMessage": "To enhance the security of your account, please change your password.", + "passwordRequired": "Please enter password", + "passwordTooShort": "Your password is too short", + "passwordsDoesNotMatch": "The passwords does not match", + "permissionsTitle": "Application permissions", + "personalData": "Personal data", + "pleaseEnterPassword": "Please, enter your current password", + "preferencesDescription": "Here you can change the key preferences of your account. Please note that all actions must be confirmed by entering a password.", + "pressButtonToStart": "Press the button below to send a message with the code for E-mail change initialization.", + "rePasswordRequired": "Please retype your password", + "register": "Join", + "registerTitle": "Sign Up", + "repeatPassword": "Repeat password", + "rulesAgreementRequired": "You must accept rules in order to create an account", + "scope_minecraft_server_session": "Authorization data for minecraft server", + "scope_offline_access": "Access to your profile data, when you offline", + "sendEmailButton": "Send E-mail", + "sendMail": "Send mail", + "signUpButton": "Register", + "skipThisStep": "Skip password changing", + "suggestResetPassword": "Are you have {link}?", + "termsOfService": "Terms of service", + "theAppNeedsAccess1": "This application needs access", + "theAppNeedsAccess2": "to your data", + "title": "Confirm your action", + "twoFactorAuth": "Two factor auth", + "usernameRequired": "Username is required", + "usernameUnavailable": "This username is already taken", + "waitAppReaction": "Please, wait till your application response", + "youAuthorizedAs": "You authorized as:", + "yourEmail": "Your E-mail", + "yourNickname": "Your nickname" +} diff --git a/src/i18n/ru.json b/src/i18n/ru.json new file mode 100644 index 0000000..2a94c66 --- /dev/null +++ b/src/i18n/ru.json @@ -0,0 +1,111 @@ +{ + "acceptRules": "I agree with {link}", + "accountActivationTitle": "Account activation", + "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.", + "accountEmail": "Enter account E-mail", + "accountPassword": "Account password", + "accountPreferencesTitle": "Ely.by account preferences", + "activationMailWasSent": "Please check {email} for the message with the last registration step", + "alreadyReceivedCode": "Already received code", + "approve": "Approve", + "authForAppFailed": "Authorization for {appName} was failed", + "authForAppSuccessful": "Authorization for {appName} was successfully completed", + "change": "Change", + "changeEmailButton": "Change E-mail", + "changeEmailDescription": "To change current account E-mail you must first verify that you own the current address and then confirm the new one.", + "changeEmailTitle": "Change E-mail", + "changePasswordTitle": "Change password", + "changeUsernameButton": "Change nickname", + "changeUsernameDescription": "You can change your nickname to any arbitrary value. Remember that it is not recommended to take a nickname of already existing Mojang account.", + "changeUsernameTitle": "Change nickname", + "changeUsernameWarning": "Be careful: if you playing on the server with nickname binding, then after changing nickname you may lose all your progress.", + "changedAt": "Changed {at}", + "codePlaceholder": "Paste the code here", + "components.auth.password.accountPassword": "Account password", + "components.auth.password.forgotPassword": "Forgot password", + "components.auth.password.forgotYourPassword": "forgot your password", + "components.auth.password.invalidPassword": "You entered wrong account password.", + "components.auth.password.passwordTitle": "Enter password", + "components.auth.password.rememberMe": "Remember me on this device", + "components.auth.password.signInButton": "Sign in", + "components.auth.password.suggestResetPassword": "Are you have {link}?", + "components.profile.changePassword.achievementLossWarning": "Вы ведь дорожите своими игровыми достижениями?", + "components.profile.changePassword.changePasswordButton": "Сменить пароль", + "components.profile.changePassword.changePasswordDescription": "Придумайте пароль, который будет отличаться от ваших паролей на других сайтах и не будет совпадаеть с тем паролем, который вы используете для входа на различные игровые сервера Minecraft.", + "components.profile.changePassword.changePasswordTitle": "Смена пароля", + "components.profile.changePassword.logoutOnAllDevices": "Вылогиниться на всех устройствах", + "components.profile.changePassword.newPasswordLabel": "Новый пароль:", + "components.profile.changePassword.passwordRequirements": "Пароль должен содержать не менее 8 символов. Это могут быть любым символы — не ограничивайте себя, придумайте непредсказуемый пароль!", + "components.profile.changePassword.repeatNewPasswordLabel": "Повторите указанный пароль:", + "confirmEmail": "Confirm E-mail", + "contactSupport": "Contact support", + "copy": "Copy", + "currentAccountEmail": "Current account E-mail address:", + "currentPassword": "Enter current password", + "decline": "Decline", + "didNotReceivedEmail": "Did not received E-mail?", + "disabled": "Disabled", + "emailInvalid": "Email is invalid", + "emailIsTempmail": "Tempmail E-mail addresses is not allowed", + "emailNotAvailable": "This email is already registered.", + "emailOrUsername": "E-mail or username", + "emailRequired": "Email is required", + "enterFinalizationCode": "The E-mail change confirmation code was sent to {email}. Please enter the code received into the field below:", + "enterInitializationCode": "The E-mail with an initialization code for E-mail change procedure was sent to {email}. Please enter the code into the field below:", + "enterNewEmail": "Then provide your new E-mail address, that you want to use with this account. You will be mailed with confirmation code.", + "enterTheCode": "Enter the code from E-mail here", + "forgotPasswordMessage": "Specify the registration E-mail address for your account and we will send an email with instructions for further password recovery.", + "forgotPasswordTitle": "Forgot password", + "forgotYourPassword": "forgot your password", + "goToAuth": "Go to auth", + "invalidPassword": "You have entered wrong account password.", + "keyNotExists": "The key is incorrect", + "keyRequired": "Please, enter an activation key", + "loginNotExist": "Sorry, Ely doesn't recognise your login.", + "loginRequired": "Please enter email or username", + "loginTitle": "Sign in", + "logout": "Logout", + "mojangPriorityWarning": "A Mojang account with the same nickname was found. According to project rules, account owner has the right to demand the restoration of control over nickname.", + "newEmailPlaceholder": "Enter new E-mail", + "newPassword": "Enter new password", + "newPasswordRequired": "Please enter new password", + "newRePassword": "Repeat new password", + "newRePasswordRequired": "Please repeat new password", + "next": "Next", + "nickname": "Nickname", + "oldHashingAlgoWarning": "Your was hashed with an old hashing algorithm.
Please, change password.", + "passCodeToApp": "To complete authorization process, please, provide the following code to {appName}", + "password": "Password", + "passwordChangeMessage": "To enhance the security of your account, please change your password.", + "passwordRequired": "Please enter password", + "passwordTooShort": "Your password is too short", + "passwordsDoesNotMatch": "The passwords does not match", + "permissionsTitle": "Application permissions", + "personalData": "Personal data", + "pleaseEnterPassword": "Please, enter your current password", + "preferencesDescription": "Here you can change the key preferences of your account. Please note that all actions must be confirmed by entering a password.", + "pressButtonToStart": "Press the button below to send a message with the code for E-mail change initialization.", + "rePasswordRequired": "Please retype your password", + "register": "Join", + "registerTitle": "Sign Up", + "repeatPassword": "Repeat password", + "rulesAgreementRequired": "You must accept rules in order to create an account", + "scope_minecraft_server_session": "Authorization data for minecraft server", + "scope_offline_access": "Access to your profile data, when you offline", + "sendEmailButton": "Send E-mail", + "sendMail": "Send mail", + "signUpButton": "Register", + "skipThisStep": "Skip password changing", + "suggestResetPassword": "Are you have {link}?", + "termsOfService": "Terms of service", + "theAppNeedsAccess1": "This application needs access", + "theAppNeedsAccess2": "to your data", + "title": "Confirm your action", + "twoFactorAuth": "Two factor auth", + "usernameRequired": "Username is required", + "usernameUnavailable": "This username is already taken", + "waitAppReaction": "Please, wait till your application response", + "youAuthorizedAs": "You authorized as:", + "yourEmail": "Your E-mail", + "yourNickname": "Your nickname" +} diff --git a/src/index.js b/src/index.js index 78a18b6..b070e5c 100644 --- a/src/index.js +++ b/src/index.js @@ -15,7 +15,9 @@ import thunk from 'redux-thunk'; import { Router, browserHistory } from 'react-router'; import { syncHistory, routeReducer } from 'react-router-redux'; -import { IntlProvider } from 'react-intl'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import enLocaleData from 'react-intl/locale-data/en'; +import ruLocaleData from 'react-intl/locale-data/ru'; import reducers from 'reducers'; import routesFactory from 'routes'; @@ -32,23 +34,47 @@ const store = applyMiddleware( thunk )(createStore)(reducer); -if (process.env.NODE_ENV !== 'production') { - // some shortcuts for testing on localhost +addLocaleData(enLocaleData); +addLocaleData(ruLocaleData); - window.testOAuth = () => location.href = '/oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session'; +// TODO: bind with user state +const SUPPORTED_LANGUAGES = ['ru', 'en']; +const DEFAULT_LANGUAGE = 'en'; +const state = store.getState(); +function getUserLanguages() { + return [].concat(state.user.lang || []) + .concat(navigator.languages || []) + .concat(navigator.language || []); } -ReactDOM.render( - - - - {routesFactory(store)} - - - , - document.getElementById('app') -); +function detectLanguage(userLanguages, availableLanguages, defaultLanguage) { + return (userLanguages || []) + .concat(defaultLanguage) + .map((lang) => lang.split('-').shift().toLowerCase()) + .find((lang) => availableLanguages.indexOf(lang) !== -1); +} -setTimeout(() => { - document.getElementById('loader').classList.remove('is-active'); -}, 50); +const locale = detectLanguage(getUserLanguages(), SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE); + +new Promise(require(`bundle!i18n/${locale}.json`)) + .then((messages) => { + ReactDOM.render( + + + + {routesFactory(store)} + + + , + document.getElementById('app') + ); + + document.getElementById('loader').classList.remove('is-active'); + }); + + + +if (process.env.NODE_ENV !== 'production') { + // some shortcuts for testing on localhost + window.testOAuth = () => location.href = '/oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session'; +} diff --git a/webpack.config.js b/webpack.config.js index 2406c45..d477605 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -156,15 +156,26 @@ var webpackConfig = { }, { test: /\.json$/, + exclude: /intl\.json/, loader: 'json' }, { test: /\.html$/, loader: 'html' + }, + { + test: /\.intl\.json$/, + loader: 'babel!intl-loader!json' } ] }, + resolveLoader: { + alias: { + 'intl-loader': path.resolve('./webpack/intl-loader') + } + }, + sassLoader: { importer: iconfontImporter({ test: /\.font.(js|json)$/, diff --git a/webpack/intl-loader.js b/webpack/intl-loader.js new file mode 100644 index 0000000..a6c1a59 --- /dev/null +++ b/webpack/intl-loader.js @@ -0,0 +1,21 @@ +module.exports = function() { + this.cacheable && this.cacheable(); + + var moduleId = this.context + .replace(this.options.resolve.root, '') + .replace(/^\/|\/$/g, '') + .replace(/\//g, '.'); + + var content = this.inputValue[0]; + content = JSON.stringify(Object.keys(content).reduce(function(translations, key) { + translations[key] = { + id: moduleId + '.' + key, + defaultMessage: content[key] + }; + + return translations; + }, {})); + + return 'import { defineMessages } from \'react-intl\';' + + 'export default defineMessages(' + content + ')'; +};