├── .babelrc ├── .eslintrc ├── .gitignore ├── README.md ├── circle.yml ├── index.js ├── package.json ├── run-webpack-server.js ├── src ├── client │ ├── app │ │ ├── App.js │ │ ├── app.scss │ │ └── react-logo.png │ ├── auth │ │ ├── LoginApp.js │ │ ├── LoginComponent.js │ │ ├── RegisterApp.js │ │ └── RegisterComponent.js │ ├── counter │ │ └── Counter.js │ ├── createRoutes.js │ ├── devTools.js │ ├── event │ │ ├── CreateEvent.js │ │ └── Event.js │ └── index.js ├── common │ ├── app │ │ └── reducers.js │ ├── auth │ │ ├── actions.js │ │ └── reducers.js │ ├── components │ │ └── RouterHandler.js │ ├── configureStore.js │ ├── counter │ │ ├── actions.js │ │ ├── api.js │ │ ├── counter.spec.js │ │ └── reducers.js │ ├── event │ │ ├── actions.js │ │ └── reducers.js │ ├── fetchComponentData.js │ └── translations.js └── server │ ├── index.js │ └── server.js ├── translate ├── index.js └── translate.js ├── webpack-dev-server.js ├── webpack-isomorphic-tools-configuration.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "plugins": [ 4 | "transform-class-properties", 5 | "transform-object-rest-spread", 6 | "syntax-async-functions", 7 | "transform-async-to-generator", 8 | ["react-intl", { 9 | "messagesDir": "./build/messages/" 10 | }] 11 | ] 12 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser" : "babel-eslint", 3 | "plugins": [ 4 | "import" 5 | ], 6 | "extends" : ["airbnb"], 7 | "rules": { 8 | "react/prop-types": 0, 9 | "react/jsx-no-bind": 0 10 | }, 11 | "globals": { 12 | "require": false, 13 | "it": false, 14 | "describe": false, 15 | }, 16 | "settings": { 17 | "import/ignore": [ 18 | "node_modules", 19 | "\\.json$" 20 | ], 21 | "import/parser": "babel-eslint", 22 | "import/resolve": { 23 | "extensions": [ 24 | ".js", 25 | ".jsx", 26 | ".json" 27 | ] 28 | } 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | build/ 3 | node_modules/ 4 | npm-debug.log 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## General 2 | 3 | This project is a work in progress. It's a project that serves as a minimal starting point for my React apps. 4 | 5 | [![Circle CI](https://circleci.com/gh/jvorcak/universal-react-kit.svg?style=svg)](https://circleci.com/gh/jvorcak/universal-react-kit) 6 | [![Dependency Status](https://david-dm.org/jvorcak/universal-react-kit.svg)](https://david-dm.org/jvorcak/universal-react-kit) 7 | 8 | ## Most important features 9 | - Store management with Redux 10 | - Server rendering 11 | - Tests 12 | - Routing 13 | - i18n support 14 | - Redux dev tools 15 | - Checking for Airbnb JavaScript Style Guide using eslint 16 | - Firebase integration 17 | - ~~E2E tests~~ (coming soon) 18 | - ~~React native support~~ (coming soon) 19 | 20 | ## It currently includes/supports: 21 | - React 22 | - React-router 23 | - Babel 6 24 | - Redux 25 | - Express with server rendering support 26 | - Immutable.JS (mostly used for Redux store) 27 | - Webpack 28 | - Redux devtools 29 | - Hot reload 30 | - Chai 31 | - React Helmet 32 | - Mocha 33 | - React-intl 2 34 | - Eslint 35 | - Firebase 36 | 37 | As written in a headline, it's used to understand how things fit toghether, so there are probably a lot of things that should be done differently. If you see any of those, please feel free to comment. 38 | 39 | # Recommended setup/Prerequisities 40 | 41 | Recommended setup is to use Node 5 with npm 3. It hasn't been properly tested with previous versions. 42 | 43 | # Instalation 44 | 45 | ``` 46 | npm install 47 | npm start 48 | ``` 49 | 50 | # Runtime 51 | 52 | `http://localhost:3000/` main page 53 | `http://localhost:3000/counter` shows a simple counter example with server rendering 54 | `http://localhost:3000/event` this page does nothing usefull :) it just serves to test a different module in the app. 55 | 56 | # Commands 57 | 58 | - Run `npm run tests` to execute tests. 59 | - Run `npm run eslint` to check a quality of a source code. 60 | - Run `npm run translate` to combine translated messages into one flat object. 61 | 62 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 5.3.0 4 | post: 5 | - npm install -g npm@3 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('./src/client'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-universal-example", 3 | "version": "0.0.0", 4 | "description": "An example of a universally-rendered Redux application", 5 | "scripts": { 6 | "start": "concurrent --kill-others \"npm run start-dev-server\" \"npm run start-server\"", 7 | "start-dev-server": "node ./run-webpack-server.js", 8 | "start-server": "node src/server/index.js", 9 | "translate": "node translate", 10 | "test": "mocha --compilers js:babel-core/register ./src/**/*.spec.js", 11 | "eslint": "eslint src" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/rackt/redux.git" 16 | }, 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/rackt/redux/issues" 20 | }, 21 | "homepage": "http://rackt.github.io/redux", 22 | "dependencies": { 23 | "css-loader": "^0.23.1", 24 | "express": "^4.13.3", 25 | "firebase": "^2.4.0", 26 | "firebase-promisified": "0.0.1", 27 | "immutable": "^3.7.6", 28 | "react": "^0.14.0", 29 | "react-dom": "^0.14.0", 30 | "react-helmet": "^2.3.1", 31 | "react-intl": "^2.0.0-beta-2", 32 | "react-pure-render": "^1.0.2", 33 | "react-redux": "^4.0.0", 34 | "react-router": "^2.0.0", 35 | "redux": "^3.0.0", 36 | "redux-form": "^4.1.16", 37 | "redux-logger": "^2.3.1", 38 | "redux-promise-middleware": "^2.3.3", 39 | "rx": "^4.0.7", 40 | "serve-static": "^1.10.0", 41 | "shortid": "^2.2.4", 42 | "url-loader": "^0.5.7", 43 | "webpack-isomorphic-tools": "^2.2.26" 44 | }, 45 | "devDependencies": { 46 | "babel-core": "^6.4.5", 47 | "babel-eslint": "^4.1.8", 48 | "babel-loader": "^6.2.1", 49 | "babel-plugin-react-intl": "^2.1.1", 50 | "babel-plugin-react-transform": "^2.0.0", 51 | "babel-plugin-syntax-async-functions": "^6.3.13", 52 | "babel-plugin-transform-async-to-generator": "^6.4.6", 53 | "babel-plugin-transform-class-properties": "^6.4.0", 54 | "babel-plugin-transform-object-rest-spread": "^6.3.13", 55 | "babel-polyfill": "^6.3.14", 56 | "babel-preset-es2015": "^6.3.13", 57 | "babel-preset-react": "^6.3.13", 58 | "babel-preset-stage-2": "^6.3.13", 59 | "babel-runtime": "^6.3.19", 60 | "chai": "^3.5.0", 61 | "chai-immutable": "^1.5.3", 62 | "concurrently": "^1.0.0", 63 | "css-loader": "^0.23.1", 64 | "eslint": "^1.10.3", 65 | "eslint-config-airbnb": "^5.0.0", 66 | "eslint-plugin-import": "^0.12.1", 67 | "eslint-plugin-react": "^3.16.1", 68 | "glob": "^6.0.4", 69 | "json-loader": "^0.5.4", 70 | "mkdirp": "^0.5.1", 71 | "mocha": "^2.4.5", 72 | "node-sass": "^3.4.2", 73 | "react-hot-loader": "^1.3.0", 74 | "react-transform-hmr": "^1.0.0", 75 | "redux-devtools": "^3.0.1", 76 | "redux-devtools-dock-monitor": "^1.0.1", 77 | "redux-devtools-log-monitor": "^1.0.1", 78 | "sass-loader": "^3.1.2", 79 | "style-loader": "^0.13.0", 80 | "webpack": "^1.11.0", 81 | "webpack-dev-middleware": "^1.2.0", 82 | "webpack-hot-middleware": "^2.2.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /run-webpack-server.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register'); 2 | require('./webpack-dev-server'); 3 | -------------------------------------------------------------------------------- /src/client/app/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import { Map } from 'immutable'; 6 | import DevTools from '../devTools'; 7 | import * as counterActions from '../../common/counter/actions'; 8 | import * as eventActions from '../../common/event/actions'; 9 | import * as authActions from '../../common/auth/actions'; 10 | import RouterHandler from '../../common/components/RouterHandler'; 11 | 12 | import styles from './app.scss'; 13 | 14 | function mapStateToProps(state) { 15 | return { 16 | ...state, 17 | }; 18 | } 19 | 20 | const actions = [ 21 | counterActions, 22 | eventActions, 23 | authActions, 24 | ]; 25 | 26 | function mapDispatchToProps(dispatch) { 27 | const creators = new Map() 28 | .merge(...actions) 29 | .filter(value => typeof value === 'function') 30 | .toObject(); 31 | 32 | return { 33 | actions: bindActionCreators(creators, dispatch), 34 | }; 35 | } 36 | 37 | class App extends Component { 38 | 39 | componentWillMount() { 40 | const { actions: { checkAuth } } = this.props; 41 | checkAuth(); 42 | } 43 | 44 | render() { 45 | // to demonstrate webpack-isomorphic-tools 46 | const imagePath = require('./react-logo.png'); 47 | 48 | const { auth, actions: {logOut} } = this.props; 49 | 50 | const avatarURL = auth.getIn(["loggedIn", "password", "profileImageURL"]); 51 | 52 | return (
53 |
54 |

universal-react-kit

55 | 56 | 64 | 65 |
66 | 67 | 68 |
); 69 | } 70 | 71 | } 72 | 73 | export default connect(mapStateToProps, mapDispatchToProps)(App); 74 | -------------------------------------------------------------------------------- /src/client/app/app.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background: #61DAFB; 3 | } 4 | 5 | :local(.app) { 6 | $color: white; 7 | padding: 10px; 8 | background-color: $color; 9 | } 10 | -------------------------------------------------------------------------------- /src/client/app/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jvorcak/universal-react-kit/c0af3dff1d95716e14b3a2342b7981754ca1caa1/src/client/app/react-logo.png -------------------------------------------------------------------------------- /src/client/auth/LoginApp.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { LoginFormWrapper } from './LoginComponent'; 4 | 5 | class LoginApp extends Component { 6 | 7 | componentWillMount() { 8 | //this.context.router.push('/register'); 9 | } 10 | 11 | render() { 12 | const { auth: loggedIn } = this.props; 13 | 14 | return ( 15 |
16 |

