diff --git a/package.json b/package.json index b18438df8..37f271d6a 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,15 @@ "express": "4.13.4", "express-graphql": "0.5.1", "express-jwt": "3.3.0", + "express-request-language": "^1.1.4", "fastclick": "1.0.6", - "fbjs": "0.8.1", + "fbjs": "0.8.0", "front-matter": "2.0.7", "graphiql": "0.7.0", "graphql": "0.5.0", - "history": "2.1.0", + "history": "2.0.2", + "intl": "^1.1.0", + "intl-locales-supported": "^1.0.0", "isomorphic-style-loader": "1.0.0", "jade": "1.11.0", "jsonwebtoken": "5.7.0", @@ -33,6 +36,10 @@ "pretty-error": "2.0.0", "react": "15.0.1", "react-dom": "15.0.1", + "react-intl": "^2.1.0", + "react-redux": "^4.4.5", + "redux": "^3.4.0", + "redux-thunk": "^2.0.1", "sequelize": "^3.21.0", "source-map-support": "0.4.0", "sqlite3": "^3.1.3", @@ -42,10 +49,11 @@ "devDependencies": { "assets-webpack-plugin": "^3.4.0", "autoprefixer": "^6.3.6", - "babel-cli": "^6.7.7", - "babel-core": "^6.7.7", - "babel-eslint": "^6.0.3", + "babel-cli": "^6.7.5", + "babel-core": "^6.7.6", + "babel-eslint": "^6.0.2", "babel-loader": "^6.2.4", + "babel-plugin-react-intl": "^2.1.2", "babel-plugin-react-transform": "^2.0.2", "babel-plugin-rewire": "^1.0.0-rc-2", "babel-plugin-transform-react-constant-elements": "^6.5.0", @@ -58,7 +66,7 @@ "babel-preset-stage-0": "^6.5.0", "babel-register": "^6.7.2", "babel-template": "^6.7.0", - "babel-types": "^6.7.7", + "babel-types": "^6.7.2", "browser-sync": "^2.12.3", "chai": "^3.5.0", "css-loader": "^0.23.1", @@ -90,9 +98,10 @@ "react-transform-catch-errors": "^1.0.2", "react-transform-hmr": "^1.0.4", "redbox-react": "^1.2.3", + "redux-logger": "^2.6.1", "sinon": "^2.0.0-pre", - "stylelint": "^6.0.3", - "stylelint-config-standard": "^6.0.0", + "stylelint": "^5.4.0", + "stylelint-config-standard": "^5.0.0", "url-loader": "^0.5.7", "webpack": "^1.13.0", "webpack-hot-middleware": "^2.10.0", @@ -153,6 +162,7 @@ "test:watch": "mocha src/**/*.test.js --require test/setup.js --compilers js:babel-register --reporter min --watch", "clean": "babel-node tools/run clean", "copy": "babel-node tools/run copy", + "extractMessages": "babel-node tools/run extractMessages", "bundle": "babel-node tools/run bundle", "build": "babel-node tools/run build", "deploy": "babel-node tools/run deploy", diff --git a/src/actions/README.md b/src/actions/README.md new file mode 100644 index 000000000..be6bb8f18 --- /dev/null +++ b/src/actions/README.md @@ -0,0 +1,3 @@ +# Action creators + +Action Creators should go there diff --git a/src/actions/intl.js b/src/actions/intl.js new file mode 100644 index 000000000..e9e5d3632 --- /dev/null +++ b/src/actions/intl.js @@ -0,0 +1,55 @@ +import fetch from '../core/fetch'; +import { + SET_LOCALE_START, + SET_LOCALE_SUCCESS, + SET_LOCALE_ERROR, +} from '../constants'; + +export function setLocale({ locale }) { + return async (dispatch) => { + dispatch({ + type: SET_LOCALE_START, + payload: { + locale, + }, + }); + + try { + const resp = await fetch('/graphql', { + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: `query{intl(locale:${JSON.stringify(locale)}){id,message}}`, + }), + credentials: 'include', + }); + if (resp.status !== 200) throw new Error(resp.statusText); + const { data } = await resp.json(); + const messages = data.intl.reduce((msgs, msg) => { + msgs[msg.id] = msg.message; // eslint-disable-line no-param-reassign + return msgs; + }, {}); + dispatch({ + type: SET_LOCALE_SUCCESS, + payload: { + locale, + messages, + }, + }); + } catch (error) { + dispatch({ + type: SET_LOCALE_ERROR, + payload: { + locale, + error, + }, + }); + return false; + } + + return true; + }; +} diff --git a/src/actions/runtime.js b/src/actions/runtime.js new file mode 100644 index 000000000..353aba1a6 --- /dev/null +++ b/src/actions/runtime.js @@ -0,0 +1,11 @@ +import { SET_RUNTIME_VARIABLE } from '../constants'; + +export function setRuntimeVariable({ name, value }) { + return { + type: SET_RUNTIME_VARIABLE, + payload: { + name, + value, + }, + }; +} diff --git a/src/client.js b/src/client.js index 4f9de5b16..87021c979 100644 --- a/src/client.js +++ b/src/client.js @@ -8,14 +8,25 @@ */ import 'babel-polyfill'; +import React from 'react'; import ReactDOM from 'react-dom'; import FastClick from 'fastclick'; import { match } from 'universal-router'; import routes from './routes'; import history from './core/history'; +import configureStore from './store/configureStore'; import { addEventListener, removeEventListener } from './core/DOMUtils'; +import Provide from './components/Provide'; + +import { addLocaleData } from 'react-intl'; + +import en from 'react-intl/locale-data/en'; +import cs from 'react-intl/locale-data/cs'; + +[en, cs].forEach(addLocaleData); const context = { + store: null, insertCss: styles => styles._insertCss(), setTitle: value => (document.title = value), setMeta: (name, content) => { @@ -60,11 +71,20 @@ let renderComplete = (state, callback) => { }; }; -function render(container, state, component) { +function render(container, state, config, component) { return new Promise((resolve, reject) => { + if (process.env.NODE_ENV === 'development') { + console.log(// eslint-disable-line no-console + 'React rendering. State:', + config.store.getState() + ); + } + try { ReactDOM.render( - component, + + {component} + , container, renderComplete.bind(undefined, state, resolve) ); @@ -77,10 +97,18 @@ function render(container, state, component) { function run() { let currentLocation = null; const container = document.getElementById('app'); + const initialState = JSON.parse( + document. + getElementById('source'). + getAttribute('data-initial-state') + ); // Make taps on links and buttons work fast on mobiles FastClick.attach(document.body); + const store = configureStore(initialState); + context.store = store; + // Re-render the app when window.location changes const removeHistoryListener = history.listen(location => { currentLocation = location; @@ -89,7 +117,7 @@ function run() { query: location.query, state: location.state, context, - render: render.bind(undefined, container, location.state), + render: render.bind(undefined, container, location.state, { store }), }).catch(err => console.error(err)); // eslint-disable-line no-console }); diff --git a/src/components/App/App.js b/src/components/App/App.js index a8483dc3a..2cd2455ae 100644 --- a/src/components/App/App.js +++ b/src/components/App/App.js @@ -21,7 +21,7 @@ class App extends Component { insertCss: PropTypes.func, setTitle: PropTypes.func, setMeta: PropTypes.func, - }), + }).isRequired, children: PropTypes.element.isRequired, error: PropTypes.object, }; @@ -51,14 +51,18 @@ class App extends Component { } render() { - return !this.props.error ? ( + if (this.props.error) { + return this.props.children; + } + + return (
{this.props.children}
- ) : this.props.children; + ); } } diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js index 125268b58..2470d03ac 100644 --- a/src/components/Header/Header.js +++ b/src/components/Header/Header.js @@ -8,10 +8,30 @@ */ import React from 'react'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import withStyles from 'isomorphic-style-loader/lib/withStyles'; import s from './Header.scss'; import Link from '../Link'; import Navigation from '../Navigation'; +import LanguageSwitcher from '../LanguageSwitcher'; + +const messages = defineMessages({ + brand: { + id: 'header.brand', + defaultMessage: 'Your Company Brand', + description: 'Brand name displayed in header', + }, + bannerTitle: { + id: 'header.banner.title', + defaultMessage: 'React', + description: 'Title in page header', + }, + bannerDesc: { + id: 'header.banner.desc', + defaultMessage: 'Complex web apps made easy', + description: 'Description in header', + }, +}); function Header() { return ( @@ -20,15 +40,20 @@ function Header() { React - Your Company + + + +
-

React

-

Complex web apps made easy

+

+ +

+
); } -export default withStyles(s)(Header); +export default injectIntl(withStyles(s)(Header)); diff --git a/src/components/LanguageSwitcher/LanguageSwitcher.js b/src/components/LanguageSwitcher/LanguageSwitcher.js new file mode 100644 index 000000000..cc7e1b43c --- /dev/null +++ b/src/components/LanguageSwitcher/LanguageSwitcher.js @@ -0,0 +1,42 @@ +/* eslint-disable no-shadow */ + +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { setLocale } from '../../actions/intl'; + +function LanguageSwitcher({ currentLocale, availableLocales, setLocale }) { + const isSelected = locale => locale === currentLocale; + return ( +
+ {availableLocales.map(locale => ( + + {isSelected(locale) ? ( + {locale} + ) : ( + { + setLocale({ locale }); + e.preventDefault(); + }} + >{locale} + )} + {' '} + + ))} +
+ ); +} + +LanguageSwitcher.propTypes = { + currentLocale: PropTypes.string.isRequired, + availableLocales: PropTypes.arrayOf(PropTypes.string).isRequired, + setLocale: PropTypes.func.isRequired, +}; + +export default connect(state => ({ + availableLocales: state.runtime.availableLocales, + currentLocale: state.intl.locale, +}), { + setLocale, +})(LanguageSwitcher); diff --git a/src/components/LanguageSwitcher/package.json b/src/components/LanguageSwitcher/package.json new file mode 100644 index 000000000..ef3f495fa --- /dev/null +++ b/src/components/LanguageSwitcher/package.json @@ -0,0 +1,6 @@ +{ + "name": "LanguageSwitcher", + "version": "0.0.0", + "private": true, + "main": "./LanguageSwitcher.js" +} diff --git a/src/components/Navigation/Navigation.js b/src/components/Navigation/Navigation.js index 8e75b17f4..bd75039de 100644 --- a/src/components/Navigation/Navigation.js +++ b/src/components/Navigation/Navigation.js @@ -8,20 +8,59 @@ */ import React, { PropTypes } from 'react'; +import { defineMessages, FormattedMessage } from 'react-intl'; import cx from 'classnames'; import withStyles from 'isomorphic-style-loader/lib/withStyles'; import s from './Navigation.scss'; import Link from '../Link'; +const messages = defineMessages({ + about: { + id: 'navigation.about', + defaultMessage: 'About', + description: 'About link in header', + }, + contact: { + id: 'navigation.contact', + defaultMessage: 'Contact', + description: 'Contact link in header', + }, + login: { + id: 'navigation.login', + defaultMessage: 'Log in', + description: 'Log in link in header', + }, + or: { + id: 'navigation.separator.or', + defaultMessage: 'or', + description: 'Last separator in list, lowercase "or"', + }, + signup: { + id: 'navigation.signup', + defaultMessage: 'Sign up', + description: 'Sign up link in header', + }, +}); + function Navigation({ className }) { return (
- About - Contact + + + + + + | - Log in - or - Sign up + + + + + + + + +
); } diff --git a/src/components/Provide/IntlProvider.js b/src/components/Provide/IntlProvider.js new file mode 100644 index 000000000..12985004b --- /dev/null +++ b/src/components/Provide/IntlProvider.js @@ -0,0 +1,26 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { IntlProvider, intlShape } from 'react-intl'; + +function ProvideIntl({ intl, children }) { + return ( + + {children} + + ); +} + +ProvideIntl.propTypes = { + intl: intlShape, + children: PropTypes.element.isRequired, +}; + +export default connect(state => ({ + runtime: state.runtime, + intl: state.intl, +}))(ProvideIntl); diff --git a/src/components/Provide/Provide.js b/src/components/Provide/Provide.js new file mode 100644 index 000000000..1671505db --- /dev/null +++ b/src/components/Provide/Provide.js @@ -0,0 +1,24 @@ +import React, { PropTypes } from 'react'; +import { Provider } from 'react-redux'; +import IntlProvider from './IntlProvider'; + +function Provide({ store, children }) { + return ( + + + {children} + + + ); +} + +Provide.propTypes = { + store: PropTypes.shape({ + subscribe: PropTypes.func.isRequired, + dispatch: PropTypes.func.isRequired, + getState: PropTypes.func.isRequired, + }).isRequired, + children: PropTypes.element.isRequired, +}; + +export default Provide; diff --git a/src/components/Provide/package.json b/src/components/Provide/package.json new file mode 100644 index 000000000..056dd1dfd --- /dev/null +++ b/src/components/Provide/package.json @@ -0,0 +1,6 @@ +{ + "name": "Provide", + "version": "0.0.0", + "private": true, + "main": "./Provide.js" +} diff --git a/src/config.js b/src/config.js index 7ead528bf..93f2ffb3d 100644 --- a/src/config.js +++ b/src/config.js @@ -13,6 +13,9 @@ export const port = process.env.PORT || 3000; export const host = process.env.WEBSITE_HOSTNAME || `localhost:${port}`; +// default locale is the first one +export const locales = ['en', 'cs']; + export const databaseUrl = process.env.DATABASE_URL || 'sqlite:database.sqlite'; export const analytics = { diff --git a/src/constants/index.js b/src/constants/index.js new file mode 100644 index 000000000..c0a21d497 --- /dev/null +++ b/src/constants/index.js @@ -0,0 +1,4 @@ +export const SET_RUNTIME_VARIABLE = 'SET_RUNTIME_VARIABLE'; +export const SET_LOCALE_START = 'SET_LOCALE_START'; +export const SET_LOCALE_SUCCESS = 'SET_LOCALE_SUCCESS'; +export const SET_LOCALE_ERROR = 'SET_LOCALE_ERROR'; diff --git a/src/data/models/UserClaim.js b/src/data/models/UserClaim.js index 8eb90bbae..132de13ee 100644 --- a/src/data/models/UserClaim.js +++ b/src/data/models/UserClaim.js @@ -17,7 +17,7 @@ const UserClaim = Model.define('UserClaim', { }, value: { - type: DataType.STRING, + type: DataType.INTEGER, }, }); diff --git a/src/data/queries/intl.js b/src/data/queries/intl.js new file mode 100644 index 000000000..baaf976a7 --- /dev/null +++ b/src/data/queries/intl.js @@ -0,0 +1,49 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-2016 Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +import fs from 'fs'; +import { join } from 'path'; +import Promise from 'bluebird'; +import { + GraphQLList as List, + GraphQLString as StringType, + GraphQLNonNull as NonNull, +} from 'graphql'; +import IntlMessageType from '../types/IntlMessageType'; +import { locales } from '../../config'; + +// A folder with messages +const CONTENT_DIR = join(__dirname, './messages'); + +const readFile = Promise.promisify(fs.readFile); + +const intl = { + type: new List(IntlMessageType), + args: { + locale: { type: new NonNull(StringType) }, + }, + async resolve({ request }, { locale }) { + if (!locales.includes(locale)) { + throw new Error(`Locale '${locale}' not supported`); + } + + let localeData; + try { + localeData = await readFile(join(CONTENT_DIR, `${locale}.json`)); + } catch (err) { + if (err.code === 'ENOENT') { + throw new Error(`Locale '${locale}' not found`); + } + } + + return JSON.parse(localeData); + }, +}; + +export default intl; diff --git a/src/data/schema.js b/src/data/schema.js index 43ca42834..ee2298157 100644 --- a/src/data/schema.js +++ b/src/data/schema.js @@ -15,6 +15,7 @@ import { import me from './queries/me'; import content from './queries/content'; import news from './queries/news'; +import intl from './queries/intl'; const schema = new Schema({ query: new ObjectType({ @@ -23,6 +24,7 @@ const schema = new Schema({ me, content, news, + intl, }, }), }); diff --git a/src/data/types/IntlMessageType.js b/src/data/types/IntlMessageType.js new file mode 100644 index 000000000..ce4829137 --- /dev/null +++ b/src/data/types/IntlMessageType.js @@ -0,0 +1,28 @@ +/** + * React Starter Kit (https://www.reactstarterkit.com/) + * + * Copyright © 2014-2016 Kriasoft, LLC. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE.txt file in the root directory of this source tree. + */ + +import { + GraphQLObjectType as ObjectType, + GraphQLString as StringType, + GraphQLNonNull as NonNull, + GraphQLList as List, +} from 'graphql'; + +const IntlMessageType = new ObjectType({ + name: 'IntlMessage', + fields: { + id: { type: new NonNull(StringType) }, + defaultMessage: { type: new NonNull(StringType) }, + message: { type: StringType }, + description: { type: StringType }, + files: { type: new List(StringType) }, + }, +}); + +export default IntlMessageType; diff --git a/src/messages/_default.json b/src/messages/_default.json new file mode 100644 index 000000000..e7b9a3c79 --- /dev/null +++ b/src/messages/_default.json @@ -0,0 +1,66 @@ +[ + { + "id": "header.banner.desc", + "defaultMessage": "Complex web apps made easy", + "message": "", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "header.banner.title", + "defaultMessage": "React", + "message": "", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "header.brand", + "defaultMessage": "Your Company Brand", + "message": "", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "navigation.about", + "defaultMessage": "About", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.contact", + "defaultMessage": "Contact", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.login", + "defaultMessage": "Log in", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.signup", + "defaultMessage": "Sign up", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.separator.or", + "defaultMessage": "or", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + } +] \ No newline at end of file diff --git a/src/messages/cs.json b/src/messages/cs.json new file mode 100644 index 000000000..9c3d32764 --- /dev/null +++ b/src/messages/cs.json @@ -0,0 +1,66 @@ +[ + { + "id": "header.banner.desc", + "defaultMessage": "Complex web apps made easy", + "message": "Komplexní aplikace jednodušše", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "header.banner.title", + "defaultMessage": "React", + "message": "React", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "header.brand", + "defaultMessage": "Your Company Brand", + "message": "Vaše firma", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "navigation.about", + "defaultMessage": "About", + "message": "O nás", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.contact", + "defaultMessage": "Contact", + "message": "Kontakt", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.login", + "defaultMessage": "Log in", + "message": "Přihlásit", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.signup", + "defaultMessage": "Sign up", + "message": "Registrace", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.separator.or", + "defaultMessage": "or", + "message": "nebo", + "files": [ + "src/components/Navigation/Navigation.js" + ] + } +] \ No newline at end of file diff --git a/src/messages/en.json b/src/messages/en.json new file mode 100644 index 000000000..e7b9a3c79 --- /dev/null +++ b/src/messages/en.json @@ -0,0 +1,66 @@ +[ + { + "id": "header.banner.desc", + "defaultMessage": "Complex web apps made easy", + "message": "", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "header.banner.title", + "defaultMessage": "React", + "message": "", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "header.brand", + "defaultMessage": "Your Company Brand", + "message": "", + "files": [ + "src/components/Header/Header.js" + ] + }, + { + "id": "navigation.about", + "defaultMessage": "About", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.contact", + "defaultMessage": "Contact", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.login", + "defaultMessage": "Log in", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.signup", + "defaultMessage": "Sign up", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + }, + { + "id": "navigation.separator.or", + "defaultMessage": "or", + "message": "", + "files": [ + "src/components/Navigation/Navigation.js" + ] + } +] \ No newline at end of file diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 000000000..7d2d9a069 --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux'; +import runtime from './runtime'; +import intl from './intl'; + +export default combineReducers({ + runtime, + intl, +}); diff --git a/src/reducers/intl.js b/src/reducers/intl.js new file mode 100644 index 000000000..6bec9709a --- /dev/null +++ b/src/reducers/intl.js @@ -0,0 +1,47 @@ +import { + SET_LOCALE_START, + SET_LOCALE_SUCCESS, + SET_LOCALE_ERROR, +} from '../constants'; + +export default function intl(state = null, action) { + if (state === null) { + return { + initialNow: Date.now(), + }; + } + + switch (action.type) { + case SET_LOCALE_START: { + const locale = state[action.payload.locale] ? action.payload.locale : state.locale; + return { + ...state, + locale, + newLocale: action.payload.locale, + }; + } + + case SET_LOCALE_SUCCESS: { + return { + ...state, + locale: action.payload.locale, + newLocale: null, + messages: { + ...state.messages, + [action.payload.locale]: action.payload.messages, + }, + }; + } + + case SET_LOCALE_ERROR: { + return { + ...state, + newLocale: null, + }; + } + + default: { + return state; + } + } +} diff --git a/src/reducers/runtime.js b/src/reducers/runtime.js new file mode 100644 index 000000000..f5f2584ea --- /dev/null +++ b/src/reducers/runtime.js @@ -0,0 +1,13 @@ +import { SET_RUNTIME_VARIABLE } from '../constants'; + +export default function runtime(state = {}, action) { + switch (action.type) { + case SET_RUNTIME_VARIABLE: + return { + ...state, + [action.payload.name]: action.payload.value, + }; + default: + return state; + } +} diff --git a/src/routes/home/Home.js b/src/routes/home/Home.js index 7bcecfb2e..f6a5f20a4 100644 --- a/src/routes/home/Home.js +++ b/src/routes/home/Home.js @@ -10,6 +10,7 @@ import React, { PropTypes } from 'react'; import withStyles from 'isomorphic-style-loader/lib/withStyles'; import s from './Home.scss'; +import { FormattedRelative } from 'react-intl'; const title = 'React Starter Kit'; @@ -22,7 +23,13 @@ function Home({ news }, context) {