diff --git a/.gitignore b/.gitignore
index b645b87..ef31ca0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -677,4 +677,8 @@ fabric.properties
*.ear
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
-hs_err_pid*
\ No newline at end of file
+hs_err_pid*
+
+# Ignore all the stupid idea garbage
+**/.idea/**/*.xml
+**/.idea/**/*.iml
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/package.json b/frontendJS/full-stack-react-redux/voting-client/package.json
new file mode 100644
index 0000000..2e9524a
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "voting-client",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "start": "webpack-dev-server --history-api-fallback",
+ "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js \"test/**/*@(.js|.jsx)\"",
+ "test:watch": "npm run test -- --watch"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "autoprefixer": "^6.3.6",
+ "autoprefixer-loader": "^3.2.0",
+ "babel-core": "^6.9.1",
+ "babel-loader": "^6.2.4",
+ "babel-preset-es2015": "^6.9.0",
+ "babel-preset-react": "^6.5.0",
+ "chai": "^3.5.0",
+ "chai-immutable": "^1.6.0",
+ "css-loader": "^0.23.1",
+ "jsdom": "^9.2.1",
+ "mocha": "^2.5.3",
+ "postcss-loader": "^0.9.1",
+ "react-hot-loader": "^1.3.0",
+ "style-loader": "^0.13.1",
+ "webpack": "^1.13.1",
+ "webpack-dev-server": "^1.14.1"
+ },
+ "babel": {
+ "presets": [
+ "es2015",
+ "react"
+ ]
+ },
+ "dependencies": {
+ "classnames": "^2.2.5",
+ "immutable": "^3.8.1",
+ "react": "^15.1.0",
+ "react-addons-pure-render-mixin": "^15.1.0",
+ "react-addons-test-utils": "^15.1.0",
+ "react-dom": "^15.1.0",
+ "react-redux": "^4.4.5",
+ "react-router": "^2.4.1",
+ "redux": "^3.5.2",
+ "socket.io-client": "^1.4.6"
+ }
+}
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/action_creators.js b/frontendJS/full-stack-react-redux/voting-client/src/action_creators.js
new file mode 100644
index 0000000..5af2277
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/action_creators.js
@@ -0,0 +1,21 @@
+export function setState(state) {
+ return {
+ type: 'SET_STATE',
+ state
+ };
+}
+
+export function vote(entry) {
+ return {
+ meta: {remote: true},
+ type: 'VOTE',
+ entry
+ };
+}
+
+export function next() {
+ return {
+ meta: {remote: true},
+ type: 'NEXT'
+ };
+}
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/App.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/App.jsx
new file mode 100644
index 0000000..792bc92
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/components/App.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+export default React.createClass({
+ render: function() {
+ return this.props.children;
+ }
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Results.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Results.jsx
new file mode 100644
index 0000000..ace9bdc
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Results.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import {connect} from 'react-redux';
+import Winner from './Winner';
+import * as actionCreators from '../action_creators';
+
+export const VOTE_WIDTH_PERCENT = 8;
+
+export const Results = React.createClass({
+ mixins: [PureRenderMixin],
+ getPair: function () {
+ return this.props.pair || [];
+ },
+ getVotes: function(entry) {
+ if (this.props.tally && this.props.tally.has(entry)) {
+ return this.props.tally.get(entry);
+ }
+
+ return 0;
+ },
+ getVotesBlockWidth: function(entry) {
+ console.log(entry);
+ console.log(this.getVotes(entry));
+ return (this.getVotes(entry) * VOTE_WIDTH_PERCENT) + '%';
+ },
+ render: function () {
+ return this.props.winner ?
+ :
+
+
+ {this.getPair().map(entry =>
+
+
{entry}
+
+
+ {this.getVotes(entry)}
+
+
+ )}
+
+
+
+
+
;
+ }
+});
+
+function mapStateToProps(state) {
+ return {
+ pair: state.getIn(['vote', 'pair']),
+ tally: state.getIn(['vote', 'tally']),
+ winner: state.get('winner')
+ }
+}
+
+export const ResultsContainer = connect(mapStateToProps, actionCreators)(Results);
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Vote.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Vote.jsx
new file mode 100644
index 0000000..0c97d0a
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Vote.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import classNames from 'classnames';
+
+export default React.createClass({
+ mixins: [PureRenderMixin],
+ getPair: function() {
+ return this.props.pair || [];
+ },
+ isDisabled: function() {
+ return !! this.props.hasVoted;
+ },
+ hasVotedFor: function(entry) {
+ return this.props.hasVoted === entry;
+ },
+ render: function() {
+ return
+ {this.getPair().map(entry =>
+
+ )}
+
;
+ }
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Voting.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Voting.jsx
new file mode 100644
index 0000000..150b1bd
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Voting.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import {connect} from 'react-redux';
+import Winner from './Winner';
+import Vote from './Vote';
+import * as actionCreators from '../action_creators';
+
+export const Voting = React.createClass({
+ mixins: [PureRenderMixin],
+ render: function() {
+ return
+ {this.props.winner
+ ?
+ : }
+
+
;
+ }
+});
+
+function mapStateToProps(state) {
+ return {
+ pair: state.getIn(['vote', 'pair']),
+ hasVoted: state.get('hasVoted'),
+ winner: state.get('winner')
+ };
+}
+
+export const VotingContainer = connect(mapStateToProps, actionCreators)(Voting);
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/components/Winner.jsx b/frontendJS/full-stack-react-redux/voting-client/src/components/Winner.jsx
new file mode 100644
index 0000000..2fe1270
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/components/Winner.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+export default React.createClass({
+ mixins: [PureRenderMixin],
+ render: function() {
+ return
+ Winner is {this.props.winner}!
+
;
+ }
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/index.jsx b/frontendJS/full-stack-react-redux/voting-client/src/index.jsx
new file mode 100644
index 0000000..67b0fa1
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/index.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {Router, Route, browserHistory} from 'react-router';
+import {createStore, applyMiddleware} from 'redux';
+import {Provider} from 'react-redux';
+import io from 'socket.io-client';
+import reducer from './reducer';
+import {setState} from './action_creators';
+import remoteActionMiddleware from './remote_action_middleware';
+import App from './components/App';
+import {VotingContainer} from './components/Voting';
+import {ResultsContainer} from './components/Results';
+
+require('./style.css');
+
+const socket = io(`${location.protocol}//${location.hostname}:8090`);
+socket.on('state', state => store.dispatch(setState(state)));
+
+const createStoreWidthMiddleware = applyMiddleware(
+ remoteActionMiddleware(socket)
+)(createStore);
+const store = createStoreWidthMiddleware(reducer);
+
+const routes =
+
+
+;
+
+ReactDOM.render(
+
+ {routes}
+ ,
+ document.getElementById('app')
+);
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/reducer.js b/frontendJS/full-stack-react-redux/voting-client/src/reducer.js
new file mode 100644
index 0000000..5ad739a
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/reducer.js
@@ -0,0 +1,42 @@
+import {Map} from 'immutable';
+
+const SET_STATE = 'SET_STATE';
+const VOTE = 'VOTE';
+
+
+function setState(state, newState) {
+ return state.merge(newState);
+}
+
+function vote(state, entry) {
+ const currentPair = state.getIn(['vote', 'pair']);
+ if (currentPair && currentPair.includes(entry)) {
+ return state.set('hasVoted', entry);
+ }
+
+ return state;
+}
+
+function resetVote(state) {
+ const hasVoted = state.get('hasVoted');
+ const currentPair = state.getIn(['vote', 'pair']);
+ if (hasVoted && ! currentPair.includes(hasVoted)) {
+ return state.remove('hasVoted');
+ }
+
+ return state;
+}
+
+export default function(state = Map(), action) {
+ switch(action.type) {
+
+ case SET_STATE:
+ return resetVote(setState(state, action.state));
+
+ case VOTE:
+ return vote(state, action.entry);
+
+ default:
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/remote_action_middleware.js b/frontendJS/full-stack-react-redux/voting-client/src/remote_action_middleware.js
new file mode 100644
index 0000000..6f95dbe
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/remote_action_middleware.js
@@ -0,0 +1,6 @@
+export default socket => store => next => action => {
+ if (action.meta && action.meta.remote) {
+ socket.emit('action', action);
+ }
+ return next(action);
+};
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/src/style.css b/frontendJS/full-stack-react-redux/voting-client/src/style.css
new file mode 100644
index 0000000..3e1d12e
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/src/style.css
@@ -0,0 +1,123 @@
+body {
+ font-family: 'Open Sans', sans-serif;
+ background-color: #673AB7;
+ color: white;
+}
+
+/* Voting Screen */
+
+.voting {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+
+ display: flex;
+ flex-direction: column;
+
+ user-select: none;
+}
+
+.voting button {
+ flex: 1 0 0;
+
+ background-color: #673AB7;
+ border-width: 0;
+}
+.voting button:first-child {
+ border-bottom: 1px solid white;
+}
+.voting button:active {
+ background-color: white;
+ color: #311B92;
+}
+.voting button.voted {
+ background-color: #311B92;
+}
+.voting button:not(.voted) .label {
+ visibility: hidden;
+}
+.voting button .label {
+ opacity: 0.87;
+}
+.voting button.votedAgainst * {
+ opacity: 0.3;
+}
+
+@media only screen and (min-device-width: 500px) {
+ .voting {
+ flex-direction: row;
+ }
+ .voting button:first-child {
+ border-bottom-width: 0;
+ border-right: 1px solid white;
+ }
+}
+
+/* Results Screen */
+
+.results {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+
+ display: flex;
+ flex-direction: column;
+}
+.results .tally {
+ flex: 1;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+.results .tally .entry {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+}
+
+.results .tally h1 {
+ width: 25%;
+}
+.results .tally .voteVisualization {
+ height: 50px;
+ width: 50%;
+ display: flex;
+ justify-content: flex-start;
+
+ background-color: #7E57C2;
+}
+.results .tally .votesBlock {
+ background-color: white;
+ transition: width 0.5s;
+}
+.results .tally .voteCount {
+ font-size: 2rem;
+}
+
+.results .management {
+ display: flex;
+
+ height: 2em;
+ border-top: 1px solid #aaa;
+}
+
+.results .management button {
+ border: 0;
+ background-color: black;
+ color: #aaa;
+}
+.results .management .next {
+ flex: 1;
+}
+
+/* Winner View */
+
+.winner {
+ font-size: 4rem;
+ text-align: center;
+}
diff --git a/frontendJS/full-stack-react-redux/voting-client/test/components/Results_spec.jsx b/frontendJS/full-stack-react-redux/voting-client/test/components/Results_spec.jsx
new file mode 100644
index 0000000..a52dc7a
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/test/components/Results_spec.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {
+ renderIntoDocument,
+ scryRenderedDOMComponentsWithClass,
+ Simulate
+} from 'react-addons-test-utils';
+import {List, Map} from 'immutable';
+import {Results} from '../../src/components/Results';
+import {expect} from 'chai';
+
+describe('Results', () => {
+
+ it('renders entries with vote counts or zero', () => {
+ const pair = List.of('Trainspotting', '28 Days Later');
+ const tally = Map({'Trainspotting': 5});
+ const component = renderIntoDocument(
+
+ );
+ const entries = scryRenderedDOMComponentsWithClass(component, 'entry');
+ const [train, days] = entries.map(e => e.textContent);
+
+ expect(entries.length).to.equal(2);
+ expect(train).to.contain('Trainspotting');
+ expect(train).to.contain('5');
+ expect(days).to.contain('28 Days Later');
+ expect(days).to.contain('0');
+ });
+
+ it('invokes the next callback when next button is clicked', () => {
+ let nextInvoked = false;
+ const next = () => nextInvoked = true;
+
+ const pair = List.of('Trainspotting', '28 Days Later');
+ const component = renderIntoDocument(
+
+ );
+
+ Simulate.click(ReactDOM.findDOMNode(component.refs.next));
+
+ expect(nextInvoked).to.equal(true);
+ });
+
+ it('renders the winner when there is one', () => {
+ const conponent = renderIntoDocument(
+
+ );
+ const winner = ReactDOM.findDOMNode(conponent.refs.winner);
+ expect(winner).to.be.ok;
+ expect(winner.textContent).to.contain('Trainspotting');
+ });
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/test/components/Voting_spec.jsx b/frontendJS/full-stack-react-redux/voting-client/test/components/Voting_spec.jsx
new file mode 100644
index 0000000..c9bcd52
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/test/components/Voting_spec.jsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {
+ renderIntoDocument,
+ scryRenderedDOMComponentsWithTag,
+ Simulate
+} from 'react-addons-test-utils';
+
+import {List} from 'immutable';
+import {Voting} from '../../src/components/Voting';
+import {expect} from 'chai';
+
+describe('Voting', () => {
+
+ it('renders a pair of buttons', () => {
+ const component = renderIntoDocument(
+
+ );
+ const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
+
+ expect(buttons.length).to.equal(2);
+ expect(buttons[0].textContent).to.equal('Trainspotting');
+ expect(buttons[1].textContent).to.equal('28 Days Later');
+ });
+
+ it('invokes callback when a button is clicked', () => {
+ let votedWith;
+ const vote = (entry) => votedWith = entry;
+
+ const component = renderIntoDocument(
+
+ );
+ const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
+ Simulate.click(buttons[0]);
+
+ expect(votedWith).to.equal('Trainspotting');
+ });
+
+ it('disables buttons when user has voted', () => {
+ const component = renderIntoDocument(
+
+ );
+ const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
+
+ expect(buttons.length).to.equal(2);
+ expect(buttons[0].hasAttribute('disabled')).to.equal(true);
+ expect(buttons[1].hasAttribute('disabled')).to.equal(true);
+ });
+
+ it('adds label to the voted entry', () => {
+ const component = renderIntoDocument(
+
+ );
+ const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
+
+ expect(buttons[0].textContent).to.contain('Voted');
+ });
+
+ it('renders just the winner when there is one', () => {
+ const component = renderIntoDocument(
+
+ );
+ const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
+ expect(buttons.length).to.equal(0);
+
+ const winner = ReactDOM.findDOMNode(component.refs.winner);
+ expect(winner).to.be.ok;
+ expect(winner.textContent).to.contain('Trainspotting');
+ });
+
+ it('renders as a pure component', () => {
+ const pair = ['Trainspotting', '28 Days Later'];
+ const container = document.createElement('div');
+ let component = ReactDOM.render(
+ ,
+ container
+ );
+
+ let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
+ expect(firstButton.textContent).to.equal('Trainspotting');
+
+ pair[0] = 'Sunshine';
+ component = ReactDOM.render(
+ ,
+ container
+ );
+ firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
+ expect(firstButton.textContent).to.equal('Trainspotting');
+ });
+
+ it('does update DOM when prop changes', () => {
+ const pair = List.of('Trainspotting', '28 Days Later');
+ const container = document.createElement('div');
+ let component = ReactDOM.render(
+ ,
+ container
+ );
+
+ let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
+ expect(firstButton.textContent).to.equal('Trainspotting');
+
+ const newPair = pair.set(0, 'Sunshine');
+ component = ReactDOM.render(
+ ,
+ container
+ );
+ firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
+ expect(firstButton.textContent).to.equal('Sunshine');
+ });
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/test/reducer_spec.js b/frontendJS/full-stack-react-redux/voting-client/test/reducer_spec.js
new file mode 100644
index 0000000..0a118e7
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/test/reducer_spec.js
@@ -0,0 +1,133 @@
+import {List, Map, fromJS} from 'immutable';
+import {expect} from 'chai';
+
+import reducer from '../src/reducer';
+
+describe('reducer', () => {
+
+ it('handles SET_STATE', () => {
+ const initialState = Map();
+ const action = {
+ type: 'SET_STATE',
+ state: Map({
+ vote: Map({
+ pair: List.of('Trainspotting', '28 Days Later'),
+ tally: Map({
+ Trainspotting: 1
+ })
+ })
+ })
+ };
+ const nextState = reducer(initialState, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ }));
+ });
+
+ it('handles SET_STATE with plain JS payload', () => {
+ const initialState = Map();
+ const action = {
+ type: 'SET_STATE',
+ state: {
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ }
+ };
+ const nextState = reducer(initialState, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ }));
+ });
+
+ it('handles SET_STATE without initial state', () => {
+ const action = {
+ type: 'SET_STATE',
+ state: {
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ }
+ };
+ const nextState = reducer(undefined, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ }));
+ });
+
+ it('handles VOTE by setting hasVoted', () => {
+ const state = fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ });
+ const action = {type: 'VOTE', entry: 'Trainspotting'};
+ const nextState = reducer(state, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ },
+ hasVoted: 'Trainspotting'
+ }));
+ });
+
+ it('does not set hasVoted for VOTE on invalid entry', () => {
+ const state = fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ });
+ const action = {type: 'VOTE', entry: 'Sunshine'};
+ const nextState = reducer(state, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ }
+ }));
+ });
+
+ it('removes hasVoted on SET_STATE if pair changes', () => {
+ const initialState = fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {Trainspotting: 1}
+ },
+ hasVoted: 'Trainspotting'
+ });
+ const action = {
+ type: 'SET_STATE',
+ state: {
+ vote: {
+ pair: ['Sunshine', 'Slumdog Millionaire']
+ }
+ }
+ };
+ const nextState = reducer(initialState, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Sunshine', 'Slumdog Millionaire']
+ }
+ }));
+ });
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/test/test_helper.js b/frontendJS/full-stack-react-redux/voting-client/test/test_helper.js
new file mode 100644
index 0000000..824d268
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/test/test_helper.js
@@ -0,0 +1,21 @@
+/**
+ * Fakes a browser environment for testing react
+ */
+import jsdom from 'jsdom';
+import chai from 'chai';
+import chaiImmutable from 'chai-immutable';
+
+const doc = jsdom.jsdom('');
+const win = doc.defaultView;
+
+global.document = doc;
+global.window = win;
+
+// Hoist window properties to global
+Object.keys(window).forEach(key => {
+ if ( ! (key in global)) {
+ global[key] = window[key];
+ }
+});
+
+chai.use(chaiImmutable);
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-client/webpack.config.js b/frontendJS/full-stack-react-redux/voting-client/webpack.config.js
new file mode 100644
index 0000000..c9e4e87
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-client/webpack.config.js
@@ -0,0 +1,39 @@
+const autoprefixer = require('autoprefixer');
+const webpack = require('webpack');
+
+module.exports = {
+ entry: [
+ 'webpack-dev-server/client?http://localhost:8080',
+ 'webpack/hot/only-dev-server',
+ './src/index.jsx'
+ ],
+ module: {
+ loaders: [{
+ text: /\.jsx?$/,
+ exclude: /node_modules/,
+ loader: 'react-hot!babel'
+ }, {
+ test: /\.css$/,
+ loader: 'style-loader!css-loader!postcss-loader'
+ }]
+ },
+ resolve: {
+ extensions: ['', '.js', '.jsx']
+ },
+ output: {
+ path: `${__dirname}/dist`,
+ publicPath: '/',
+ filename: 'bundle.js'
+ },
+ devServer: {
+ contentBase: './dist',
+ hot: true
+ },
+ plugins: [
+ new webpack.HotModuleReplacementPlugin()
+ ],
+ postcss: () => {
+ return [autoprefixer];
+ },
+ devtool: '#source-map'
+};
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/entries.json b/frontendJS/full-stack-react-redux/voting-server/entries.json
new file mode 100644
index 0000000..1899351
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/entries.json
@@ -0,0 +1,13 @@
+[
+ "Shallow Grave",
+ "Trainspotting",
+ "A Life Less Ordinary",
+ "The Beach",
+ "28 Days Later",
+ "Millions",
+ "Sunshine",
+ "Slumdog Millionaire",
+ "127 Hours",
+ "Trance",
+ "Steve Jobs"
+]
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/index.js b/frontendJS/full-stack-react-redux/voting-server/index.js
new file mode 100644
index 0000000..6ab892b
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/index.js
@@ -0,0 +1,23 @@
+// Overall summary of architecture:
+// 1. A client sends an action to the server
+// 2. The server hands the action to the Redux store
+// 3. The store calls the reducer and the reducer executes the logic related to
+// the action
+// 4. The store updates its state based on the return value of the reducer
+// 5. The store executes its listener function subscribed by the server
+// 6. The server emits a 'state' event
+// 7. All connected clients -- including the on that initiated teh original
+// action -- receive the new state
+import makeStore from './src/store';
+import startServer from './src/server';
+
+export const store = makeStore();
+startServer(store);
+
+// Preload the state
+store.dispatch({
+ type: 'SET_ENTRIES',
+ entries: require('./entries.json')
+});
+store.dispatch({type: 'NEXT'});
+
diff --git a/frontendJS/full-stack-react-redux/voting-server/package.json b/frontendJS/full-stack-react-redux/voting-server/package.json
new file mode 100644
index 0000000..8b60f5d
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "voting-server",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "babel": {
+ "presets": [
+ "es2015"
+ ]
+ },
+ "scripts": {
+ "start": "babel-node index.js",
+ "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js --recursive",
+ "test:watch": "npm run test -- --watch"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "babel-cli": "^6.10.1",
+ "babel-core": "^6.9.1",
+ "babel-preset-es2015": "^6.9.0",
+ "chai": "^3.5.0",
+ "chai-immutable": "^1.6.0",
+ "mocha": "^2.5.3"
+ },
+ "dependencies": {
+ "immutable": "^3.8.1",
+ "redux": "^3.5.2",
+ "socket.io": "^1.4.6"
+ }
+}
diff --git a/frontendJS/full-stack-react-redux/voting-server/src/core.js b/frontendJS/full-stack-react-redux/voting-server/src/core.js
new file mode 100644
index 0000000..eabe143
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/src/core.js
@@ -0,0 +1,46 @@
+import {List, Map} from 'immutable';
+
+export const INITIAL_STATE = Map();
+
+export function setEntries(state, entries) {
+ return state.set('entries', List(entries));
+}
+
+function getWinners(vote) {
+ if ( ! vote) return [];
+ const [a, b] = vote.get('pair');
+ const aVotes = vote.getIn(['tally', a], 0);
+ const bVotes = vote.getIn(['tally', b], 0);
+
+ if (aVotes > bVotes) return [a];
+ else if (aVotes < bVotes) return [b];
+ else {
+ return [a, b]
+ }
+}
+
+export function next(state) {
+ const entries = state.get('entries')
+ .concat(getWinners(state.get('vote')));
+
+ if (entries.size === 1) {
+ return state.remove('vote')
+ .remove('entries')
+ .set('winner', entries.first());
+ } else {
+ return state.merge({
+ vote: Map({
+ pair: entries.take(2)
+ }),
+ entries: entries.skip(2)
+ });
+ }
+}
+
+export function vote(voteState, entry) {
+ return voteState.updateIn(
+ ['tally', entry],
+ 0,
+ tally => tally + 1
+ );
+}
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/src/reducer.js b/frontendJS/full-stack-react-redux/voting-server/src/reducer.js
new file mode 100644
index 0000000..c348f00
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/src/reducer.js
@@ -0,0 +1,19 @@
+import {setEntries, next, vote, INITIAL_STATE} from './core';
+
+export default function reducer(state = INITIAL_STATE, action) {
+ // Figure out which function to call and call it
+ switch(action.type) {
+ case 'SET_ENTRIES':
+ return setEntries(state, action.entries);
+
+ case 'NEXT':
+ return next(state);
+
+ case 'VOTE':
+ return state.update('vote',
+ voteState => vote(voteState, action.entry));
+
+ default:
+ return state;
+ }
+}
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/src/server.js b/frontendJS/full-stack-react-redux/voting-server/src/server.js
new file mode 100644
index 0000000..cd0bc39
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/src/server.js
@@ -0,0 +1,20 @@
+import Server from 'socket.io';
+
+export default function startServer(store) {
+ const io = new Server().attach(8090);
+
+ store.subscribe(
+ // Send 'state' events when updating state via Redux
+ () => io.emit('state', store.getState().toJS())
+ );
+
+ io.on('connection', socket => {
+ // Send state on connection
+ socket.emit('state', store.getState().toJS());
+
+ // Subscribe to action events sent by clients
+ // NOTE: this is dangerously insecure -- any client can trigger
+ // any action
+ socket.on('action', store.dispatch.bind(store));
+ });
+}
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/src/store.js b/frontendJS/full-stack-react-redux/voting-server/src/store.js
new file mode 100644
index 0000000..f8be799
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/src/store.js
@@ -0,0 +1,6 @@
+import {createStore} from 'redux';
+import reducer from './reducer';
+
+export default function makeStore() {
+ return createStore(reducer);
+}
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/test/core_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/core_spec.js
new file mode 100644
index 0000000..4e9983c
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/test/core_spec.js
@@ -0,0 +1,133 @@
+import {List, Map} from 'immutable';
+import {expect} from 'chai';
+import {setEntries, next, vote} from '../src/core';
+
+describe('application logic', () => {
+
+ describe('setEntries', () => {
+
+ it('adds the entries to the state', () => {
+ const state = Map();
+ const entries = List.of('Trainspotting', '28 Days Later');
+ const nextState = setEntries(state, entries);
+ expect(nextState).to.equal(Map({
+ entries: List.of('Trainspotting', '28 Days Later')
+ }));
+ });
+
+ it('converts to immutable', () => {
+ const state = Map();
+ const entries = ['Trainspotting', '28 Days Later'];
+ const nextState = setEntries(state, entries);
+ expect(nextState).to.equal(Map({
+ entries: List.of('Trainspotting', '28 Days Later')
+ }));
+ });
+
+ });
+
+ describe('next', () => {
+ it('takes the next two entries under vote', () => {
+ const state = Map({
+ entries: List.of('Trainspotting', '28 Days Later', 'Sunshine')
+ });
+ const nextState = next(state);
+ expect(nextState).to.equal(Map({
+ vote: Map({
+ pair: List.of('Trainspotting', '28 Days Later')
+ }),
+ entries: List.of('Sunshine')
+ }));
+ });
+
+ it('puts winner of current vote back to entries', () => {
+ const state = Map({
+ vote: Map({
+ pair: List.of('Trainspotting', '28 Days Later'),
+ tally: Map({
+ 'Trainspotting': 4,
+ '28 Days Later': 2
+ })
+ }),
+ entries: List.of('Sunshine', 'Millions', '127 Hours')
+ });
+ const nextState = next(state);
+ expect(nextState).to.equal(Map({
+ vote: Map({
+ pair: List.of('Sunshine', 'Millions')
+ }),
+ entries: List.of('127 Hours', 'Trainspotting')
+ }));
+ });
+
+ it('puts both from tied vote back to entries', () => {
+ const state = Map({
+ vote: Map({
+ pair: List.of('Trainspotting', '28 Days Later'),
+ tally: Map({
+ 'Trainspotting': 3,
+ '28 Days Later': 3
+ })
+ }),
+ entries: List.of('Sunshine', 'Millions', '127 Hours')
+ });
+ const nextState = next(state);
+ expect(nextState).to.equal(Map({
+ vote: Map({
+ pair: List.of('Sunshine', 'Millions')
+ }),
+ entries: List.of('127 Hours', 'Trainspotting', '28 Days Later')
+ }));
+ });
+
+ it('marks winner when just one entry left', () => {
+ const state = Map({
+ vote: Map({
+ pair: List.of('Trainspotting', '28 Days Later'),
+ tally: Map({
+ 'Trainspotting': 4,
+ '28 Days Later': 2
+ })
+ }),
+ entries: List()
+ });
+ const nextState = next(state);
+ expect(nextState).to.equal(Map({
+ winner: 'Trainspotting'
+ }));
+ });
+ });
+
+ describe('vote', () => {
+ it('creates a tally for the voted entry', () => {
+ const state = Map({
+ pair: List.of('Trainspotting', '28 Days Later')
+ });
+ const nextState = vote(state, 'Trainspotting');
+ expect(nextState).to.equal(Map({
+ pair: List.of('Trainspotting', '28 Days Later'),
+ tally: Map({
+ Trainspotting: 1
+ })
+ }));
+ });
+
+ it('adds to existing tally for the voted entry', () => {
+ const state = Map({
+ pair: List.of('Trainspotting', '28 Days Later'),
+ tally: Map({
+ 'Trainspotting': 3,
+ '28 Days Later': 2
+ })
+ });
+ const nextState = vote(state, 'Trainspotting');
+ expect(nextState).to.equal(Map({
+ pair: List.of('Trainspotting', '28 Days Later'),
+ tally: Map({
+ 'Trainspotting': 4,
+ '28 Days Later': 2
+ })
+ }));
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/test/immutable_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/immutable_spec.js
new file mode 100644
index 0000000..51df945
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/test/immutable_spec.js
@@ -0,0 +1,66 @@
+import {expect} from 'chai';
+import {List, Map} from 'immutable';
+
+describe('immutability', () => {
+ describe('a number', () => {
+ function increment(currentState) {
+ return currentState + 1;
+ }
+
+ it('is immutable', () => {
+ let state = 42;
+ let nextState = increment(state);
+
+ expect(nextState).to.equal(43);
+ expect(state).to.equal(42);
+ });
+ });
+
+ describe('a list', () => {
+ function addMovie(currentState, movie) {
+ return currentState.push(movie);
+ }
+
+ it('is immutable', () => {
+ let state = List.of('Trainspotting', '28 Days Later');
+ let nextState = addMovie(state, 'Sunshine');
+
+ expect(nextState).to.equal(List.of(
+ 'Trainspotting',
+ '28 Days Later',
+ 'Sunshine'
+ ));
+ expect(state).to.equal(List.of(
+ 'Trainspotting',
+ '28 Days Later'
+ ));
+ });
+ });
+
+ describe('a tree', () => {
+ function addMovie(currentState, movie) {
+ return currentState.update('movies', movies => movies.push(movie));
+ }
+
+ it('is immutable', () => {
+ let state = Map({
+ movies: List.of('Trainspotting', '28 Days Later')
+ });
+ let nextState = addMovie(state, 'Sunshine');
+
+ expect(nextState).to.equal(Map({
+ movies: List.of(
+ 'Trainspotting',
+ '28 Days Later',
+ 'Sunshine'
+ )
+ }));
+ expect(state).to.equal(Map({
+ movies: List.of(
+ 'Trainspotting',
+ '28 Days Later'
+ )
+ }));
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/test/reducer_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/reducer_spec.js
new file mode 100644
index 0000000..ee81f0f
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/test/reducer_spec.js
@@ -0,0 +1,77 @@
+import {Map, fromJS} from 'immutable';
+import {expect} from 'chai';
+
+import reducer from '../src/reducer';
+
+describe('reducer', () => {
+
+ it('handles SET_ENTRIES', () => {
+ const initialState = Map();
+ const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
+ const nextState = reducer(initialState, action);
+
+ expect(nextState).to.equal(fromJS({
+ entries: ['Trainspotting']
+ }));
+ });
+
+ it('handles NEXT', () => {
+ const initialState = fromJS({
+ entries: ['Trainspotting', '28 Days Later']
+ });
+ const action = {type: 'NEXT'};
+ const nextState = reducer(initialState, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later']
+ },
+ entries: []
+ }));
+ });
+
+ it('handles VOTE', () => {
+ const initialState = fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later']
+ },
+ entries: []
+ });
+ const action = {type: 'VOTE', entry: 'Trainspotting'};
+ const nextState = reducer(initialState, action);
+
+ expect(nextState).to.equal(fromJS({
+ vote: {
+ pair: ['Trainspotting', '28 Days Later'],
+ tally: {
+ Trainspotting: 1
+ }
+ },
+ entries: []
+ }));
+ });
+
+ it('has an initial state', () => {
+ const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
+ const nextState = reducer(undefined, action);
+ expect(nextState).to.equal(fromJS({
+ entries: ['Trainspotting']
+ }));
+ });
+
+ it('can be used with reduce', () => {
+ const actions = [
+ {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']},
+ {type: 'NEXT'},
+ {type: 'VOTE', entry: 'Trainspotting'},
+ {type: 'VOTE', entry: '28 Days Later'},
+ {type: 'VOTE', entry: 'Trainspotting'},
+ {type: 'NEXT'}
+ ];
+ const finalState = actions.reduce(reducer, Map());
+
+ expect(finalState).to.equal(fromJS({
+ winner: 'Trainspotting'
+ }));
+ });
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/test/store_spec.js b/frontendJS/full-stack-react-redux/voting-server/test/store_spec.js
new file mode 100644
index 0000000..b1bc97d
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/test/store_spec.js
@@ -0,0 +1,21 @@
+import {Map, fromJS} from 'immutable';
+import {expect} from 'chai';
+
+import makeStore from '../src/store';
+
+describe('store', () => {
+
+ it('is a Redux store configured with the correct reducer', () => {
+ const store = makeStore();
+ expect(store.getState()).to.equal(Map());
+
+ store.dispatch({
+ type: 'SET_ENTRIES',
+ entries: ['Trainspotting', '28 Days Later']
+ });
+ expect(store.getState()).to.equal(fromJS({
+ entries: ['Trainspotting', '28 Days Later']
+ }));
+ });
+
+});
\ No newline at end of file
diff --git a/frontendJS/full-stack-react-redux/voting-server/test/test_helper.js b/frontendJS/full-stack-react-redux/voting-server/test/test_helper.js
new file mode 100644
index 0000000..a87528d
--- /dev/null
+++ b/frontendJS/full-stack-react-redux/voting-server/test/test_helper.js
@@ -0,0 +1,4 @@
+import chai from 'chai';
+import chaiImmutable from 'chai-immutable';
+
+chai.use(chaiImmutable);
\ No newline at end of file
diff --git a/frontendJS/respotify/.babelrc b/frontendJS/respotify/.babelrc
new file mode 100644
index 0000000..8d73672
--- /dev/null
+++ b/frontendJS/respotify/.babelrc
@@ -0,0 +1,3 @@
+{
+ "presets": ["react", "es2015"]
+}
\ No newline at end of file
diff --git a/frontendJS/respotify/package.json b/frontendJS/respotify/package.json
new file mode 100644
index 0000000..a092c5b
--- /dev/null
+++ b/frontendJS/respotify/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "respotify",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "start": "webpack-dev-server --hot --inline",
+ "build": "webpack",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "babel-core": "^6.10.4",
+ "babel-loader": "^6.2.4",
+ "babel-preset-es2015": "^6.9.0",
+ "babel-preset-react": "^6.11.1",
+ "file-loader": "^0.9.0",
+ "react-hot-loader": "^1.3.0",
+ "webpack": "^1.13.1",
+ "webpack-dev-server": "^1.14.1"
+ },
+ "dependencies": {
+ "react": "^15.2.0",
+ "react-dom": "^15.2.0"
+ }
+}
diff --git a/frontendJS/respotify/src/greeting.js b/frontendJS/respotify/src/greeting.js
new file mode 100644
index 0000000..0f3f0c9
--- /dev/null
+++ b/frontendJS/respotify/src/greeting.js
@@ -0,0 +1,11 @@
+import React from "react";
+
+export default React.createClass({
+ render: function() {
+ return (
+
+ Hello, {this.props.name}!
+
+ )
+ }
+})
\ No newline at end of file
diff --git a/frontendJS/respotify/src/index.html b/frontendJS/respotify/src/index.html
new file mode 100644
index 0000000..a889f34
--- /dev/null
+++ b/frontendJS/respotify/src/index.html
@@ -0,0 +1,12 @@
+
+
+
+ Respotify
+
+
+
+ Respotify
+
+
+
+
\ No newline at end of file
diff --git a/frontendJS/respotify/src/index.js b/frontendJS/respotify/src/index.js
new file mode 100644
index 0000000..4f0df5b
--- /dev/null
+++ b/frontendJS/respotify/src/index.js
@@ -0,0 +1,8 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import Greeting from "./greeting";
+
+ReactDOM.render(
+ ,
+ document.getElementById('container')
+);
\ No newline at end of file
diff --git a/frontendJS/respotify/webpack.config.js b/frontendJS/respotify/webpack.config.js
new file mode 100644
index 0000000..e1168e5
--- /dev/null
+++ b/frontendJS/respotify/webpack.config.js
@@ -0,0 +1,36 @@
+const webpack = require('webpack');
+const path = require('path');
+
+const PATHS = {
+ app: './src/index.js',
+ html: './src/index.html',
+ dist: path.join(__dirname, 'dist')
+};
+
+module.exports = {
+ entry: {
+ javascript: PATHS.app,
+ html: PATHS.html
+ },
+ output: {
+ path: PATHS.dist,
+ publicPath: '/',
+ filename: 'bundle.js'
+ },
+ devServer: {
+ contentBase: PATHS.dist
+ },
+ module: {
+ loaders: [
+ {
+ test: /\.html$/,
+ loader: "file?name=[name].[ext]"
+ },
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ loaders: ["react-hot", "babel-loader"]
+ }
+ ]
+ }
+};
\ No newline at end of file