├── .env ├── .eslintignore ├── api ├── actions │ ├── widget │ │ ├── index.js │ │ ├── update.js │ │ └── load.js │ ├── loadAuth.js │ ├── login.js │ ├── index.js │ ├── loadInfo.js │ └── logout.js ├── utils │ └── url.js ├── __tests__ │ └── api-test.js └── api.js ├── static ├── logo.jpg ├── favicon.ico └── favicon.png ├── src ├── containers │ ├── Home │ │ ├── logo.png │ │ ├── Home.scss │ │ └── Home.js │ ├── About │ │ ├── kitten.jpg │ │ └── About.js │ ├── Chat │ │ ├── Chat.scss │ │ └── Chat.js │ ├── Login │ │ ├── Login.scss │ │ └── Login.js │ ├── NotFound │ │ └── NotFound.js │ ├── DevTools │ │ └── DevTools.js │ ├── index.js │ ├── Uploader │ │ └── Uploader.js │ ├── App │ │ ├── App.scss │ │ └── App.js │ ├── Widgets │ │ ├── Widgets.scss │ │ └── Widgets.js │ ├── LoginSuccess │ │ └── LoginSuccess.js │ └── Survey │ │ └── Survey.js ├── components │ ├── InfoBar │ │ ├── InfoBar.scss │ │ └── InfoBar.js │ ├── SurveyForm │ │ ├── surveyValidation.js │ │ ├── SurveyForm.scss │ │ └── SurveyForm.js │ ├── WidgetForm │ │ ├── widgetValidation.js │ │ └── WidgetForm.js │ ├── index.js │ ├── MiniInfoBar │ │ └── MiniInfoBar.js │ ├── CounterButton │ │ └── CounterButton.js │ ├── GithubButton │ │ └── GithubButton.js │ └── __tests__ │ │ └── InfoBar-test.js ├── theme │ ├── font-awesome.config.less │ ├── bootstrap.overrides.scss │ ├── bootstrap.config.prod.js │ ├── font-awesome.config.prod.js │ ├── font-awesome.config.js │ ├── variables.scss │ └── bootstrap.config.js ├── helpers │ ├── getStatusFromRoutes.js │ ├── getDataDependencies.js │ ├── makeRouteHooksSafe.js │ ├── ApiClient.js │ ├── __tests__ │ │ ├── getStatusFromRoutes-test.js │ │ ├── getDataDependencies-test.js │ │ └── makeRouteHooksSafe-test.js │ └── Html.js ├── redux │ ├── modules │ │ ├── counter.js │ │ ├── reducer.js │ │ ├── info.js │ │ ├── auth.js │ │ └── widgets.js │ ├── middleware │ │ ├── clientMiddleware.js │ │ └── transitionMiddleware.js │ └── create.js ├── config.js ├── routes.js ├── utils │ └── validation.js ├── client.js └── server.js ├── tests.webpack.js ├── .gitignore ├── docs ├── Ducks.md └── InlineStyles.md ├── .editorconfig ├── .travis.yml ├── bin ├── api.js └── server.js ├── server.babel.js ├── .babelrc ├── app.json ├── webpack ├── webpack-dev-server.js ├── prod.config.js ├── webpack-isomorphic-tools.js └── dev.config.js ├── LICENSE ├── .eslintrc ├── karma.conf.js ├── package.json └── README.md /.env: -------------------------------------------------------------------------------- 1 | NODE_PATH=./src 2 | NODE_ENV=production 3 | PORT=80 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack/* 2 | karma.conf.js 3 | tests.webpack.js 4 | -------------------------------------------------------------------------------- /api/actions/widget/index.js: -------------------------------------------------------------------------------- 1 | export update from './update'; 2 | export load from './load'; 3 | -------------------------------------------------------------------------------- /static/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reggi/react-shopify-app/HEAD/static/logo.jpg -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reggi/react-shopify-app/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reggi/react-shopify-app/HEAD/static/favicon.png -------------------------------------------------------------------------------- /src/containers/Home/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reggi/react-shopify-app/HEAD/src/containers/Home/logo.png -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | var context = require.context('./src', true, /-test\.js$/); 2 | context.keys().forEach(context); 3 | -------------------------------------------------------------------------------- /src/containers/About/kitten.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reggi/react-shopify-app/HEAD/src/containers/About/kitten.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | dist/ 4 | *.iml 5 | webpack-assets.json 6 | webpack-stats.json 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /api/actions/loadAuth.js: -------------------------------------------------------------------------------- 1 | export default function loadAuth(req) { 2 | return Promise.resolve(req.session.user || null); 3 | } 4 | -------------------------------------------------------------------------------- /src/components/InfoBar/InfoBar.scss: -------------------------------------------------------------------------------- 1 | .infoBar { 2 | font-variant: italics; 3 | } 4 | 5 | .time { 6 | margin: 0 30px; 7 | } 8 | -------------------------------------------------------------------------------- /docs/Ducks.md: -------------------------------------------------------------------------------- 1 | This document has found [another, hopefully permanent, home](https://github.com/erikras/ducks-modular-redux). 2 | 3 | Quack. 4 | -------------------------------------------------------------------------------- /src/theme/font-awesome.config.less: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration file for font-awesome-webpack 3 | * 4 | */ 5 | 6 | // Example: 7 | // @fa-border-color: #ddd; 8 | -------------------------------------------------------------------------------- /api/actions/login.js: -------------------------------------------------------------------------------- 1 | export default function login(req) { 2 | const user = { 3 | name: req.body.name 4 | }; 5 | req.session.user = user; 6 | return Promise.resolve(user); 7 | } 8 | -------------------------------------------------------------------------------- /api/actions/index.js: -------------------------------------------------------------------------------- 1 | export loadInfo from './loadInfo'; 2 | export loadAuth from './loadAuth'; 3 | export login from './login'; 4 | export logout from './logout'; 5 | export * as widget from './widget/index'; 6 | -------------------------------------------------------------------------------- /src/theme/bootstrap.overrides.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Override Bootstrap styles that you can't modify via variables here. 3 | * 4 | */ 5 | 6 | .navbar-brand { 7 | position: relative; 8 | padding-left: 50px; 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | end_of_line = lf 4 | indent_size = 2 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.md] 9 | max_line_length = 0 10 | trim_trailing_whitespace = false 11 | -------------------------------------------------------------------------------- /api/actions/loadInfo.js: -------------------------------------------------------------------------------- 1 | export default function loadInfo() { 2 | return new Promise((resolve) => { 3 | resolve({ 4 | message: 'This came from the api server', 5 | time: Date.now() 6 | }); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /api/actions/logout.js: -------------------------------------------------------------------------------- 1 | export default function logout(req) { 2 | return new Promise((resolve) => { 3 | req.session.destroy(() => { 4 | req.session = null; 5 | return resolve(null); 6 | }); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/containers/Chat/Chat.scss: -------------------------------------------------------------------------------- 1 | .chat { 2 | input { 3 | padding: 5px 10px; 4 | border-radius: 5px; 5 | border: 1px solid #ccc; 6 | } 7 | form { 8 | margin: 30px 0; 9 | :global(.btn) { 10 | margin-left: 10px; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/containers/Login/Login.scss: -------------------------------------------------------------------------------- 1 | .loginPage { 2 | input { 3 | padding: 5px 10px; 4 | border-radius: 5px; 5 | border: 1px solid #ccc; 6 | } 7 | form { 8 | margin: 30px 0; 9 | :global(.btn) { 10 | margin-left: 10px; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "0.12" 5 | - "4.0" 6 | - "4" 7 | 8 | sudo: false 9 | 10 | before_script: 11 | - export DISPLAY=:99.0 12 | - sh -e /etc/init.d/xvfb start 13 | 14 | script: 15 | - npm run lint 16 | - npm test 17 | - npm run test-node 18 | -------------------------------------------------------------------------------- /src/theme/bootstrap.config.prod.js: -------------------------------------------------------------------------------- 1 | const bootstrapConfig = require('./bootstrap.config.js'); 2 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 3 | bootstrapConfig.styleLoader = ExtractTextPlugin.extract('style-loader', 'css-loader!sass-loader'); 4 | module.exports = bootstrapConfig; 5 | 6 | -------------------------------------------------------------------------------- /src/theme/font-awesome.config.prod.js: -------------------------------------------------------------------------------- 1 | const fontAwesomeConfig = require('./font-awesome.config.js'); 2 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 3 | fontAwesomeConfig.styleLoader = ExtractTextPlugin.extract('style-loader', 'css-loader!less-loader'); 4 | module.exports = fontAwesomeConfig; 5 | 6 | -------------------------------------------------------------------------------- /src/helpers/getStatusFromRoutes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the status code from the last matched route with a status property. 3 | * 4 | * @param matchedRoutes 5 | * @returns {Number|null} 6 | */ 7 | export default (matchedRoutes) => { 8 | return matchedRoutes.reduce((prev, cur) => cur.status || prev, null); 9 | }; 10 | -------------------------------------------------------------------------------- /bin/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | if (process.env.NODE_ENV !== 'production') { 3 | if (!require('piping')({ 4 | hook: true, 5 | ignore: /(\/\.|~$|\.json$)/i 6 | })) { 7 | return; 8 | } 9 | } 10 | require('../server.babel'); // babel registration (runtime transpilation for node) 11 | require('../api/api'); 12 | -------------------------------------------------------------------------------- /src/containers/NotFound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | export default class NotFound extends Component { 4 | render() { 5 | return ( 6 |
7 |