Login

17 | 18 |
19 | ); 20 | } 21 | } 22 | 23 | LoginApp.contextTypes = { 24 | router: React.PropTypes.object.isRequired 25 | }; 26 | 27 | export default LoginApp; 28 | -------------------------------------------------------------------------------- /src/client/auth/LoginComponent.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { reduxForm } from 'redux-form'; 3 | 4 | const fields = ['email', 'password']; 5 | 6 | class LoginForm extends Component { 7 | 8 | handleSubmit(e) { 9 | e.preventDefault(); 10 | const { actions: { login }, fields: { email, password } } = this.props; 11 | login(email.value, password.value); 12 | } 13 | 14 | render() { 15 | const { actions: { 16 | loginWithFacebook, 17 | loginWithTwitter, 18 | }, fields: { email, password } } = this.props; 19 | 20 | return ( 21 |
22 | 23 | 24 |
this.handleSubmit(e)}> 25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | } 43 | 44 | export const LoginFormWrapper = reduxForm({ 45 | form: 'login', 46 | fields, 47 | })(LoginForm); 48 | -------------------------------------------------------------------------------- /src/client/auth/RegisterApp.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import { RegisterFormWrapper } from './RegisterComponent'; 4 | 5 | export default class RegisterApp extends Component { 6 | 7 | render() { 8 | return ( 9 |
10 |

Register

11 | 12 |
13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/client/auth/RegisterComponent.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { reduxForm } from 'redux-form'; 3 | 4 | const fields = ['email', 'password']; 5 | 6 | class RegisterForm extends Component { 7 | 8 | handleSubmit(e) { 9 | e.preventDefault(); 10 | const { actions: { register }, fields: { email, password } } = this.props; 11 | register(email.value, password.value); 12 | } 13 | 14 | render() { 15 | const { fields: { email, password } } = this.props; 16 | 17 | return ( 18 |
19 |
this.handleSubmit(e)}> 20 |
21 | 22 |
23 | 24 |
25 |
26 |
27 | 28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 | ); 36 | } 37 | } 38 | 39 | export const RegisterFormWrapper = reduxForm({ 40 | form: 'registration', 41 | fields, 42 | })(RegisterForm); 43 | -------------------------------------------------------------------------------- /src/client/counter/Counter.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import Helmet from 'react-helmet'; 3 | import { FormattedMessage, defineMessages, injectIntl, intlShape } from 'react-intl'; 4 | 5 | import { incrementAsync as asyncAction } from '../../common/counter/actions'; 6 | 7 | const messages = defineMessages({ 8 | counterTitle: { 9 | id: 'counterTitle', 10 | defaultMessage: 'Counter title', 11 | }, 12 | }); 13 | 14 | class Counter extends Component { 15 | 16 | static propTypes = { 17 | actions: React.PropTypes.shape({ 18 | increment: PropTypes.func.isRequired, 19 | incrementIfOdd: PropTypes.func.isRequired, 20 | incrementAsync: PropTypes.func.isRequired, 21 | decrement: PropTypes.func.isRequired, 22 | }), 23 | counter: React.PropTypes.shape({ 24 | counter: PropTypes.number.isRequired, 25 | }), 26 | intl: intlShape.isRequired, 27 | }; 28 | 29 | render() { 30 | const { 31 | actions: { increment, incrementIfOdd, incrementAsync, decrement }, 32 | counter: { 33 | counter, 34 | }, 35 | } = this.props; 36 | 37 | const { formatMessage } = this.props.intl; 38 | 39 | return ( 40 |

41 | 42 | : {counter} 47 | 51 | {' '} 52 | 53 | {' '} 54 | 55 | {' '} 56 | 57 | {' '} 58 | 59 |

60 | ); 61 | } 62 | } 63 | 64 | const wrappedCounter = injectIntl(Counter); 65 | /** 66 | * Here we define all async actions that needs 67 | * to be completed before this class is rendered 68 | * on a server 69 | */ 70 | wrappedCounter.needs = [ 71 | asyncAction, 72 | ]; 73 | export default wrappedCounter; 74 | -------------------------------------------------------------------------------- /src/client/createRoutes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './app/App'; 3 | import Counter from './counter/Counter'; 4 | import EventsApp from './event/Event'; 5 | import { EventsCreateApp } from './event/CreateEvent'; 6 | import { Route } from 'react-router'; 7 | import RegisterApp from './auth/RegisterApp'; 8 | import LoginApp from './auth/LoginApp'; 9 | 10 | // simple NotFound component 11 | const NotFound = () =>
Not found
; 12 | 13 | export default function createRoutes() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/client/devTools.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Exported from redux-devtools 4 | import { createDevTools } from 'redux-devtools'; 5 | 6 | // Monitors are separate packages, and you can make a custom one 7 | import LogMonitor from 'redux-devtools-log-monitor'; 8 | import DockMonitor from 'redux-devtools-dock-monitor'; 9 | 10 | // createDevTools takes a monitor and produces a DevTools component 11 | const DevTools = createDevTools( 12 | // Monitors are individually adjustable with props. 13 | // Consult their repositories to learn about those props. 14 | // Here, we put LogMonitor inside a DockMonitor. 15 | 16 | 17 | 18 | ); 19 | 20 | export default DevTools; 21 | -------------------------------------------------------------------------------- /src/client/event/CreateEvent.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { reduxForm } from 'redux-form'; 3 | import { injectIntl } from 'react-intl'; 4 | 5 | export const fields = ['name', 'location']; 6 | 7 | class CreateEvent extends Component { 8 | 9 | static propTypes = { 10 | fields: PropTypes.object.isRequired, 11 | resetForm: PropTypes.func.isRequired, 12 | submitting: PropTypes.bool.isRequired, 13 | }; 14 | 15 | saveEventHandler = (e) => { 16 | e.preventDefault(); 17 | const { resetForm, actions: { saveEvent }, fields } = this.props; 18 | saveEvent(fields); 19 | resetForm(); 20 | }; 21 | 22 | render() { 23 | const { fields: { name, location } } = this.props; 24 | 25 | return ( 26 |
27 |

Create event

28 |
this.saveEventHandler(e)}> 29 |
30 | 31 |
32 | 33 |
34 |
35 |
36 | 37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | const CreateEventWrappedIntl = injectIntl(CreateEvent); 49 | const CreateEventWrappedReduxForm = reduxForm({ 50 | form: 'newEvent', 51 | fields, 52 | })(CreateEventWrappedIntl); 53 | 54 | export class EventsCreateApp extends Component { 55 | render() { 56 | return ; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/client/event/Event.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { injectIntl } from 'react-intl'; 3 | import { Link } from 'react-router'; 4 | 5 | import { getAllEvents as asyncAction } from '../../common/event/actions'; 6 | 7 | class EventsApp extends Component { 8 | 9 | static propTypes = { 10 | event: React.PropTypes.shape({ 11 | events: PropTypes.object.isRequired, 12 | }), 13 | actions: React.PropTypes.shape({ 14 | getAllEvents: PropTypes.func.isRequired, 15 | }), 16 | }; 17 | 18 | componentDidMount() { 19 | const { actions: { getAllEvents }, event: { events } } = this.props; 20 | 21 | // if no events are in the component, let's load them 22 | if (!events) { 23 | getAllEvents(); 24 | } 25 | } 26 | 27 | render() { 28 | const { event } = this.props; 29 | 30 | const events = event.get('events').toList().toJS(); 31 | 32 | return ( 33 |
34 |
Create event
35 | Events 36 |
    37 | {events.map(event => 38 |
  • {event.name}
  • 39 | )} 40 |
41 |
42 | ); 43 | } 44 | } 45 | 46 | const wrappedEventsApp = injectIntl(EventsApp); 47 | /** 48 | * Here we define all async actions that needs 49 | * to be completed before this class is rendered 50 | * on a server 51 | */ 52 | wrappedEventsApp.needs = [ 53 | asyncAction, 54 | ]; 55 | export default wrappedEventsApp; 56 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router } from 'react-router'; 3 | import { render } from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import configureStore from '../common/configureStore'; 6 | import createRoutes from './createRoutes'; 7 | import { browserHistory } from 'react-router'; 8 | import { IntlProvider } from 'react-intl'; 9 | 10 | const initialState = window.__INITIAL_STATE__; 11 | const store = configureStore(initialState); 12 | const rootElement = document.getElementById('app'); 13 | const routes = createRoutes(); 14 | 15 | const { locale, messages } = window.__I18N__; 16 | 17 | render( 18 | 19 | 20 | 21 | {routes} 22 | 23 | 24 | , rootElement 25 | ); 26 | -------------------------------------------------------------------------------- /src/common/app/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import counter from '../counter/reducers'; 3 | import event from '../event/reducers'; 4 | import auth from '../auth/reducers'; 5 | import { reducer as formReducer } from 'redux-form'; 6 | 7 | const rootReducer = combineReducers({ 8 | counter, 9 | event, 10 | auth, 11 | form: formReducer, 12 | }); 13 | 14 | export default rootReducer; 15 | -------------------------------------------------------------------------------- /src/common/auth/actions.js: -------------------------------------------------------------------------------- 1 | export const REGISTER = 'REGISTER'; 2 | export const REGISTER_SUCCESS = 'REGISTER_SUCCESS'; 3 | export const REGISTER_ERROR = 'REGISTER_ERROR'; 4 | 5 | export const LOGOUT = 'LOGOUT'; 6 | 7 | export const LOGIN = 'LOGIN'; 8 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 9 | export const LOGIN_ERROR = 'LOGIN_ERROR'; 10 | 11 | export const LOGIN_WITH_FACEBOOK = 'LOGIN_WITH_FACEBOOK'; 12 | export const LOGIN_WITH_FACEBOOK_SUCCESS = 'LOGIN_WITH_FACEBOOK_SUCCESS'; 13 | export const LOGIN_WITH_FACEBOOK_ERROR = 'LOGIN_WITH_FACEBOOK_ERROR'; 14 | 15 | export const LOGIN_WITH_TWITTER = 'LOGIN_WITH_TWITTER'; 16 | export const LOGIN_WITH_TWITTER_SUCCESS = 'LOGIN_WITH_TWITTER_SUCCESS'; 17 | export const LOGIN_WITH_TWITTER_ERROR = 'LOGIN_WITH_TWITTER_ERROR'; 18 | 19 | export function register(email, password) { 20 | return ({ firebase }) => Object({ 21 | type: REGISTER, 22 | payload: { 23 | promise: firebase 24 | .createUser({ 25 | email, 26 | password, 27 | }), 28 | }, 29 | }); 30 | } 31 | 32 | export function checkAuth() { 33 | return ({firebase}) => Object({ 34 | type: LOGIN, 35 | payload: { 36 | promise: new Promise((resolve, reject) => 37 | firebase.onAuth(data => data === null ? reject() : resolve(data)) 38 | ) 39 | } 40 | }); 41 | } 42 | 43 | export function logOut() { 44 | return ({firebase}) => { 45 | firebase.unauth(); 46 | return { 47 | type: LOGOUT 48 | }; 49 | }; 50 | } 51 | 52 | export function login(email, password) { 53 | 54 | // find a suitable name based on the meta info given by each provider 55 | function getName(authData) { 56 | switch (authData.provider) { 57 | case 'password': 58 | return authData.password.email.replace(/@.*/, ''); 59 | case 'twitter': 60 | return authData.twitter.displayName; 61 | case 'facebook': 62 | return authData.facebook.displayName; 63 | } 64 | } 65 | 66 | return ({ firebase }) => Object({ 67 | type: LOGIN, 68 | payload: { 69 | promise: firebase 70 | .authWithPassword({ 71 | email, 72 | password, 73 | }) 74 | .then(authData => { 75 | if (authData) { 76 | // save the user's profile into the database so we can list users, 77 | // use them in Security and Firebase Rules, and show profiles 78 | firebase.child("users").child(authData.uid).set({ 79 | provider: authData.provider, 80 | name: getName(authData) 81 | }); 82 | } 83 | return authData; 84 | }) 85 | , 86 | }, 87 | }); 88 | } 89 | 90 | export function loginWithFacebook() { 91 | return ({ firebase }) => Object({ 92 | type: LOGIN_WITH_TWITTER, 93 | payload: { 94 | promise: firebase 95 | .authWithOAuthPopup('facebook'), 96 | }, 97 | }); 98 | } 99 | 100 | 101 | export function loginWithTwitter() { 102 | return ({ firebase }) => Object({ 103 | type: LOGIN_WITH_TWITTER, 104 | payload: { 105 | promise: firebase 106 | .authWithOAuthPopup('twitter'), 107 | }, 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /src/common/auth/reducers.js: -------------------------------------------------------------------------------- 1 | import Immutable, { Record } from 'immutable'; 2 | import { REGISTER_SUCCESS, LOGIN_SUCCESS, LOGOUT } from './actions'; 3 | 4 | export const InitialState = new Record({ 5 | loggedIn: 0, 6 | }); 7 | const initialState = new InitialState(); 8 | 9 | const revive = ({ loggedIn }) => initialState.merge({ 10 | loggedIn, 11 | }); 12 | 13 | export default function authReducer(state = initialState, action) { 14 | if (!(state instanceof InitialState)) return revive(state); 15 | 16 | switch (action.type) { 17 | case LOGIN_SUCCESS: 18 | return state.set('loggedIn', Immutable.fromJS(action.payload)); 19 | case LOGOUT: 20 | return state.set('loggedIn', null); 21 | default: 22 | return state; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/common/components/RouterHandler.js: -------------------------------------------------------------------------------- 1 | import Component from 'react-pure-render/component'; 2 | import React, { PropTypes } from 'react'; 3 | 4 | // RouterHandler is back since suggested solution via React.cloneElement sucks. 5 | // https://github.com/rackt/react-router/blob/master/UPGRADE_GUIDE.md#routehandler 6 | // This is just syntax sugar for react-router 1.0.0 filtering children in props. 7 | // https://github.com/este/este/issues/535 8 | // Note React does not validate propTypes that are specified via cloneElement. 9 | // It is recommended to make such propTypes optional. 10 | // https://github.com/facebook/react/issues/4494#issuecomment-125068868 11 | export default class RouterHandler extends Component { 12 | 13 | static propTypes = { 14 | children: PropTypes.object, 15 | }; 16 | 17 | render() { 18 | const { children } = this.props; 19 | // No children means nothing to render. 20 | if (!children) return null; 21 | 22 | // That makes nested routes working. 23 | const propsWithoutChildren = { ...this.props }; 24 | delete propsWithoutChildren.children; 25 | 26 | return React.cloneElement(children, propsWithoutChildren); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/common/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import createLogger from 'redux-logger'; 3 | import rootReducer from './app/reducers'; 4 | import DevTools from '../client/devTools'; 5 | import promiseMiddleware from 'redux-promise-middleware'; 6 | import Firebase from 'firebase'; 7 | import shortid from 'shortid'; 8 | 9 | // adds Rx and Promises to the Firebase prototype 10 | 11 | export default function configureStore(initialState) { 12 | 13 | const firebase = new Firebase('https://fiery-inferno-4599.firebaseio.com/'); 14 | 15 | // Inspired by https://github.com/este/este 16 | // TODO Maybe I misunderstood, but it fails if an actions returns undefined. 17 | const injectMiddleware = deps => store => next => action => 18 | next(typeof action === 'function' 19 | ? action({...deps, store}) 20 | : action 21 | ); 22 | const logger = createLogger({ logger: console }); 23 | 24 | const store = compose( 25 | applyMiddleware( 26 | injectMiddleware({ 27 | firebase, 28 | getUid: () => shortid.generate(), 29 | }), 30 | logger, 31 | promiseMiddleware({ 32 | promiseTypeSuffixes: ['START', 'SUCCESS', 'ERROR'], 33 | }) 34 | ), 35 | DevTools.instrument() 36 | )(createStore)(rootReducer, initialState); 37 | 38 | if (module.hot) { 39 | // Enable Webpack hot module replacement for reducers 40 | module.hot.accept('./app/reducers', () => { 41 | const nextRootReducer = require('./app/reducers'); 42 | store.replaceReducer(nextRootReducer); 43 | }); 44 | } 45 | 46 | return store; 47 | } 48 | -------------------------------------------------------------------------------- /src/common/counter/actions.js: -------------------------------------------------------------------------------- 1 | export const SET_COUNTER = 'SET_COUNTER'; 2 | export const INCREMENT_COUNTER = 'INCREMENT_COUNTER'; 3 | export const DECREMENT_COUNTER = 'DECREMENT_COUNTER'; 4 | export const INCREMENT_COUNTER_SUCCESS = 'INCREMENT_COUNTER_SUCCESS'; 5 | 6 | export function set(value) { 7 | return { 8 | type: SET_COUNTER, 9 | payload: value, 10 | }; 11 | } 12 | 13 | export function increment() { 14 | return { 15 | type: INCREMENT_COUNTER, 16 | }; 17 | } 18 | 19 | export function decrement() { 20 | return { 21 | type: DECREMENT_COUNTER, 22 | }; 23 | } 24 | 25 | export function incrementIfOdd() { 26 | return ({store: { getState, dispatch }}) => { 27 | const { counter: { counter } } = getState(); 28 | 29 | // TODO - write a better middleware so that one 30 | // doesn't need to return anything from an action 31 | if (counter % 2 === 0) { 32 | return { 33 | type: '' 34 | }; 35 | } 36 | 37 | return { 38 | type: INCREMENT_COUNTER 39 | } 40 | }; 41 | } 42 | 43 | export function incrementAsync(delay = 1000) { 44 | return { 45 | type: INCREMENT_COUNTER, 46 | payload: { 47 | promise: new Promise(resolve => { 48 | setTimeout(() => { 49 | function getRandomInt(min, max) { 50 | return Math.floor(Math.random() * (max - min)) + min; 51 | } 52 | resolve(getRandomInt(1, 100)); 53 | }, delay); 54 | }), 55 | }, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/common/counter/api.js: -------------------------------------------------------------------------------- 1 | function getRandomInt(min, max) { 2 | return Math.floor(Math.random() * (max - min)) + min; 3 | } 4 | 5 | export const fetchCounter = async () => 6 | new Promise(resolve => { 7 | setTimeout(() => { 8 | resolve(getRandomInt(1, 100)); 9 | }, 500); 10 | }); 11 | -------------------------------------------------------------------------------- /src/common/counter/counter.spec.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import chaiImmutable from 'chai-immutable'; 3 | import * as actions from './actions'; 4 | import counterReducer, { InitialState } from './reducers'; 5 | 6 | const initialState = new InitialState(); 7 | chai.use(chaiImmutable); 8 | 9 | describe('counter reducer', () => { 10 | it('should return the initial state', () => { 11 | expect( 12 | counterReducer(undefined, {}) 13 | ).to.equal(initialState); 14 | }); 15 | 16 | it('should set a counter', () => { 17 | const afterState = initialState.set('counter', 4); 18 | 19 | expect( 20 | counterReducer(initialState, { 21 | type: actions.SET_COUNTER, 22 | payload: 4, 23 | })).to.equal(afterState); 24 | }); 25 | 26 | it('should increment a counter', () => { 27 | const initialStateLocal = initialState.set('counter', 4); 28 | const afterState = initialState.set('counter', 5); 29 | 30 | expect( 31 | counterReducer(initialStateLocal, { 32 | type: actions.INCREMENT_COUNTER, 33 | })).to.equal(afterState); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/common/counter/reducers.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable'; 2 | import { SET_COUNTER, INCREMENT_COUNTER, DECREMENT_COUNTER, 3 | INCREMENT_COUNTER_SUCCESS } from './actions'; 4 | 5 | export const InitialState = new Record({ 6 | counter: 0, 7 | }); 8 | const initialState = new InitialState(); 9 | 10 | const revive = ({ counter }) => initialState.merge({ 11 | counter, 12 | }); 13 | 14 | export default function counterReducer(state = initialState, action) { 15 | if (!(state instanceof InitialState)) return revive(state); 16 | 17 | switch (action.type) { 18 | case SET_COUNTER: 19 | return state.set('counter', action.payload); 20 | case INCREMENT_COUNTER: 21 | return state.set('counter', state.get('counter') + 1); 22 | case DECREMENT_COUNTER: 23 | return state.set('counter', state.get('counter') - 1); 24 | case INCREMENT_COUNTER_SUCCESS: 25 | return state.set('counter', state.get('counter') + action.payload); 26 | default: 27 | return state; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/common/event/actions.js: -------------------------------------------------------------------------------- 1 | export const GET_ALL_EVENTS = 'GET_ALL_EVENTS'; 2 | export const GET_ALL_EVENTS_SUCCESS = 'GET_ALL_EVENTS_SUCCESS'; 3 | export const GET_ALL_EVENTS_ERROR = 'GET_ALL_EVENTS_ERROR'; 4 | 5 | export const SAVE_EVENT = 'SAVE_EVENT'; 6 | export const SAVE_EVENT_SUCCESS = 'SAVE_EVENT_SUCCESS'; 7 | export const SAVE_EVENT_ERROR = 'SAVE_EVENT_ERROR'; 8 | 9 | export function saveEvent({name, location}) { 10 | return ({firebase, getUid}) => { 11 | return { 12 | type: SAVE_EVENT, 13 | payload: { 14 | promise: firebase 15 | .child('events') 16 | .child(getUid()) 17 | .set({ 18 | name: name.value, 19 | location: location.value, 20 | }) 21 | } 22 | } 23 | }; 24 | } 25 | 26 | export function getAllEvents() { 27 | return ({ firebase }) => Object({ 28 | type: GET_ALL_EVENTS, 29 | payload: { 30 | promise: firebase 31 | .child('events') 32 | .once('value') 33 | .then(x => x.val()), 34 | }, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/common/event/reducers.js: -------------------------------------------------------------------------------- 1 | import Immutable, { Record } from 'immutable'; 2 | import { GET_ALL_EVENTS_SUCCESS, SAVE_EVENT } from './actions'; 3 | 4 | export const InitialState = new Record({ 5 | events: {}, 6 | }); 7 | const initialState = new InitialState(); 8 | 9 | const revive = ({ events }) => initialState.merge({ 10 | events, 11 | }); 12 | 13 | export default function counterReducer(state = initialState, action) { 14 | if (!(state instanceof InitialState)) return revive(state); 15 | 16 | switch (action.type) { 17 | case GET_ALL_EVENTS_SUCCESS: 18 | return state.set('events', Immutable.fromJS(action.payload)); 19 | case SAVE_EVENT: 20 | return state.setIn(['events', 'saving'], true); 21 | default: 22 | return state; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/common/fetchComponentData.js: -------------------------------------------------------------------------------- 1 | export default function fetchComponentData(dispatch, components, params) { 2 | const needs = components.reduce((prev, current) => 3 | (current.needs || []) 4 | .concat((current.WrappedComponent ? current.WrappedComponent.needs : []) || []) 5 | .concat(prev) 6 | , []); 7 | 8 | const promises = needs 9 | .map(need => dispatch(need(params))) 10 | .map(action => action.payload.promise); 11 | return Promise.all(promises); 12 | } 13 | -------------------------------------------------------------------------------- /src/common/translations.js: -------------------------------------------------------------------------------- 1 | import { sync as globSync } from 'glob'; 2 | import * as path from 'path'; 3 | import { readFileSync } from 'fs'; 4 | 5 | const translations = globSync('./build/lang/*.json') 6 | .map((filename) => [ 7 | path.basename(filename, '.json'), 8 | readFileSync(filename, 'utf8'), 9 | ]) 10 | .map(([locale, file]) => [locale, JSON.parse(file)]) 11 | .reduce((collection, [locale, messages]) => { 12 | const retCollection = {}; 13 | retCollection[locale] = messages; 14 | return retCollection; 15 | }, {}); 16 | 17 | 18 | export default translations; 19 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register'); 2 | 3 | const WebpackIsomorphicTools = require('webpack-isomorphic-tools'); 4 | 5 | // this must be equal to your Webpack configuration "context" parameter 6 | const projectBasePath = require('path').resolve(__dirname, '../..'); 7 | 8 | // this global variable will be used later in express middleware 9 | global.webpack_isomorphic_tools = new WebpackIsomorphicTools( 10 | require('../../webpack-isomorphic-tools-configuration')) 11 | // enter development mode if needed 12 | // (you may also prefer to use a Webpack DefinePlugin variable) 13 | // .development(process.env.NODE_ENV === 'development') 14 | .development() 15 | // initializes a server-side instance of webpack-isomorphic-tools 16 | // (the first parameter is the base path for your project 17 | // and is equal to the "context" parameter of you Webpack configuration) 18 | // (if you prefer Promises over callbacks 19 | // you can omit the callback parameter 20 | // and then it will return a Promise instead) 21 | .server(projectBasePath) 22 | .then(() => { 23 | require('./server'); 24 | }) 25 | .catch(e => console.log(e)); 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | import Express from 'express'; 2 | import React from 'react'; 3 | import { renderToString } from 'react-dom/server'; 4 | import { Provider } from 'react-redux'; 5 | import { IntlProvider } from 'react-intl'; 6 | import { match, RouterContext } from 'react-router'; 7 | import Helmet from 'react-helmet'; 8 | 9 | import configureStore from '../common/configureStore'; 10 | import createRoutes from '../client/createRoutes'; 11 | import translations from '../common/translations'; 12 | 13 | import fetchComponentData from '../common/fetchComponentData'; 14 | 15 | const app = new Express(); 16 | const port = 3000; 17 | const routes = createRoutes(); 18 | 19 | function renderFullPage(html, initialState, head, locale, messages) { 20 | 21 | return ` 22 | 23 | 24 | 25 | ${head.title.toString()} 26 | 27 | 28 |
${html}
29 | 33 | 34 | 35 | 36 | `; 37 | } 38 | 39 | function handleRender(req, res) { 40 | return match({ routes, location: req.url }, (error, redirectLocation, renderProps) => { 41 | if (error) { 42 | res.status(500).end(error.message); 43 | } else if (redirectLocation) { 44 | res.redirect(302, redirectLocation.pathname + redirectLocation.search); 45 | } else if (renderProps) { 46 | const locale = req.query.locale || 'en-US'; 47 | const messages = translations[locale]; 48 | const store = configureStore(); 49 | 50 | fetchComponentData(store.dispatch, renderProps.components, renderProps.params) 51 | .then(() => { 52 | const html = renderToString( 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | 60 | const head = Helmet.rewind(); 61 | 62 | // Grab the initial state from our Redux store 63 | const finalState = store.getState(); 64 | 65 | // Send the rendered page back to the client 66 | res.end(renderFullPage(html, finalState, head, locale, messages)); 67 | }); 68 | } else { 69 | res.status(404).send('Not found.'); 70 | } 71 | }); 72 | } 73 | 74 | app.use(handleRender); 75 | app.listen(port, (error) => { 76 | /* eslint-disable no-console */ 77 | if (error) { 78 | console.error(error); 79 | } else { 80 | console.info(`Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`); 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /translate/index.js: -------------------------------------------------------------------------------- 1 | require("babel-core/register"); 2 | require("./translate"); -------------------------------------------------------------------------------- /translate/translate.js: -------------------------------------------------------------------------------- 1 | require("babel-core/register"); 2 | import * as fs from 'fs'; 3 | import {sync as globSync} from 'glob'; 4 | import {sync as mkdirpSync} from 'mkdirp'; 5 | 6 | const MESSAGES_PATTERN = './build/messages/**/*.json'; 7 | const LANG_DIR = './build/lang/'; 8 | 9 | // Aggregates the default messages that were extracted from the example app's 10 | // React components via the React Intl Babel plugin. An error will be thrown if 11 | // there are messages in different components that use the same `id`. The result 12 | // is a flat collection of `id: message` pairs for the app's default locale. 13 | let defaultMessages = globSync(MESSAGES_PATTERN) 14 | .map((filename) => fs.readFileSync(filename, 'utf8')) 15 | .map((file) => JSON.parse(file)) 16 | .reduce((collection, descriptors) => { 17 | descriptors.forEach(({id, defaultMessage}) => { 18 | if (collection.hasOwnProperty(id)) { 19 | throw new Error(`Duplicate message id: ${id}`); 20 | } 21 | 22 | collection[id] = defaultMessage; 23 | }); 24 | 25 | return collection; 26 | }, {}); 27 | 28 | 29 | mkdirpSync(LANG_DIR); 30 | fs.writeFileSync(LANG_DIR + 'en-US.json', JSON.stringify(defaultMessages, null, 2)); 31 | -------------------------------------------------------------------------------- /webpack-dev-server.js: -------------------------------------------------------------------------------- 1 | import Express from 'express'; 2 | import webpack from 'webpack'; 3 | import webpackDevMiddleware from 'webpack-dev-middleware'; 4 | import webpackHotMiddleware from 'webpack-hot-middleware'; 5 | import webpackConfig from './webpack.config'; 6 | 7 | const app = new Express(); 8 | const port = 3001; 9 | 10 | // Use this middleware to set up hot module reloading via webpack. 11 | const compiler = webpack(webpackConfig); 12 | 13 | app.use(webpackDevMiddleware(compiler, 14 | { 15 | noInfo: true, 16 | publicPath: webpackConfig.output.publicPath, 17 | } 18 | )); 19 | app.use(webpackHotMiddleware(compiler)); 20 | 21 | app.listen(port, (error) => { 22 | /* eslint-disable no-console */ 23 | if (error) { 24 | console.error(error); 25 | } else { 26 | console.info(`Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`); 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /webpack-isomorphic-tools-configuration.js: -------------------------------------------------------------------------------- 1 | import plugin from 'webpack-isomorphic-tools/plugin'; 2 | 3 | module.exports = { 4 | assets: { 5 | images: { 6 | extensions: ['jpeg', 'jpg', 'png', 'gif'], 7 | parser: plugin.url_loader_parser 8 | }, 9 | fonts: { 10 | extensions: ['woff', 'woff2', 'ttf', 'eot'], 11 | parser: plugin.url_loader_parser 12 | }, 13 | svg: { 14 | extension: 'svg', 15 | parser: plugin.url_loader_parser 16 | }, 17 | styles: { 18 | extensions: ['css', 'sass', 'scss'], 19 | filter(module, regex, options, log) { 20 | return options.development 21 | ? plugin.style_loader_filter(module, regex, options, log) 22 | : regex.test(module.name); 23 | }, 24 | path(module, options, log) { 25 | return options.development 26 | ? plugin.style_loader_path_extractor(module, options, log) 27 | : module.name; 28 | }, 29 | parser(module, options, log) { 30 | return options.development 31 | ? plugin.css_modules_loader_parser(module, options, log) 32 | : module.source; 33 | } 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill'); 2 | var path = require('path'); 3 | var webpack = require('webpack'); 4 | var Webpack_isomorphic_tools_plugin = require('webpack-isomorphic-tools/plugin'); 5 | 6 | var webpack_isomorphic_tools_plugin = 7 | // webpack-isomorphic-tools settings reside in a separate .js file 8 | // (because they will be used in the web server code too). 9 | new Webpack_isomorphic_tools_plugin(require('./webpack-isomorphic-tools-configuration')) 10 | // also enter development mode since it's a development webpack configuration 11 | // (see below for explanation) 12 | .development(); 13 | 14 | module.exports = { 15 | devtool: 'inline-source-map', 16 | entry: [ 17 | 'webpack-hot-middleware/client', 18 | './src/client/index.js' 19 | ], 20 | output: { 21 | path: path.join(__dirname, 'dist'), 22 | filename: 'bundle.js', 23 | publicPath: '/static/' 24 | }, 25 | module: { 26 | loaders: [ 27 | { 28 | test: /\.jsx?$/, 29 | loaders: ['react-hot', 'babel'], 30 | exclude: /node_modules/, 31 | include: __dirname 32 | }, 33 | { 34 | test: /\.json$/, 35 | loader: 'json', 36 | exclude: /node_modules/, 37 | include: __dirname 38 | }, 39 | { 40 | test: webpack_isomorphic_tools_plugin.regular_expression('images'), 41 | loader: 'url-loader?limit=10240', // any image below or equal to 10K will be converted to inline base64 instead 42 | }, 43 | { 44 | test: /\.scss$/, 45 | loader: 'style!css?localIdentName=[name]__[local]___[hash:base64:5]!sass', 46 | exclude: /node_modules/, 47 | include: __dirname, 48 | }, 49 | ] 50 | }, 51 | plugins: [ 52 | webpack_isomorphic_tools_plugin, 53 | new webpack.NoErrorsPlugin(), 54 | new webpack.optimize.OccurenceOrderPlugin(), 55 | new webpack.HotModuleReplacementPlugin(), 56 | ], 57 | }; 58 | 59 | --------------------------------------------------------------------------------