Client: i18n (#8972)

* wip: client: i18n

* remove maxAge from cookies to get same expiration ad localStorage

* set cookies expiration to 10 years

* moment: load translations in browser, moment: only load necessary data, remove jquery, remove bluebird

* ability to change language

* fix logout

* add some requiresLogin: false to static pages

* fix tests
This commit is contained in:
Matteo Pagliazzi
2017-08-22 18:26:53 +02:00
committed by GitHub
parent e5a92f64c0
commit bd46e3e195
21 changed files with 163 additions and 92 deletions

24
npm-shrinkwrap.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "habitica",
"version": "3.110.0",
"version": "3.111.0",
"dependencies": {
"@gulp-sourcemaps/map-sources": {
"version": "1.0.0",
@@ -2433,13 +2433,11 @@
"version": "4.0.0",
"from": "cross-env@>=4.0.0 <5.0.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-4.0.0.tgz",
"dev": true,
"dependencies": {
"is-windows": {
"version": "1.0.1",
"from": "is-windows@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.1.tgz",
"dev": true
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.1.tgz"
}
}
},
@@ -2447,19 +2445,16 @@
"version": "5.1.0",
"from": "cross-spawn@>=5.0.1 <6.0.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
"dev": true,
"dependencies": {
"lru-cache": {
"version": "4.1.1",
"from": "lru-cache@>=4.0.1 <5.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz",
"dev": true
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz"
},
"which": {
"version": "1.3.0",
"from": "which@>=1.2.9 <2.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz",
"dev": true
"resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz"
}
}
},
@@ -7385,11 +7380,6 @@
"resolved": "https://registry.npmjs.org/jpegtran-bin/-/jpegtran-bin-3.2.0.tgz",
"optional": true
},
"jquery": {
"version": "3.2.1",
"from": "jquery@>=3.1.1 <4.0.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.2.1.tgz"
},
"js-base64": {
"version": "2.1.9",
"from": "js-base64@>=2.1.9 <3.0.0",
@@ -11687,14 +11677,12 @@
"shebang-command": {
"version": "1.2.0",
"from": "shebang-command@>=1.2.0 <2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
"dev": true
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz"
},
"shebang-regex": {
"version": "1.0.0",
"from": "shebang-regex@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
"dev": true
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz"
},
"shell-quote": {
"version": "1.4.3",

View File

@@ -75,7 +75,6 @@
"in-app-purchase": "^1.1.6",
"intro.js": "^2.6.0",
"jade": "~1.11.0",
"jquery": "^3.1.1",
"js2xmlparser": "~1.0.0",
"lodash": "^4.17.4",
"merge-stream": "^1.0.0",

View File

@@ -4,7 +4,13 @@ import Vue from 'vue';
describe('i18n plugin', () => {
before(() => {
Vue.use(i18n);
Vue.use(i18n, {
i18nData: {
strings: {
reportBug: 'Report a Bug',
},
},
});
});
it('adds $t to Vue.prototype', () => {

View File

@@ -21,7 +21,7 @@ module.exports = {
productionGzipExtensions: ['js', 'css'],
// Run the build command with an extra argument to
// View the bundle analyzer report after build finishes:
// `npm run build --report`
// `npm run client:build --report`
// Set to `true` or `false` to always turn it on or off
bundleAnalyzerReport: process.env.npm_config_report, // eslint-disable-line no-process-env
},
@@ -50,6 +50,10 @@ module.exports = {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/logout': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README

View File

@@ -3,8 +3,8 @@
const path = require('path');
const config = require('./config');
const utils = require('./utils');
const projectRoot = path.resolve(__dirname, '../');
const webpack = require('webpack');
const projectRoot = path.resolve(__dirname, '../');
const autoprefixer = require('autoprefixer');
const postcssEasyImport = require('postcss-easy-import');
const IS_PROD = process.env.NODE_ENV === 'production';
@@ -36,7 +36,6 @@ const baseConfig = {
path.join(projectRoot, 'node_modules'),
],
alias: {
jquery: 'jquery/src/jquery',
website: path.resolve(projectRoot, 'website'),
common: path.resolve(projectRoot, 'website/common'),
client: path.resolve(projectRoot, 'website/client'),
@@ -45,10 +44,7 @@ const baseConfig = {
},
},
plugins: [
new webpack.ProvidePlugin({
$: 'jquery',
jQuery: 'jquery',
}),
new webpack.ContextReplacementPlugin(/moment[\\\/]locale$/, /^\.\/(NOT_EXISTING)$/),
],
module: {
rules: [

View File

@@ -6,9 +6,6 @@
"plugins": [
"html"
],
"globals": {
"$": true,
},
"parser": "babel-eslint",
"rules": {
"strict": 0

View File

@@ -262,7 +262,7 @@ export default {
methods: {
logout () {
localStorage.removeItem('habit-mobile-settings');
this.$router.go('/');
window.location.href = '/logout';
},
showInbox () {
this.$root.$emit('show::modal', 'inbox-modal');

View File

@@ -125,7 +125,6 @@
<script>
import axios from 'axios';
import Bluebird from 'bluebird';
import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep';
import { mapState } from 'client/libs/store';
@@ -203,7 +202,7 @@ export default {
}
});
let results = await Bluebird.all(promises);
let results = await Promise.all(promises);
results.forEach(result => {
let userData = result.data.data;
this.$set(this.cachedProfileData, userData._id, userData);

View File

@@ -7,8 +7,8 @@
.col-6
.form-horizontal
h5 {{ $t('language') }}
select.form-control(v-model='selectedLanguage',
@change='changeLanguage()')
select.form-control(:value='user.preferences.language',
@change='changeLanguage($event)')
option(v-for='lang in availableLanguages', :value='lang.code') {{lang.name}}
small
@@ -218,13 +218,7 @@ export default {
return {
SOCIAL_AUTH_NETWORKS: [],
party: {},
// @TODO: import
availableLanguages: [
{
code: 'en',
name: 'English',
},
],
// Made available by the server as a script
availableFormats: ['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'],
dayStartOptions,
newDayStart: 0,
@@ -240,7 +234,10 @@ export default {
this.newDayStart = this.user.preferences.dayStart;
},
computed: {
...mapState({user: 'user.data'}),
...mapState({
user: 'user.data',
availableLanguages: 'i18n.availableLanguages',
}),
timezoneOffsetToUtc () {
let offset = this.user.preferences.timezoneOffset;
let sign = offset > 0 ? '-' : '+';
@@ -254,9 +251,6 @@ export default {
return `UTC${sign}${hour}:${minutes}`;
},
selectedLanguage () {
return this.user.preferences.language;
},
dayStart () {
return this.user.preferences.dayStart;
},
@@ -327,9 +321,11 @@ export default {
// @TODO
// Notification.text(response.data.data.message);
},
changeLanguage () {
this.user.preferences.language = this.selectedLanguage.code;
changeLanguage (e) {
const newLang = e.target.value;
this.user.preferences.language = newLang;
this.set('language');
window.location.href = '/';
},
async changeUser (attribute, updates) {
await axios.put(`/api/v3/user/auth/update-${attribute}`, updates);

View File

@@ -112,12 +112,14 @@ import filter from 'lodash/filter';
import sortBy from 'lodash/sortBy';
import min from 'lodash/min';
import { mapState } from 'client/libs/store';
import encodeParams from 'client/libs/encodeParams';
import subscriptionBlocks from '../../../common/script/content/subscriptionBlocks';
import planGemLimits from '../../../common/script/libs/planGemLimits';
import amazonPaymentsModal from '../payments/amazonModal';
import paymentsMixin from '../../mixins/payments';
// TODO
const STRIPE_PUB_KEY = 'pk_test_6pRNASCoBOKtIshFeQd4XMUh';
export default {
@@ -314,7 +316,7 @@ export default {
queryParams.groupId = group._id;
}
let cancelUrl = `/${paymentMethod}/subscribe/cancel?${$.param(queryParams)}`;
let cancelUrl = `/${paymentMethod}/subscribe/cancel?${encodeParams(queryParams)}`;
await axios.get(cancelUrl);
// Success
alert(this.$t('paypalCanceled'));

View File

@@ -10,11 +10,11 @@
<div id="app"></div>
<!-- built files will be auto injected -->
<script async type='text/javascript'
src='https://static-na.payments-amazon.com/OffAmazonPayments/us/sandbox/js/Widgets.js'>
</script>
<script src="https://checkout.stripe.com/v2/checkout.js"></script>
</script>
<!-- Translations -->
<script type='text/javascript' src='/api/v3/i18n/browser-script'></script>
<script async type='text/javascript' src='https://static-na.payments-amazon.com/OffAmazonPayments/us/sandbox/js/Widgets.js'></script>
<script async type='text/javascript' src="https://checkout.stripe.com/v2/checkout.js"></script>
<script>
// Amplitude
// var r = window.amplitude || {};

View File

@@ -0,0 +1,7 @@
// Equivalent of jQuery's param
export default function (params) {
Object.keys(params).map((k) => {
return `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`;
}).join('&');
}

View File

@@ -2,20 +2,31 @@
// Can be anywhere inside vue as 'this.$t' or '$t' in templates.
import i18n from 'common/script/i18n';
// Load all english translations
// TODO it's a workaround until proper translation loading works
const context = require.context('common/locales/en', true, /\.(json)$/);
const translations = {};
context.keys().forEach(filename => {
Object.assign(translations, context(filename));
});
i18n.strings = translations;
import moment from 'moment';
export default {
install (Vue) {
install (Vue, {i18nData}) {
if (i18nData) {
// Load i18n strings
i18n.strings = i18nData.strings;
// Load Moment.js locale
const language = i18nData.language;
if (language && i18nData.momentLang && language.momentLangCode) {
// Make moment available under `window` so that the locale can be set
window.moment = moment;
// Execute the script and set the locale
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.type = 'text/javascript';
script.text = i18nData.momentLang;
head.appendChild(script);
moment.locale(language.momentLangCode);
}
}
Vue.prototype.$t = function translateString () {
return i18n.t.apply(null, arguments);
};

View File

@@ -24,7 +24,9 @@ Vue.config.performance = !IS_PRODUCTION;
Vue.config.productionTip = IS_PRODUCTION;
Vue.use(Notifications);
Vue.use(i18n);
// window['habitica-i18n] is injected by the server
Vue.use(i18n, {i18nData: window && window['habitica-i18n']});
Vue.use(StoreModule);
export default new Vue({

View File

@@ -243,24 +243,24 @@ const router = new VueRouter({
path: '/static',
component: ParentPage,
children: [
{ name: 'app', path: 'app', component: AppPage },
{ name: 'clearBrowserData', path: 'clear-browser-data', component: ClearBrowserDataPage },
{ name: 'communitGuidelines', path: 'community-guidelines', component: CommunityGuidelinesPage },
{ name: 'contact', path: 'contact', component: ContactPage },
{ name: 'faq', path: 'faq', component: FAQPage },
{ name: 'features', path: 'features', component: FeaturesPage },
{ name: 'front', path: 'front', component: FrontPage },
{ name: 'groupPlans', path: 'group-plans', component: GroupPlansPage },
{ name: 'maintenance', path: 'maintenance', component: MaintenancePage },
{ name: 'maintenance-info', path: 'maintenance-info', component: MaintenanceInfoPage },
{ name: 'merch', path: 'merch', component: MerchPage },
// { name: 'newStuff', path: 'newStuff', component: NewStuffPage },
{ name: 'overview', path: 'overview', component: OverviewPage },
{ name: 'plans', path: 'plans', component: GroupPlansPage },
{ name: 'pressKit', path: 'press-kit', component: PressKitPage },
{ name: 'app', path: 'app', component: AppPage, meta: {requiresLogin: false}},
{ name: 'clearBrowserData', path: 'clear-browser-data', component: ClearBrowserDataPage, meta: {requiresLogin: false}},
{ name: 'communitGuidelines', path: 'community-guidelines', component: CommunityGuidelinesPage, meta: {requiresLogin: false}},
{ name: 'contact', path: 'contact', component: ContactPage, meta: {requiresLogin: false}},
{ name: 'faq', path: 'faq', component: FAQPage, meta: {requiresLogin: false}},
{ name: 'features', path: 'features', component: FeaturesPage, meta: {requiresLogin: false}},
{ name: 'front', path: 'front', component: FrontPage, meta: {requiresLogin: false}},
{ name: 'groupPlans', path: 'group-plans', component: GroupPlansPage, meta: {requiresLogin: false}},
{ name: 'maintenance', path: 'maintenance', component: MaintenancePage, meta: {requiresLogin: false}},
{ name: 'maintenance-info', path: 'maintenance-info', component: MaintenanceInfoPage, meta: {requiresLogin: false}},
{ name: 'merch', path: 'merch', component: MerchPage, meta: {requiresLogin: false}},
// { name: 'newStuff', path: 'newStuff', component: NewStuffPage, meta: {requiresLogin: false}},
{ name: 'overview', path: 'overview', component: OverviewPage, meta: {requiresLogin: false}},
{ name: 'plans', path: 'plans', component: GroupPlansPage, meta: {requiresLogin: false}},
{ name: 'pressKit', path: 'press-kit', component: PressKitPage, meta: {requiresLogin: false}},
{ name: 'privacy', path: 'privacy', component: PrivacyPage, meta: {requiresLogin: false}},
{ name: 'terms', path: 'terms', component: TermsPage, meta: {requiresLogin: false}},
{ name: 'videos', path: 'videos', component: VideosPage },
{ name: 'videos', path: 'videos', component: VideosPage, meta: {requiresLogin: false}},
],
},
{

View File

@@ -25,6 +25,16 @@ if (AUTH_SETTINGS) {
isUserLoggedIn = true;
}
const i18nData = window && window['habitica-i18n'];
let availableLanguages = [];
let selectedLanguage = {};
if (i18nData) {
availableLanguages = i18nData.availableLanguages;
selectedLanguage = i18nData.language;
}
// Export a function that generates the store and not the store directly
// so that we can regenerate it multiple times for testing, when not testing
// always export the same route
@@ -73,6 +83,10 @@ export default function () {
// NOTE this takes about 10-15ms on a fast computer
content: deepFreeze(content),
constants: deepFreeze({...commonConstants, DAY_MAPPING}),
i18n: deepFreeze({
availableLanguages,
selectedLanguage,
}),
hideHeader: false,
viewingMembers: [],
openedItemRows: [],

View File

@@ -101,6 +101,7 @@ async function saveContentToDisk (language, content) {
api.getContent = {
method: 'GET',
url: '/content',
noLanguage: true,
async handler (req, res) {
let language = 'en';
let proposedLang = req.query.language && req.query.language.toString();

View File

@@ -0,0 +1,46 @@
import {
translations,
momentLangs,
availableLanguages,
} from '../../libs/i18n';
import _ from 'lodash';
const api = {};
function geti18nBrowserScript (language) {
const langCode = language.code;
return `(function () {
if (!window) return;
window['habitica-i18n'] = ${JSON.stringify({
availableLanguages,
language,
strings: translations[langCode],
momentLang: momentLangs[language.momentLangCode],
})};
})()`;
}
/**
* @api {get} /api/v3/i18n/browser-script Returns a JS script to make all the i18n strings available in the browser
* under window.i18n.strings
* @apiDescription Does not require authentication.
* @apiName i18nBrowserScriptGet
* @apiGroup i18n
*/
api.geti18nBrowserScript = {
method: 'GET',
url: '/i18n/browser-script',
async handler (req, res) {
const language = _.find(availableLanguages, {code: req.language});
res.set({
'Content-Type': 'application/javascript',
});
const jsonResString = geti18nBrowserScript(language);
res.status(200).send(jsonResString);
},
};
module.exports = api;

View File

@@ -90,6 +90,7 @@ api.getFrontPage = {
api.getNewClient = {
method: 'GET',
url: '/',
noLanguage: true,
async handler (req, res) {
return res.sendFile('./dist-client/index.html', {root: `${__dirname}/../../../../`});
},

View File

@@ -25,7 +25,8 @@ module.exports.readController = function readController (router, controller) {
let middlewaresToAdd = [getUserLanguage];
if (authMiddlewareIndex !== -1) { // the user will be authenticated, getUserLanguage and cron after authentication
if (action.noLanguage !== true) {
if (authMiddlewareIndex !== -1) { // the user will be authenticated, getUserLanguage after authentication
if (authMiddlewareIndex === middlewares.length - 1) {
middlewares.push(...middlewaresToAdd);
} else {
@@ -34,6 +35,7 @@ module.exports.readController = function readController (router, controller) {
} else { // no auth, getUserLanguage as the first middleware
middlewares.unshift(...middlewaresToAdd);
}
}
method = method.toLowerCase();
let fn = handler ? _wrapAsyncFn(handler) : noop;

View File

@@ -34,7 +34,7 @@ const ENABLE_HTTP_AUTH = nconf.get('SITE_HTTP_AUTH:ENABLED') === 'true';
// const PUBLIC_DIR = path.join(__dirname, '/../../client');
const SESSION_SECRET = nconf.get('SESSION_SECRET');
const TWO_WEEKS = 1000 * 60 * 60 * 24 * 14;
const TEN_YEARS = 1000 * 60 * 60 * 24 * 365 * 10;
module.exports = function attachMiddlewares (app, server) {
app.set('view engine', 'jade');
@@ -68,7 +68,7 @@ module.exports = function attachMiddlewares (app, server) {
secret: SESSION_SECRET,
httpOnly: true, // so cookies are not accessible with browser JS
// TODO what about https only (secure) ?
maxAge: TWO_WEEKS,
maxAge: TEN_YEARS,
}));
// Initialize Passport! Also use passport.session() middleware, to support