Doh! 404!

8 |

These are not the droids you are looking for!

9 |
10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/theme/font-awesome.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration file for font-awesome-webpack 3 | * 4 | * In order to keep the bundle size low in production, 5 | * disable components you don't use. 6 | * 7 | */ 8 | 9 | module.exports = { 10 | styles: { 11 | mixins: true, 12 | core: true, 13 | icons: true, 14 | larger: true, 15 | path: true, 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /server.babel.js: -------------------------------------------------------------------------------- 1 | // enable runtime transpilation to use ES6/7 in node 2 | 3 | var fs = require('fs'); 4 | 5 | var babelrc = fs.readFileSync('./.babelrc'); 6 | var config; 7 | 8 | try { 9 | config = JSON.parse(babelrc); 10 | } catch (err) { 11 | console.error('==> ERROR: Error parsing your .babelrc.'); 12 | console.error(err); 13 | } 14 | 15 | require('babel-core/register')(config); 16 | -------------------------------------------------------------------------------- /src/containers/DevTools/DevTools.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createDevTools } from 'redux-devtools'; 3 | import LogMonitor from 'redux-devtools-log-monitor'; 4 | import DockMonitor from 'redux-devtools-dock-monitor'; 5 | 6 | export default createDevTools( 7 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/components/SurveyForm/surveyValidation.js: -------------------------------------------------------------------------------- 1 | import memoize from 'lru-memoize'; 2 | import {createValidator, required, maxLength, email} from 'utils/validation'; 3 | 4 | const surveyValidation = createValidator({ 5 | name: [required, maxLength(10)], 6 | email: [required, email], 7 | occupation: maxLength(20) // single rules don't have to be in an array 8 | }); 9 | export default memoize(10)(surveyValidation); 10 | -------------------------------------------------------------------------------- /src/components/WidgetForm/widgetValidation.js: -------------------------------------------------------------------------------- 1 | import {createValidator, required, maxLength, integer, oneOf} from 'utils/validation'; 2 | 3 | export const colors = ['Blue', 'Fuchsia', 'Green', 'Orange', 'Red', 'Taupe']; 4 | 5 | const widgetValidation = createValidator({ 6 | color: [required, oneOf(colors)], 7 | sprocketCount: [required, integer], 8 | owner: [required, maxLength(30)] 9 | }); 10 | export default widgetValidation; 11 | -------------------------------------------------------------------------------- /src/containers/index.js: -------------------------------------------------------------------------------- 1 | export App from './App/App'; 2 | export Chat from './Chat/Chat'; 3 | export Home from './Home/Home'; 4 | export Widgets from './Widgets/Widgets'; 5 | export About from './About/About'; 6 | export Login from './Login/Login'; 7 | export LoginSuccess from './LoginSuccess/LoginSuccess'; 8 | export Survey from './Survey/Survey'; 9 | export NotFound from './NotFound/NotFound'; 10 | export Uploader from './Uploader/Uploader'; 11 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Point of contact for component modules 3 | * 4 | * ie: import { CounterButton, InfoBar } from 'components'; 5 | * 6 | */ 7 | 8 | export CounterButton from './CounterButton/CounterButton'; 9 | export GithubButton from './GithubButton/GithubButton'; 10 | export InfoBar from './InfoBar/InfoBar'; 11 | export MiniInfoBar from './MiniInfoBar/MiniInfoBar'; 12 | export SurveyForm from './SurveyForm/SurveyForm'; 13 | export WidgetForm from './WidgetForm/WidgetForm'; 14 | -------------------------------------------------------------------------------- /src/containers/Uploader/Uploader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Dropzone from 'react-dropzone'; 3 | 4 | export default class Reggi extends Component { 5 | onDrop(files) { 6 | console.log('Received files: ', files); 7 | } 8 | render() { 9 | return ( 10 |
11 | 12 |
Try dropping some files here, or click to select files to upload.
13 |
14 |
15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/redux/modules/counter.js: -------------------------------------------------------------------------------- 1 | const INCREMENT = 'redux-example/counter/INCREMENT'; 2 | 3 | const initialState = { 4 | count: 0 5 | }; 6 | 7 | export default function reducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case INCREMENT: 10 | const {count} = state; 11 | return { 12 | count: count + 1 13 | }; 14 | default: 15 | return state; 16 | } 17 | } 18 | 19 | export function increment() { 20 | return { 21 | type: INCREMENT 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/containers/App/App.scss: -------------------------------------------------------------------------------- 1 | .app { 2 | .brand { 3 | position: absolute; 4 | $size: 40px; 5 | top: 5px; 6 | left: 5px; 7 | display: inline-block; 8 | background: #2d2d2d url('../Home/logo.png') no-repeat center center; 9 | width: $size; 10 | height: $size; 11 | background-size: 80%; 12 | margin: 0 10px 0 0; 13 | border-radius: $size / 2; 14 | } 15 | nav :global(.fa) { 16 | font-size: 2em; 17 | line-height: 20px; 18 | } 19 | } 20 | .appContent { 21 | margin: 50px 0; // for fixed navbar 22 | } 23 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "optional": "runtime", 4 | "loose": "all", 5 | "plugins": [ 6 | "typecheck" 7 | ], 8 | "env": { 9 | "development": { 10 | "plugins": [ 11 | "react-transform" 12 | ], 13 | "extra": { 14 | "react-transform": { 15 | "transforms": [{ 16 | "transform": "react-transform-catch-errors", 17 | "imports": [ 18 | "react", 19 | "redbox-react" 20 | ] 21 | }] 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-universal-hot-example", 3 | "description": "Example of an isomorphic (universal) webapp using react redux and hot reloading", 4 | "repository": "https://github.com/erikras/react-redux-universal-hot-example", 5 | "logo": "http://node-js-sample.herokuapp.com/node.svg", 6 | "keywords": [ 7 | "react", 8 | "isomorphic", 9 | "universal", 10 | "webpack", 11 | "express", 12 | "hot reloading", 13 | "react-hot-reloader", 14 | "redux", 15 | "starter", 16 | "boilerplate", 17 | "babel" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/theme/variables.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Define scss variables here. 3 | * 4 | * Available options for Bootstrap: 5 | * http://getbootstrap.com/customize/ 6 | * 7 | */ 8 | 9 | // Custom Colors 10 | $cyan: #33e0ff; 11 | $humility: #777; 12 | 13 | // Bootstrap Variables 14 | $brand-primary: darken(#428bca, 6.5%); 15 | $brand-secondary: #e25139; 16 | $brand-success: #5cb85c; 17 | $brand-warning: #f0ad4e; 18 | $brand-danger: #d9534f; 19 | $brand-info: #5bc0de; 20 | 21 | $text-color: #333; 22 | 23 | $font-size-base: 14px; 24 | $font-family-sans-serif: "Helvetica Neue", Helvetica, sans-serif; 25 | -------------------------------------------------------------------------------- /src/components/MiniInfoBar/MiniInfoBar.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | 4 | @connect(state => ({ time: state.info.data.time })) 5 | export default class MiniInfoBar extends Component { 6 | static propTypes = { 7 | time: PropTypes.number 8 | } 9 | 10 | render() { 11 | const {time} = this.props; 12 | return ( 13 |
14 | The info bar was last loaded at 15 | {' '} 16 | {time && new Date(time).toString()} 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/redux/modules/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import multireducer from 'multireducer'; 3 | import { routerStateReducer } from 'redux-router'; 4 | 5 | import auth from './auth'; 6 | import counter from './counter'; 7 | import {reducer as form} from 'redux-form'; 8 | import info from './info'; 9 | import widgets from './widgets'; 10 | 11 | export default combineReducers({ 12 | router: routerStateReducer, 13 | auth, 14 | form, 15 | multireducer: multireducer({ 16 | counter1: counter, 17 | counter2: counter, 18 | counter3: counter 19 | }), 20 | info, 21 | widgets 22 | }); 23 | -------------------------------------------------------------------------------- /src/containers/Widgets/Widgets.scss: -------------------------------------------------------------------------------- 1 | .widgets { 2 | .refreshBtn { 3 | margin-left: 20px; 4 | } 5 | .idCol { 6 | width: 5%; 7 | } 8 | .colorCol { 9 | width: 20%; 10 | } 11 | .sprocketsCol { 12 | width: 20%; 13 | text-align: right; 14 | input { 15 | text-align: right; 16 | } 17 | } 18 | .ownerCol { 19 | width: 30%; 20 | } 21 | .buttonCol { 22 | width: 25%; 23 | :global(.btn) { 24 | margin: 0 5px; 25 | } 26 | } 27 | tr.saving { 28 | opacity: 0.8; 29 | :global(.btn) { 30 | &[disabled] { 31 | opacity: 1; 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/helpers/getDataDependencies.js: -------------------------------------------------------------------------------- 1 | const getDataDependency = (component = {}, methodName) => { 2 | return component.WrappedComponent ? 3 | getDataDependency(component.WrappedComponent, methodName) : 4 | component[methodName]; 5 | }; 6 | 7 | export default (components, getState, dispatch, location, params, deferred) => { 8 | const methodName = deferred ? 'fetchDataDeferred' : 'fetchData'; 9 | 10 | return components 11 | .filter((component) => getDataDependency(component, methodName)) // only look at ones with a static fetchData() 12 | .map((component) => getDataDependency(component, methodName)) // pull out fetch data methods 13 | .map(fetchData => 14 | fetchData(getState, dispatch, location, params)); // call fetch data methods and save promises 15 | }; 16 | -------------------------------------------------------------------------------- /src/redux/middleware/clientMiddleware.js: -------------------------------------------------------------------------------- 1 | export default function clientMiddleware(client) { 2 | return ({dispatch, getState}) => { 3 | return next => action => { 4 | if (typeof action === 'function') { 5 | return action(dispatch, getState); 6 | } 7 | 8 | const { promise, types, ...rest } = action; 9 | if (!promise) { 10 | return next(action); 11 | } 12 | 13 | const [REQUEST, SUCCESS, FAILURE] = types; 14 | next({...rest, type: REQUEST}); 15 | return promise(client).then( 16 | (result) => next({...rest, result, type: SUCCESS}), 17 | (error) => next({...rest, error, type: FAILURE}) 18 | ).catch((error)=> { 19 | console.error('MIDDLEWARE ERROR:', error); 20 | next({...rest, error, type: FAILURE}); 21 | }); 22 | }; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /api/actions/widget/update.js: -------------------------------------------------------------------------------- 1 | import load from './load'; 2 | 3 | export default function update(req) { 4 | return new Promise((resolve, reject) => { 5 | // write to database 6 | setTimeout(() => { 7 | if (Math.random() < 0.2) { 8 | reject('Oh no! Widget save fails 20% of the time. Try again.'); 9 | } else { 10 | const widgets = load(req); 11 | const widget = req.body; 12 | if (widget.color === 'Green') { 13 | reject({ 14 | color: 'We do not accept green widgets' // example server-side validation error 15 | }); 16 | } 17 | if (widget.id) { 18 | widgets[widget.id - 1] = widget; // id is 1-based. please don't code like this in production! :-) 19 | } 20 | resolve(widget); 21 | } 22 | }, 2000); // simulate async db write 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/SurveyForm/SurveyForm.scss: -------------------------------------------------------------------------------- 1 | .inputGroup { 2 | position: relative; 3 | } 4 | 5 | .flags { 6 | position: absolute; 7 | right: 20px; 8 | top: 7px; 9 | & > * { 10 | margin: 0 2px; 11 | width: 20px; 12 | height: 20px; 13 | border-radius: 20px; 14 | box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.4); 15 | color: white; 16 | float: right; 17 | text-align: center; 18 | } 19 | .active { 20 | background: linear-gradient(#cc0, #aa0); 21 | color: black; 22 | } 23 | .dirty { 24 | background: linear-gradient(#090, #060); 25 | } 26 | .visited { 27 | background: linear-gradient(#009, #006); 28 | } 29 | .touched { 30 | background: linear-gradient(#099, #066); 31 | } 32 | } 33 | 34 | .radioLabel { 35 | margin: 0 25px 0 5px; 36 | } 37 | .cog { 38 | position: absolute; 39 | left: 0; 40 | top: 10px; 41 | } 42 | -------------------------------------------------------------------------------- /api/actions/widget/load.js: -------------------------------------------------------------------------------- 1 | const initialWidgets = [ 2 | {id: 1, color: 'Red', sprocketCount: 7, owner: 'John'}, 3 | {id: 2, color: 'Taupe', sprocketCount: 1, owner: 'George'}, 4 | {id: 3, color: 'Green', sprocketCount: 8, owner: 'Ringo'}, 5 | {id: 4, color: 'Blue', sprocketCount: 2, owner: 'Paul'} 6 | ]; 7 | 8 | export function getWidgets(req) { 9 | let widgets = req.session.widgets; 10 | if (!widgets) { 11 | widgets = initialWidgets; 12 | req.session.widgets = widgets; 13 | } 14 | return widgets; 15 | } 16 | 17 | export default function load(req) { 18 | return new Promise((resolve, reject) => { 19 | // make async call to database 20 | setTimeout(() => { 21 | if (Math.random() < 0.33) { 22 | reject('Widget load fails 33% of the time. You were unlucky.'); 23 | } else { 24 | resolve(getWidgets(req)); 25 | } 26 | }, 1000); // simulate async load 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /api/utils/url.js: -------------------------------------------------------------------------------- 1 | export function mapUrl(availableActions = {}, url = []) { 2 | 3 | const notFound = {action: null, params: []}; 4 | 5 | // test for empty input 6 | if (url.length === 0 || Object.keys(availableActions).length === 0) { 7 | return notFound; 8 | } 9 | /*eslint-disable */ 10 | const reducer = (next, current) => { 11 | if (next.action && next.action[current]) { 12 | return {action: next.action[current], params: []}; // go deeper 13 | } else { 14 | if (typeof next.action === 'function') { 15 | return {action: next.action, params: next.params.concat(current)}; // params are found 16 | } else { 17 | return notFound; 18 | } 19 | } 20 | }; 21 | /*eslint-enable */ 22 | 23 | const actionAndParams = url.reduce(reducer, {action: availableActions, params: []}); 24 | 25 | return (typeof actionAndParams.action === 'function') ? actionAndParams : notFound; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/CounterButton/CounterButton.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connectMultireducer} from 'multireducer'; 3 | import {increment} from 'redux/modules/counter'; 4 | 5 | @connectMultireducer( 6 | state => ({count: state.count}), 7 | {increment}) 8 | export default class CounterButton extends Component { 9 | static propTypes = { 10 | count: PropTypes.number, 11 | increment: PropTypes.func.isRequired, 12 | className: PropTypes.string 13 | } 14 | 15 | props = { 16 | className: '' 17 | } 18 | 19 | render() { 20 | const {count, increment} = this.props; // eslint-disable-line no-shadow 21 | let {className} = this.props; 22 | className += ' btn btn-default'; 23 | return ( 24 | 27 | ); 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../server.babel'); // babel registration (runtime transpilation for node) 3 | var path = require('path'); 4 | var rootDir = path.resolve(__dirname, '..'); 5 | /** 6 | * Define isomorphic constants. 7 | */ 8 | global.__CLIENT__ = false; 9 | global.__SERVER__ = true; 10 | global.__DISABLE_SSR__ = false; // <----- DISABLES SERVER SIDE RENDERING FOR ERROR DEBUGGING 11 | global.__DEVELOPMENT__ = process.env.NODE_ENV !== 'production'; 12 | 13 | if (__DEVELOPMENT__) { 14 | if (!require('piping')({ 15 | hook: true, 16 | ignore: /(\/\.|~$|\.json|\.scss$)/i 17 | })) { 18 | return; 19 | } 20 | } 21 | 22 | // https://github.com/halt-hammerzeit/webpack-isomorphic-tools 23 | var WebpackIsomorphicTools = require('webpack-isomorphic-tools'); 24 | global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('../webpack/webpack-isomorphic-tools')) 25 | .development(__DEVELOPMENT__) 26 | .server(rootDir, function() { 27 | require('../src/server'); 28 | }); 29 | -------------------------------------------------------------------------------- /webpack/webpack-dev-server.js: -------------------------------------------------------------------------------- 1 | var Express = require('express'); 2 | var webpack = require('webpack'); 3 | 4 | var config = require('../src/config'); 5 | var webpackConfig = require('./dev.config'); 6 | var compiler = webpack(webpackConfig); 7 | 8 | var host = process.env.HOST || 'localhost'; 9 | var port = parseInt(config.port, 10) + 1 || 3001; 10 | var serverOptions = { 11 | contentBase: 'http://' + host + ':' + port, 12 | quiet: true, 13 | noInfo: true, 14 | hot: true, 15 | inline: true, 16 | lazy: false, 17 | publicPath: webpackConfig.output.publicPath, 18 | headers: {'Access-Control-Allow-Origin': '*'}, 19 | stats: {colors: true} 20 | }; 21 | 22 | var app = new Express(); 23 | 24 | app.use(require('webpack-dev-middleware')(compiler, serverOptions)); 25 | app.use(require('webpack-hot-middleware')(compiler)); 26 | 27 | app.listen(port, function onAppListening(err) { 28 | if (err) { 29 | console.error(err); 30 | } else { 31 | console.info('==> 🚧 Webpack development server listening on port %s', port); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/redux/modules/info.js: -------------------------------------------------------------------------------- 1 | const LOAD = 'redux-example/LOAD'; 2 | const LOAD_SUCCESS = 'redux-example/LOAD_SUCCESS'; 3 | const LOAD_FAIL = 'redux-example/LOAD_FAIL'; 4 | 5 | const initialState = { 6 | loaded: false 7 | }; 8 | 9 | export default function info(state = initialState, action = {}) { 10 | switch (action.type) { 11 | case LOAD: 12 | return { 13 | ...state, 14 | loading: true 15 | }; 16 | case LOAD_SUCCESS: 17 | return { 18 | ...state, 19 | loading: false, 20 | loaded: true, 21 | data: action.result 22 | }; 23 | case LOAD_FAIL: 24 | return { 25 | ...state, 26 | loading: false, 27 | loaded: false, 28 | error: action.error 29 | }; 30 | default: 31 | return state; 32 | } 33 | } 34 | 35 | export function isLoaded(globalState) { 36 | return globalState.info && globalState.info.loaded; 37 | } 38 | 39 | export function load() { 40 | return { 41 | types: [LOAD, LOAD_SUCCESS, LOAD_FAIL], 42 | promise: (client) => client.get('/loadInfo') 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/components/InfoBar/InfoBar.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {bindActionCreators} from 'redux'; 3 | import {connect} from 'react-redux'; 4 | import {load} from 'redux/modules/info'; 5 | 6 | @connect( 7 | state => ({info: state.info.data}), 8 | dispatch => bindActionCreators({load}, dispatch)) 9 | export default class InfoBar extends Component { 10 | static propTypes = { 11 | info: PropTypes.object, 12 | load: PropTypes.func.isRequired 13 | } 14 | 15 | render() { 16 | const {info, load} = this.props; // eslint-disable-line no-shadow 17 | const styles = require('./InfoBar.scss'); 18 | return ( 19 |
20 |
21 | This is an info bar 22 | {' '} 23 | {info ? info.message : 'no info!'} 24 | {info && new Date(info.time).toString()} 25 | 26 |
27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/containers/Home/Home.scss: -------------------------------------------------------------------------------- 1 | @import "../../theme/variables.scss"; 2 | 3 | .home { 4 | dd { 5 | margin-bottom: 15px; 6 | } 7 | } 8 | .masthead { 9 | background: #2d2d2d; 10 | padding: 40px 20px; 11 | color: white; 12 | text-align: center; 13 | .logo { 14 | $size: 200px; 15 | margin: auto; 16 | height: $size; 17 | width: $size; 18 | border-radius: $size / 2; 19 | border: 1px solid $cyan; 20 | box-shadow: inset 0 0 10px $cyan; 21 | vertical-align: middle; 22 | p { 23 | line-height: $size; 24 | margin: 0px; 25 | } 26 | img { 27 | width: 75%; 28 | margin: auto; 29 | } 30 | } 31 | h1 { 32 | color: $cyan; 33 | font-size: 4em; 34 | } 35 | h2 { 36 | color: #ddd; 37 | font-size: 2em; 38 | margin: 20px; 39 | } 40 | a { 41 | color: #ddd; 42 | } 43 | p { 44 | margin: 10px; 45 | } 46 | .humility { 47 | color: $humility; 48 | a { 49 | color: $humility; 50 | } 51 | } 52 | .github { 53 | font-size: 1.5em; 54 | } 55 | } 56 | 57 | .counterContainer { 58 | text-align: center; 59 | margin: 20px; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/GithubButton/GithubButton.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | 3 | export default class GithubButton extends Component { 4 | static propTypes = { 5 | user: PropTypes.string.isRequired, 6 | repo: PropTypes.string.isRequired, 7 | type: PropTypes.oneOf(['star', 'watch', 'fork', 'follow']).isRequired, 8 | width: PropTypes.number.isRequired, 9 | height: PropTypes.number.isRequired, 10 | count: PropTypes.bool, 11 | large: PropTypes.bool 12 | } 13 | 14 | render() { 15 | const {user, repo, type, width, height, count, large} = this.props; 16 | let src = `https://ghbtns.com/github-btn.html?user=${user}&repo=${repo}&type=${type}`; 17 | if (count) { 18 | src += '&count=true'; 19 | } 20 | if (large) { 21 | src += '&size=large'; 22 | } 23 | return ( 24 | 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Erik Rasmussen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/helpers/makeRouteHooksSafe.js: -------------------------------------------------------------------------------- 1 | import { createRoutes } from 'react-router/lib/RouteUtils'; 2 | 3 | // Wrap the hooks so they don't fire if they're called before 4 | // the store is initialised. This only happens when doing the first 5 | // client render of a route that has an onEnter hook 6 | function makeHooksSafe(routes, store) { 7 | if (Array.isArray(routes)) { 8 | return routes.map((route) => makeHooksSafe(route, store)); 9 | } 10 | 11 | const onEnter = routes.onEnter; 12 | 13 | if (onEnter) { 14 | routes.onEnter = function safeOnEnter(...args) { 15 | try { 16 | store.getState(); 17 | } catch (err) { 18 | if (onEnter.length === 3) { 19 | args[2](); 20 | } 21 | 22 | // There's no store yet so ignore the hook 23 | return; 24 | } 25 | 26 | onEnter.apply(null, args); 27 | }; 28 | } 29 | 30 | if (routes.childRoutes) { 31 | makeHooksSafe(routes.childRoutes, store); 32 | } 33 | 34 | if (routes.indexRoute) { 35 | makeHooksSafe(routes.indexRoute, store); 36 | } 37 | 38 | return routes; 39 | } 40 | 41 | export default function makeRouteHooksSafe(_getRoutes) { 42 | return (store) => makeHooksSafe(createRoutes(_getRoutes(store)), store); 43 | } 44 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | require('babel-core/polyfill'); 2 | 3 | const environment = { 4 | development: { 5 | isProduction: false 6 | }, 7 | production: { 8 | isProduction: true 9 | } 10 | }[process.env.NODE_ENV || 'development']; 11 | 12 | module.exports = Object.assign({ 13 | port: process.env.PORT, 14 | apiPort: process.env.APIPORT, 15 | app: { 16 | title: 'React Redux Example', 17 | description: 'All the modern best practices in one example.', 18 | meta: { 19 | charSet: 'utf-8', 20 | property: { 21 | 'og:site_name': 'React Redux Example', 22 | 'og:image': 'https://react-redux.herokuapp.com/logo.jpg', 23 | 'og:locale': 'en_US', 24 | 'og:title': 'React Redux Example', 25 | 'og:description': 'All the modern best practices in one example.', 26 | 'twitter:card': 'summary', 27 | 'twitter:site': '@erikras', 28 | 'twitter:creator': '@erikras', 29 | 'twitter:title': 'React Redux Example', 30 | 'twitter:description': 'All the modern best practices in one example.', 31 | 'twitter:image': 'https://react-redux.herokuapp.com/logo.jpg', 32 | 'twitter:image:width': '200', 33 | 'twitter:image:height': '200' 34 | } 35 | } 36 | } 37 | }, environment); 38 | -------------------------------------------------------------------------------- /src/containers/LoginSuccess/LoginSuccess.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import * as authActions from 'redux/modules/auth'; 4 | 5 | @connect( 6 | state => ({user: state.auth.user}), 7 | authActions) 8 | export default 9 | class LoginSuccess extends Component { 10 | static propTypes = { 11 | user: PropTypes.object, 12 | logout: PropTypes.func 13 | } 14 | 15 | render() { 16 | const {user, logout} = this.props; 17 | return (user && 18 |
19 |

Login Success

20 | 21 |
22 |

Hi, {user.name}. You have just successfully logged in, and were forwarded here 23 | by componentWillReceiveProps() in App.js, which is listening to 24 | the auth reducer via redux @connect. How exciting! 25 |

26 | 27 |

28 | The same function will forward you to / should you chose to log out. The choice is yours... 29 |

30 | 31 |
32 | 33 |
34 |
35 |
36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/redux/create.js: -------------------------------------------------------------------------------- 1 | import { createStore as _createStore, applyMiddleware, compose } from 'redux'; 2 | import createMiddleware from './middleware/clientMiddleware'; 3 | import transitionMiddleware from './middleware/transitionMiddleware'; 4 | 5 | export default function createStore(reduxReactRouter, getRoutes, createHistory, client, data) { 6 | const middleware = [createMiddleware(client), transitionMiddleware]; 7 | 8 | let finalCreateStore; 9 | if (__DEVELOPMENT__ && __CLIENT__ && __DEVTOOLS__) { 10 | const { persistState } = require('redux-devtools'); 11 | const DevTools = require('../containers/DevTools/DevTools'); 12 | finalCreateStore = compose( 13 | applyMiddleware(...middleware), 14 | DevTools.instrument(), 15 | persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)) 16 | )(_createStore); 17 | } else { 18 | finalCreateStore = applyMiddleware(...middleware)(_createStore); 19 | } 20 | 21 | finalCreateStore = reduxReactRouter({ getRoutes, createHistory })(finalCreateStore); 22 | 23 | const reducer = require('./modules/reducer'); 24 | const store = finalCreateStore(reducer, data); 25 | 26 | if (__DEVELOPMENT__ && module.hot) { 27 | module.hot.accept('./modules/reducer', () => { 28 | store.replaceReducer(require('./modules/reducer')); 29 | }); 30 | } 31 | 32 | return store; 33 | } 34 | -------------------------------------------------------------------------------- /src/redux/middleware/transitionMiddleware.js: -------------------------------------------------------------------------------- 1 | import {ROUTER_DID_CHANGE} from 'redux-router/lib/constants'; 2 | import getDataDependencies from '../../helpers/getDataDependencies'; 3 | 4 | const locationsAreEqual = (locA, locB) => (locA.pathname === locB.pathname) && (locA.search === locB.search); 5 | 6 | export default ({getState, dispatch}) => next => action => { 7 | if (action.type === ROUTER_DID_CHANGE) { 8 | if (getState().router && locationsAreEqual(action.payload.location, getState().router.location)) { 9 | return next(action); 10 | } 11 | 12 | const {components, location, params} = action.payload; 13 | const promise = new Promise((resolve) => { 14 | 15 | const doTransition = () => { 16 | next(action); 17 | Promise.all(getDataDependencies(components, getState, dispatch, location, params, true)) 18 | .then(resolve, resolve); 19 | }; 20 | 21 | Promise.all(getDataDependencies(components, getState, dispatch, location, params)) 22 | .then(doTransition, doTransition); 23 | }); 24 | 25 | if (__SERVER__) { 26 | // router state is null until ReduxRouter is created so we can use this to store 27 | // our promise to let the server know when it can render 28 | getState().router = promise; 29 | } 30 | 31 | return promise; 32 | } 33 | 34 | return next(action); 35 | }; 36 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "eslint-config-airbnb", 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "react/jsx-uses-react": 2, 9 | "react/jsx-uses-vars": 2, 10 | "react/react-in-jsx-scope": 2, 11 | "react/jsx-quotes": 0, 12 | "import/default": 0, 13 | "import/no-duplicates": 0, 14 | "import/named": 0, 15 | "import/namespace": 0, 16 | "import/no-unresolved": 0, 17 | "import/no-named-as-default": 2, 18 | "jsx-quotes": 2, 19 | // Temporarirly disabled due to a possible bug in babel-eslint (todomvc example) 20 | "block-scoped-var": 0, 21 | // Temporarily disabled for test/* until babel/babel-eslint#33 is resolved 22 | "padded-blocks": 0, 23 | "comma-dangle": 0, // not sure why airbnb turned this on. gross! 24 | "indent": [2, 2, {"SwitchCase": 1}], 25 | "no-console": 0, 26 | "no-alert": 0 27 | }, 28 | "plugins": [ 29 | "react", "import" 30 | ], 31 | "settings": { 32 | "import/parser": "babel-eslint", 33 | "import/resolve": { 34 | moduleDirectory: ["node_modules", "src"] 35 | } 36 | }, 37 | "globals": { 38 | "__DEVELOPMENT__": true, 39 | "__CLIENT__": true, 40 | "__SERVER__": true, 41 | "__DISABLE_SSR__": true, 42 | "__DEVTOOLS__": true, 43 | "socket": true, 44 | "webpackIsomorphicTools": true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/helpers/ApiClient.js: -------------------------------------------------------------------------------- 1 | import superagent from 'superagent'; 2 | import config from '../config'; 3 | 4 | const methods = ['get', 'post', 'put', 'patch', 'del']; 5 | 6 | function formatUrl(path) { 7 | const adjustedPath = path[0] !== '/' ? '/' + path : path; 8 | if (__SERVER__) { 9 | // Prepend host and port of the API server to the path. 10 | return 'http://localhost:' + config.apiPort + adjustedPath; 11 | } 12 | // Prepend `/api` to relative URL, to proxy to API server. 13 | return '/api' + adjustedPath; 14 | } 15 | 16 | /* 17 | * This silly underscore is here to avoid a mysterious "ReferenceError: ApiClient is not defined" error. 18 | * See Issue #14. https://github.com/erikras/react-redux-universal-hot-example/issues/14 19 | * 20 | * Remove it at your own risk. 21 | */ 22 | class _ApiClient { 23 | constructor(req) { 24 | methods.forEach((method) => 25 | this[method] = (path, { params, data } = {}) => new Promise((resolve, reject) => { 26 | const request = superagent[method](formatUrl(path)); 27 | 28 | if (params) { 29 | request.query(params); 30 | } 31 | 32 | if (__SERVER__ && req.get('cookie')) { 33 | request.set('cookie', req.get('cookie')); 34 | } 35 | 36 | if (data) { 37 | request.send(data); 38 | } 39 | 40 | request.end((err, { body } = {}) => err ? reject(body || err) : resolve(body)); 41 | })); 42 | } 43 | } 44 | 45 | const ApiClient = _ApiClient; 46 | 47 | export default ApiClient; 48 | -------------------------------------------------------------------------------- /src/helpers/__tests__/getStatusFromRoutes-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import getStatusFromRoutes from '../getStatusFromRoutes'; 3 | 4 | describe('getStatusFromRoutes', () => { 5 | 6 | it('should return null when no routes have status code', () => { 7 | const status = getStatusFromRoutes([ 8 | {}, {} 9 | ]); 10 | 11 | expect(status).to.equal(null); 12 | }); 13 | 14 | it('should return the only status code', () => { 15 | const status = getStatusFromRoutes([ 16 | {status: 404} 17 | ]); 18 | 19 | expect(status).to.equal(404); 20 | }); 21 | 22 | it('should return the only status code when other routes have none', () => { 23 | const status = getStatusFromRoutes([ 24 | {status: 404}, {}, {} 25 | ]); 26 | 27 | expect(status).to.equal(404); 28 | }); 29 | 30 | it('should return the last status code when later routes have none', () => { 31 | const status = getStatusFromRoutes([ 32 | {status: 200}, {status: 404}, {} 33 | ]); 34 | 35 | expect(status).to.equal(404); 36 | }); 37 | 38 | it('should return the last status code when previous routes have one', () => { 39 | const status = getStatusFromRoutes([ 40 | {status: 200}, {}, {status: 404} 41 | ]); 42 | 43 | expect(status).to.equal(404); 44 | }); 45 | 46 | it('should return the last status code', () => { 47 | const status = getStatusFromRoutes([ 48 | {}, {}, {status: 404} 49 | ]); 50 | 51 | expect(status).to.equal(404); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {IndexRoute, Route} from 'react-router'; 3 | import { isLoaded as isAuthLoaded, load as loadAuth } from 'redux/modules/auth'; 4 | import { 5 | App, 6 | Chat, 7 | Home, 8 | Widgets, 9 | About, 10 | Login, 11 | LoginSuccess, 12 | Survey, 13 | NotFound, 14 | Uploader 15 | } from 'containers'; 16 | 17 | export default (store) => { 18 | const requireLogin = (nextState, replaceState, cb) => { 19 | function checkAuth() { 20 | const { auth: { user }} = store.getState(); 21 | if (!user) { 22 | // oops, not logged in, so can't be here! 23 | replaceState(null, '/'); 24 | } 25 | cb(); 26 | } 27 | 28 | if (!isAuthLoaded(store.getState())) { 29 | store.dispatch(loadAuth()).then(checkAuth); 30 | } else { 31 | checkAuth(); 32 | } 33 | }; 34 | 35 | /** 36 | * Please keep routes in alphabetical order 37 | */ 38 | return ( 39 | 40 | { /* Home (main) route */ } 41 | 42 | 43 | { /* Routes requiring login */ } 44 | 45 | 46 | 47 | 48 | 49 | { /* Routes */ } 50 | 51 | 52 | 53 | 54 | 55 | 56 | { /* Catch all route */ } 57 | 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/containers/Login/Login.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import DocumentMeta from 'react-document-meta'; 4 | import * as authActions from 'redux/modules/auth'; 5 | 6 | @connect( 7 | state => ({user: state.auth.user}), 8 | authActions) 9 | export default class Login extends Component { 10 | static propTypes = { 11 | user: PropTypes.object, 12 | login: PropTypes.func, 13 | logout: PropTypes.func 14 | } 15 | 16 | handleSubmit(event) { 17 | event.preventDefault(); 18 | const input = this.refs.username; 19 | this.props.login(input.value); 20 | input.value = ''; 21 | } 22 | 23 | render() { 24 | const {user, logout} = this.props; 25 | const styles = require('./Login.scss'); 26 | return ( 27 |
28 | 29 |

Login

30 | {!user && 31 |
32 |
33 | 34 | 36 | 37 |

This will "log you in" as this user, storing the username in the session of the API server.

38 |
39 | } 40 | {user && 41 |
42 |

You are currently logged in as {user.name}.

43 | 44 |
45 | 46 |
47 |
48 | } 49 |
50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/containers/App/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import DocumentMeta from 'react-document-meta'; 4 | import { isLoaded as isInfoLoaded, load as loadInfo } from 'redux/modules/info'; 5 | import { isLoaded as isAuthLoaded, load as loadAuth, logout } from 'redux/modules/auth'; 6 | import { pushState } from 'redux-router'; 7 | import config from '../../config'; 8 | 9 | @connect( 10 | state => ({user: state.auth.user}), 11 | {logout, pushState}) 12 | export default class App extends Component { 13 | static propTypes = { 14 | children: PropTypes.object.isRequired, 15 | user: PropTypes.object, 16 | logout: PropTypes.func.isRequired, 17 | pushState: PropTypes.func.isRequired 18 | }; 19 | 20 | static contextTypes = { 21 | store: PropTypes.object.isRequired 22 | }; 23 | 24 | componentWillReceiveProps(nextProps) { 25 | if (!this.props.user && nextProps.user) { 26 | // login 27 | this.props.pushState(null, '/loginSuccess'); 28 | } else if (this.props.user && !nextProps.user) { 29 | // logout 30 | this.props.pushState(null, '/'); 31 | } 32 | } 33 | 34 | static fetchData(getState, dispatch) { 35 | const promises = []; 36 | if (!isInfoLoaded(getState())) { 37 | promises.push(dispatch(loadInfo())); 38 | } 39 | if (!isAuthLoaded(getState())) { 40 | promises.push(dispatch(loadAuth())); 41 | } 42 | return Promise.all(promises); 43 | } 44 | 45 | handleLogout(event) { 46 | event.preventDefault(); 47 | this.props.logout(); 48 | } 49 | 50 | render() { 51 | return ( 52 |
53 | 54 |
55 | {this.props.children} 56 |
57 |
58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/__tests__/InfoBar-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {renderIntoDocument} from 'react-addons-test-utils'; 4 | import { expect} from 'chai'; 5 | import { InfoBar } from 'components'; 6 | import { Provider } from 'react-redux'; 7 | import {reduxReactRouter} from 'redux-router'; 8 | import createHistory from 'history/lib/createMemoryHistory'; 9 | import createStore from 'redux/create'; 10 | import ApiClient from 'helpers/ApiClient'; 11 | const client = new ApiClient(); 12 | 13 | describe('InfoBar', () => { 14 | const mockStore = { 15 | info: { 16 | load: () => {}, 17 | loaded: true, 18 | loading: false, 19 | data: { 20 | message: 'This came from the api server', 21 | time: Date.now() 22 | } 23 | } 24 | }; 25 | 26 | const store = createStore(reduxReactRouter, null, createHistory, client, mockStore); 27 | const renderer = renderIntoDocument( 28 | 29 | 30 | 31 | ); 32 | const dom = ReactDOM.findDOMNode(renderer); 33 | 34 | it('should render correctly', () => { 35 | return expect(renderer).to.be.ok; 36 | }); 37 | 38 | it('should render with correct value', () => { 39 | const text = dom.getElementsByTagName('strong')[0].textContent; 40 | expect(text).to.equal(mockStore.info.data.message); 41 | }); 42 | 43 | it('should render with a reload button', () => { 44 | const text = dom.getElementsByTagName('button')[0].textContent; 45 | expect(text).to.be.a('string'); 46 | }); 47 | 48 | it('should render the correct className', () => { 49 | const styles = require('components/InfoBar/InfoBar.scss'); 50 | expect(styles.infoBar).to.be.a('string'); 51 | expect(dom.className).to.include(styles.infoBar); 52 | }); 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /src/containers/About/About.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import DocumentMeta from 'react-document-meta'; 3 | import { MiniInfoBar } from 'components'; 4 | 5 | export default class About extends Component { 6 | state = { 7 | showKitten: false 8 | } 9 | 10 | handleToggleKitten() { 11 | this.setState({showKitten: !this.state.showKitten}); 12 | } 13 | 14 | render() { 15 | const {showKitten} = this.state; 16 | const kitten = require('./kitten.jpg'); 17 | return ( 18 |
19 |

About Us

20 | 21 | 22 |

This project was orginally created by Erik Rasmussen 23 | (@erikras), but has since seen many contributions 24 | from the open source community. Thank you to all the contributors. 27 |

28 | 29 |

Mini Bar (not that kind)

30 | 31 |

Hey! You found the mini info bar! The following component is display-only. Note that it shows the same 32 | time as the info bar.

33 | 34 | 35 | 36 |

Images

37 | 38 |

39 | Psst! Would you like to see a kitten? 40 | 41 | 45 |

46 | 47 | {showKitten &&
} 48 |
49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/theme/bootstrap.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bootstrap configuration for bootstrap-sass-loader 3 | * 4 | * Scripts are disabled to not load jQuery. 5 | * If you depend on Bootstrap scripts consider react-bootstrap instead. 6 | * https://github.com/react-bootstrap/react-bootstrap 7 | * 8 | * In order to keep the bundle size low in production 9 | * disable components you don't use. 10 | * 11 | */ 12 | 13 | module.exports = { 14 | preBootstrapCustomizations: './src/theme/variables.scss', 15 | mainSass: './src/theme/bootstrap.overrides.scss', 16 | verbose: false, 17 | debug: false, 18 | scripts: { 19 | transition: false, 20 | alert: false, 21 | button: false, 22 | carousel: false, 23 | collapse: false, 24 | dropdown: false, 25 | modal: false, 26 | tooltip: false, 27 | popover: false, 28 | scrollspy: false, 29 | tab: false, 30 | affix: false 31 | }, 32 | styles: { 33 | mixins: true, 34 | normalize: true, 35 | print: true, 36 | glyphicons: true, 37 | scaffolding: true, 38 | type: true, 39 | code: true, 40 | grid: true, 41 | tables: true, 42 | forms: true, 43 | buttons: true, 44 | 'component-animations': true, 45 | dropdowns: true, 46 | 'button-groups': true, 47 | 'input-groups': true, 48 | navs: true, 49 | navbar: true, 50 | breadcrumbs: true, 51 | pagination: true, 52 | pager: true, 53 | labels: true, 54 | badges: true, 55 | jumbotron: true, 56 | thumbnails: true, 57 | alerts: true, 58 | 'progress-bars': true, 59 | media: true, 60 | 'list-group': true, 61 | panels: true, 62 | wells: true, 63 | 'responsive-embed': true, 64 | close: true, 65 | modals: true, 66 | tooltip: true, 67 | popovers: true, 68 | carousel: true, 69 | utilities: true, 70 | 'responsive-utilities': true 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/utils/validation.js: -------------------------------------------------------------------------------- 1 | const isEmpty = value => value === undefined || value === null || value === ''; 2 | const join = (rules) => (value, data) => rules.map(rule => rule(value, data)).filter(error => !!error)[0 /* first error */ ]; 3 | 4 | export function email(value) { 5 | // Let's not start a debate on email regex. This is just for an example app! 6 | if (!isEmpty(value) && !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value)) { 7 | return 'Invalid email address'; 8 | } 9 | } 10 | 11 | export function required(value) { 12 | if (isEmpty(value)) { 13 | return 'Required'; 14 | } 15 | } 16 | 17 | export function minLength(min) { 18 | return value => { 19 | if (!isEmpty(value) && value.length < min) { 20 | return `Must be at least ${min} characters`; 21 | } 22 | }; 23 | } 24 | 25 | export function maxLength(max) { 26 | return value => { 27 | if (!isEmpty(value) && value.length > max) { 28 | return `Must be no more than ${max} characters`; 29 | } 30 | }; 31 | } 32 | 33 | export function integer(value) { 34 | if (!Number.isInteger(Number(value))) { 35 | return 'Must be an integer'; 36 | } 37 | } 38 | 39 | export function oneOf(enumeration) { 40 | return value => { 41 | if (!~enumeration.indexOf(value)) { 42 | return `Must be one of: ${enumeration.join(', ')}`; 43 | } 44 | }; 45 | } 46 | 47 | export function match(field) { 48 | return (value, data) => { 49 | if (data) { 50 | if (value !== data[field]) { 51 | return 'Do not match'; 52 | } 53 | } 54 | }; 55 | } 56 | 57 | export function createValidator(rules) { 58 | return (data = {}) => { 59 | const errors = {}; 60 | Object.keys(rules).forEach((key) => { 61 | const rule = join([].concat(rules[key])); // concat enables both functions and arrays of functions 62 | const error = rule(data[key], data); 63 | if (error) { 64 | errors[key] = error; 65 | } 66 | }); 67 | return errors; 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | 6 | browsers: ['PhantomJS'], 7 | 8 | singleRun: !!process.env.CONTINUOUS_INTEGRATION, 9 | 10 | frameworks: [ 'mocha' ], 11 | 12 | files: [ 13 | './node_modules/phantomjs-polyfill/bind-polyfill.js', 14 | 'tests.webpack.js' 15 | ], 16 | 17 | preprocessors: { 18 | 'tests.webpack.js': [ 'webpack', 'sourcemap' ] 19 | }, 20 | 21 | reporters: [ 'mocha' ], 22 | 23 | plugins: [ 24 | require("karma-webpack"), 25 | require("karma-mocha"), 26 | require("karma-mocha-reporter"), 27 | require("karma-phantomjs-launcher"), 28 | require("karma-sourcemap-loader") 29 | ], 30 | 31 | webpack: { 32 | devtool: 'inline-source-map', 33 | module: { 34 | loaders: [ 35 | { test: /\.(jpe?g|png|gif|svg)$/, loader: 'url', query: {limit: 10240} }, 36 | { test: /\.js$/, exclude: /node_modules/, loaders: ['babel']}, 37 | { test: /\.json$/, loader: 'json-loader' }, 38 | { test: /\.less$/, loader: 'style!css!less' }, 39 | { test: /\.scss$/, loader: 'style!css?modules&importLoaders=2&sourceMap&localIdentName=[local]___[hash:base64:5]!autoprefixer?browsers=last 2 version!sass?outputStyle=expanded&sourceMap' } 40 | ] 41 | }, 42 | resolve: { 43 | modulesDirectories: [ 44 | 'src', 45 | 'node_modules' 46 | ], 47 | extensions: ['', '.json', '.js'] 48 | }, 49 | plugins: [ 50 | new webpack.IgnorePlugin(/\.json$/), 51 | new webpack.NoErrorsPlugin(), 52 | new webpack.DefinePlugin({ 53 | __CLIENT__: true, 54 | __SERVER__: false, 55 | __DEVELOPMENT__: true, 56 | __DEVTOOLS__: false // <-------- DISABLE redux-devtools HERE 57 | }) 58 | ] 59 | }, 60 | 61 | webpackServer: { 62 | noInfo: true 63 | } 64 | 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * THIS IS THE ENTRY POINT FOR THE CLIENT, JUST LIKE server.js IS THE ENTRY POINT FOR THE SERVER. 3 | */ 4 | import 'babel-core/polyfill'; 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import createHistory from 'history/lib/createBrowserHistory'; 8 | import createStore from './redux/create'; 9 | import ApiClient from './helpers/ApiClient'; 10 | import io from 'socket.io-client'; 11 | import {Provider} from 'react-redux'; 12 | import {reduxReactRouter, ReduxRouter} from 'redux-router'; 13 | 14 | import getRoutes from './routes'; 15 | import makeRouteHooksSafe from './helpers/makeRouteHooksSafe'; 16 | 17 | const client = new ApiClient(); 18 | 19 | const dest = document.getElementById('content'); 20 | const store = createStore(reduxReactRouter, makeRouteHooksSafe(getRoutes), createHistory, client, window.__data); 21 | 22 | function initSocket() { 23 | const socket = io('', {path: '/api/ws', transports: ['polling']}); 24 | socket.on('news', (data) => { 25 | console.log(data); 26 | socket.emit('my other event', { my: 'data from client' }); 27 | }); 28 | socket.on('msg', (data) => { 29 | console.log(data); 30 | }); 31 | 32 | return socket; 33 | } 34 | 35 | global.socket = initSocket(); 36 | 37 | const component = ( 38 | 39 | ); 40 | 41 | ReactDOM.render( 42 | 43 | {component} 44 | , 45 | dest 46 | ); 47 | 48 | if (process.env.NODE_ENV !== 'production') { 49 | window.React = React; // enable debugger 50 | 51 | if (!dest || !dest.firstChild || !dest.firstChild.attributes || !dest.firstChild.attributes['data-react-checksum']) { 52 | console.error('Server-side React render was discarded. Make sure that your initial render does not contain any client-side code.'); 53 | } 54 | } 55 | 56 | if (__DEVTOOLS__) { 57 | const DevTools = require('./containers/DevTools/DevTools'); 58 | ReactDOM.render( 59 | 60 |
61 | {component} 62 | 63 |
64 |
, 65 | dest 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/containers/Chat/Chat.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import {connect} from 'react-redux'; 3 | 4 | @connect( 5 | state => ({user: state.auth.user}) 6 | ) 7 | export default 8 | class Chat extends Component { 9 | static propTypes = { 10 | user: PropTypes.object 11 | }; 12 | 13 | state = { 14 | message: '', 15 | messages: [] 16 | }; 17 | 18 | componentDidMount() { 19 | if (socket && !this.onMsgListener) { 20 | this.onMsgListener = socket.on('msg', this.onMessageReceived.bind(this)); 21 | 22 | setTimeout(() => { 23 | socket.emit('history', {offset: 0, length: 100}); 24 | }, 100); 25 | } 26 | } 27 | 28 | componentWillUnmount() { 29 | if (socket && this.onMsgListener) { 30 | socket.removeListener('on', this.onMsgListener); 31 | this.onMsgListener = null; 32 | } 33 | } 34 | 35 | onMessageReceived(data) { 36 | const messages = this.state.messages; 37 | messages.push(data); 38 | this.setState({messages}); 39 | } 40 | 41 | handleSubmit(event) { 42 | event.preventDefault(); 43 | 44 | const msg = this.state.message; 45 | 46 | this.setState({message: ''}); 47 | 48 | socket.emit('msg', { 49 | from: this.props.user.name, 50 | text: msg 51 | }); 52 | } 53 | 54 | render() { 55 | const style = require('./Chat.scss'); 56 | const {user} = this.props; 57 | 58 | return ( 59 |
60 |

Chat

61 | 62 | {user && 63 |
64 |
    65 | {this.state.messages.map((msg) => { 66 | return
  • {msg.from}: {msg.text}
  • ; 67 | })} 68 |
69 |
70 | { 73 | this.setState({message: event.target.value}); 74 | } 75 | }/> 76 | 77 |
78 |
79 | } 80 |
81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/helpers/Html.js: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import ReactDOM from 'react-dom/server'; 3 | import serialize from 'serialize-javascript'; 4 | import DocumentMeta from 'react-document-meta'; 5 | 6 | /** 7 | * Wrapper component containing HTML metadata and boilerplate tags. 8 | * Used in server-side code only to wrap the string output of the 9 | * rendered route component. 10 | * 11 | * The only thing this component doesn't (and can't) include is the 12 | * HTML doctype declaration, which is added to the rendered output 13 | * by the server.js file. 14 | */ 15 | export default class Html extends Component { 16 | static propTypes = { 17 | assets: PropTypes.object, 18 | component: PropTypes.node, 19 | store: PropTypes.object 20 | } 21 | 22 | render() { 23 | const {assets, component, store} = this.props; 24 | const content = component ? ReactDOM.renderToString(component) : ''; 25 | 26 | return ( 27 | 28 | 29 | {DocumentMeta.renderAsReact()} 30 | 31 | 32 | 33 | {/* styles (will be present only in production with webpack extract text plugin) */} 34 | {Object.keys(assets.styles).map((style, key) => 35 | 37 | )} 38 | 39 | {/* (will be present only in development mode) */} 40 | {/* outputs a