Merge pull request #232 from RickCarlino/master

Styling Updates
pull/234/head
Rick Carlino 2015-10-09 09:55:11 -05:00
commit a39f032ef9
31 changed files with 435 additions and 156 deletions

1
.gitignore vendored
View File

@ -32,6 +32,7 @@ rerun.txt
pickle-email-*.html
/public/assets/
/public/build/*
/public/js/*
## Environment normalisation:

View File

@ -19,9 +19,10 @@ gem 'devise', github: 'plataformatec/devise'
gem 'mutations'
gem 'active_model_serializers', '~> 0.8.3'
gem 'ice_cube'
gem 'gulp_rails', '~> 1.0'
source 'https://rails-assets.org' do
gem 'rails-assets-ng-sortable'
gem 'rails-assets-ng-sortable', '~> 1.2.2'
gem 'rails-assets-ng-pickadate'
gem 'rails-assets-js-data'
gem 'rails-assets-js-data-angular'

View File

@ -145,6 +145,7 @@ GEM
sexp_processor (~> 4.4)
font-awesome-rails (4.3.0.0)
railties (>= 3.2, < 5.0)
gulp_rails (1.0)
haml (4.0.6)
tilt
high_voltage (2.1.0)
@ -242,7 +243,7 @@ GEM
rails-assets-ng-pickadate (0.2.2)
rails-assets-angular (~> 1.4.5)
rails-assets-pickadate (~> 3.5.6)
rails-assets-ng-sortable (1.3.0)
rails-assets-ng-sortable (1.2.3)
rails-assets-angular (>= 1.3.0)
rails-assets-pickadate (3.5.6)
rails-assets-jquery (>= 1.7)
@ -370,6 +371,7 @@ DEPENDENCIES
factory_girl_rails
faker
font-awesome-rails
gulp_rails (~> 1.0)
haml
high_voltage (~> 2.1.0)
ice_cube
@ -387,7 +389,7 @@ DEPENDENCIES
rails-assets-js-data-angular!
rails-assets-lodash!
rails-assets-ng-pickadate!
rails-assets-ng-sortable!
rails-assets-ng-sortable (~> 1.2.2)!
rails-assets-pickadate!
rails-assets-sio-client!
rails_12factor

View File

@ -14,14 +14,18 @@ This Repo is the Web based side of FarmBot. It allows users to control the devic
# Developer setup
1. `git clone git@github.com:FarmBot/farmbot-web-app.git`
2. `cd farmbot-web-app`
3. [Install MongoDB](http://docs.mongodb.org/manual/tutorial/install-mongodb-on-os-x/)
4. Start Mongo if you have not already done so. (typically via the `mongod` command)
3. `bundle install`
4. `npm install`
5. `rails s`
6. Go to `http://localhost:3000`
0. `git clone git@github.com:FarmBot/farmbot-web-app.git`
0. `cd farmbot-web-app`
0. [Install MongoDB](http://docs.mongodb.org/manual/tutorial/install-mongodb-on-os-x/)
0. Start Mongo if you have not already done so. (typically via the `mongod` command)
0. `bundle install`
0. [Install node](https://nodejs.org/en/download/package-manager/)
0. `sudo npm install gulp -g` if you don't have gulp installed already.
0. `npm install`
0. `rails s`
0. Go to `http://localhost:3000`
The frontend (and asset management) are very much in a transitional state. We're experimenting with Gulp as an alternative
**We can't fix issues we don't know about.** Please submit an issue if you are having trouble installing on your local machine.

View File

@ -0,0 +1,117 @@
function NullSequence(){
this._id = null;
this.steps = [];
}
var controller = function($scope, Data, Devices) {
$scope.sequence = new NullSequence;
$scope.operators = ['==', '>', '<', '!='];
$scope.variables = ["x", "y", "z", "s", "busy", "last", "pin0", "pin1",
"pin2", "pin3", "pin4", "pin5", "pin6", "pin7", "pin8",
"pin9", "pin10", "pin11", "pin12", "pin13"];
var nope = function(e) {
alert('Doh!');
return console.error(e);
};
Data.findAll('sequence', {}).catch(nope);
Data.bindAll('sequence', {}, $scope, 'storedSequences');
$scope.dragControlListeners = {
orderChanged: function(event) {
var position, step;
position = event.dest.index;
step = event.source.itemScope.step;
return Data.update('step', step._id, {
position: position
}).catch(nope).then(function(step) {
return $scope.load($scope.sequence);
});
}
};
var hasSequence = function() {
var whoah = function() {
return alert('Select or create a sequence first.');
};
if (!!$scope.sequence._id) {
return true;
} else {
whoah();
return false;
}
};
$scope.addStep = function(message_type) {
if (!hasSequence()) {
return;
}
return Data.create('step', {
message_type: message_type,
sequence_id: $scope.sequence._id
}).catch(nope);
};
$scope.load = function(seq) {
return Data.loadRelations('sequence', seq._id, ['step'], {
bypassCache: true
}).catch(nope).then(function(sequence) {
return $scope.sequence = sequence;
});
};
$scope.addSequence = function(params) {
if (params == null) {
params = {};
}
if (params.name == null) {
params.name = 'Untitled Sequence';
}
return Data.create('sequence', params).catch(nope).then(function(seq) {
return $scope.load(seq);
});
};
$scope.deleteSequence = function(seq) {
if (!hasSequence()) {
return;
}
return Data.destroy('sequence', seq._id).catch(nope).then(function() {
return $scope.sequence = new NullSequence;
});
};
$scope.saveSequence = function(seq) {
return Data.save('sequence', seq._id).catch(nope).then(function(sequence) {
var i, inx, len, ref, results, step;
ref = sequence.steps;
results = [];
for (inx = i = 0, len = ref.length; i < len; inx = ++i) {
step = ref[inx];
results.push(Data.update('step', step._id, {
command: step.command
}).catch(function(e) {
alert("Error saving step " + (inx + 1) + ". See console for details.");
return console.error(e);
}));
}
return results;
});
};
$scope.copy = function(obj, index) {
if (!hasSequence()) {
return;
}
return Data.create('step', {
sequence_id: $scope.sequence._id,
message_type: obj.message_type,
command: obj.command || {},
position: index
}).catch(nope);
};
$scope.deleteStep = function(index) {
return Data.destroy('step', $scope.sequence.steps[index]._id).catch(nope);
};
return $scope.execute = function(seq) {
var sequence;
sequence = Data.utils.removeCircular(seq);
return Devices.send("exec_sequence", sequence);
};
};
angular.module('FarmBot').controller("SequenceController",
['$scope', 'Data', 'Devices', controller])

View File

@ -0,0 +1,26 @@
module Api
class PlantingAreasController < Api::AbstractController
def index
render json: PlantingArea.where(device: current_device)
end
def create
mutate PlantingAreas::Create.run(params, device: current_device)
end
def destroy
if (planting_area.device == current_device) && planting_area.destroy
render nothing: true
else
raise Errors::Forbidden, "Not your Planting Area."
end
end
private
def planting_area
@planting_area ||= PlantingArea.find(params[:id])
end
end
end

View File

@ -10,7 +10,7 @@ module Api
end
def destroy
if (crop.device == current_device) && crop.destroy
if (plant.device == current_device) && plant.destroy
render nothing: true
else
raise Errors::Forbidden, "Not your Plant object."
@ -19,8 +19,8 @@ module Api
private
def crop
@crop ||= Plant.find(params[:id])
def plant
@plant ||= Plant.find(params[:id])
end
end
end

View File

@ -7,6 +7,7 @@ class Device
has_many :schedules, dependent: :destroy
has_many :sequences
has_many :plants, dependent: :destroy
has_one :planting_area
# The SkyNet UUID of the device

View File

@ -3,6 +3,7 @@ class Plant
include Mongoid::Document
belongs_to :device
belongs_to :planting_area
field :x, type: Integer
field :y, type: Integer

View File

@ -0,0 +1,10 @@
# The area inside of the FarmBot's tracks.
class PlantingArea
include Mongoid::Document
field :width, type: Integer, default: 600
field :length, type: Integer, default: 300
has_many :plants
belongs_to :device
end

View File

@ -1,3 +1,4 @@
= render partial: "pages/navbar"
%div
%script{:id => "devices.html", :type => "text/ng-template"}
= render partial: "dashboard/ng-partials/devices"

View File

@ -14,57 +14,5 @@
= javascript_include_tag "//maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"
= javascript_include_tag "application"
%body{'ng-app' => 'FarmBot'}
.row
%nav.navbar.navbar-default.drop-shadow{ng_app: 'farmbot', ng_controller: 'nav', role: "navigation"}
.container-fluid
/ Brand and toggle get grouped for better mobile display
.navbar-header
%button.navbar-toggle{"data-target" => "#navbar", "data-toggle" => "collapse", :type => "button"}
%span.glyphicon.glyphicon-menu-hamburger
/ Collect the nav links, forms, and other content for toggling
#navbar.collapse.navbar-collapse
%ul.nav.navbar-nav
- if current_user
%li
%a{:href => "/pages/farm_designer"} Farm Designer
%li
%a{:href => "/dashboard#/movement"} Controls
%li
%a{:href => "/dashboard#/devices"} Devices
%li
%a{:href => "/dashboard#/sequence"} Sequences
%li
%a{:href => "/dashboard#/schedule"} Schedules
%ul.nav.navbar-nav.navbar-right
- if current_user && controller_name == "dashboard"
%li
%syncbutton.nav-status-buttons{schedules: "schedules"}
%li
%stopbutton.nav-status-buttons
- if current_user
%li
%a{:href => destroy_user_session_path} Sign out
%li
%a{:href => edit_user_registration_path} My Account
- else
%li
%a{:href => new_user_session_path} Log In
%li
%a{:href => new_user_registration_path} Register
%li
%a{:href => page_path('help')} Help
/ /.navbar-collapse
/ /.container-fluid
.row
.container.col-xs-12.col-sm-6.col-md-4.col-xs-centered
- if notice
.alert-box.notice.round{"onClick" => "hidden = true"}
= notice
%a.close{style: 'margin-right: 10px; color: white; opacity: 1.0;'} ×
- if alert
.alert-box.alert.round{"onClick" => "hidden = true"}
= alert
%a.close{style: 'margin-right: 10px; color: white; opacity: 1.0;'} ×
.container.col-lg-12
.content
= yield
.content
= yield

View File

@ -0,0 +1,83 @@
<div class="row"></div>
<nav class="navbar navbar-default drop-shadow" ng_app="farmbot" ng_controller="nav" role="navigation">
<div class="container-fluid">
<!-- Brand and toggle get grouped for better mobile display
-->
<div class="navbar-header">
<button class="navbar-toggle" data-target="#navbar" data-toggle="collapse" type="button">
<span class="glyphicon glyphicon-menu-hamburger"></span>
</button>
</div>
<!-- Collect the nav links, forms, and other content for toggling
-->
<div class="collapse navbar-collapse" id="navbar">
<ul class="nav navbar-nav">
<% if current_user %>
<li>
<a href="/pages/farm_designer">Farm Designer</a>
</li>
<li>
<a href="/dashboard#/movement">Controls</a>
</li>
<li>
<a href="/dashboard#/devices">Devices</a>
</li>
<li>
<a href="/dashboard#/sequence">Sequences</a>
</li>
<li>
<a href="/dashboard#/schedule">Schedules</a>
</li>
<% end %>
</ul>
<ul class="nav navbar-nav navbar-right">
<% if current_user && controller_name == "dashboard" %>
<li>
<syncbutton class="nav-status-buttons" schedules="schedules"></syncbutton>
</li>
<li>
<stopbutton class="nav-status-buttons"></stopbutton>
</li>
<% end %>
<% if current_user %>
<li>
<a href="<%= destroy_user_session_path %>">Sign out</a>
</li>
<li>
<a href="<%= edit_user_registration_path %>">My Account</a>
</li>
<% else %>
<li>
<a href="<%= new_user_session_path %>">Log In</a>
</li>
<li>
<a href="<%= new_user_registration_path %>">Register</a>
</li>
<li>
<a href="<%= page_path('help') %>">Help</a>
</li>
<% end %>
</ul>
</div>
<!-- /.navbar-collapse
-->
</div>
<!-- /.container-fluid
-->
</nav>
<div class="row">
<div class="container col-xs-12 col-sm-6 col-md-4 col-xs-centered">
<% if notice %>
<div class="alert-box notice round" onClick="hidden = true">
<%= notice %>
<a class="close" style="margin-right: 10px; color: white; opacity: 1.0;">×</a>
</div>
<% end %>
<% if alert %>
<div class="alert-box alert round" onClick="hidden = true">
<%= alert %>
<a class="close" style="margin-right: 10px; color: white; opacity: 1.0;">×</a>
</div>
<% end %>
</div>
</div>

View File

@ -1,4 +1,4 @@
<div class="farm-designer" id="root">
<div id="root">
</div>
<script type="text/javascript">
@ -9,6 +9,7 @@
},
global: {
plants: <%= raw(current_user.device.plants.to_json) %>,
planting_area: <%= raw PlantingArea.find_or_create_by(device: current_user.device).to_json %>,
selectedPlant: {}
}
};

View File

@ -7,3 +7,9 @@ FarmBot::Application.configure do
config.consider_all_requests_local = true
config.eager_load = false
end
# Whether or not compilation should take place
GulpRails.options[:enabled] = true
# The command to run
GulpRails.options[:command] = 'gulp default'
# The directory in which your command should be executed
GulpRails.options[:directory] = Rails.root

View File

@ -3,6 +3,7 @@ FarmBot::Application.routes.draw do
namespace :api, defaults: {format: :json} do
resource :device, only: [:show, :destroy, :create, :update]
resources :plants, only: [:create, :destroy, :index]
resources :planting_area, only: [:create, :destroy]
resources :sequences, only: [:create, :update, :destroy, :index, :show] do
resources :steps, only: [:show, :create, :index, :update, :destroy]
end

View File

@ -3,30 +3,27 @@ var gulp = require('gulp'),
concat = require('gulp-concat'),
browserify = require('browserify'),
source = require('vinyl-source-stream'),
exec = require('child_process').exec;
exec = require('child_process').exec,
babelify = require('babelify');
var paths = {
js: './javascripts/**/**/*.js'
};
function oops (s) {
exec("espeak 'build Error.'");
exec( 'notify-send "' + (s.message || s) + '"' );
exec( 'notify-send "' + (s.message || s || "Gulp Error") + '"' );
gutil.log(s.message);
}
gulp.task('default', function () {
gulp.watch(paths.js, ['build']);
gulp.task('watch', function () {
gulp.watch(paths.js, ['default']);
});
gulp.task('build', function () {
browserify({
entries: ['javascripts/farm_designer.js'],
extensions: ['.js']
})
gulp.task('default', function () {
browserify('javascripts/farm_designer.js',{debug:true})
.transform(babelify)
.bundle()
.on('error', oops)
.pipe(source('farm-designer.js'))
.pipe(gulp.dest('public/build/'));
exec("espeak 'Saved.'");
})

View File

@ -1,17 +1,20 @@
import React from 'react/addons';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { store } from './redux/store';
import { connect } from 'react-redux';
import { DesignerMain } from './menus/designer_main';
function wow (d) {
return {dispatch: d};
function mapDispatchToProps(d) {
return {
dispatch: d
};
}
var App = connect(s => s, wow)(DesignerMain);
var App = connect(state => state, mapDispatchToProps)(DesignerMain);
React.render(
<Provider store={store}>
{() => <App />}
{ () => <App />}
</Provider>,
document.getElementById('root')
);

View File

@ -0,0 +1,10 @@
/*
Translates page x/y coordinates to garden x/y.
TODO: A type checker would be pretty sweet here.
*/
export function fromScreenToGarden(mouseX, mouseY, boxX, boxY) {
var rawX = mouseX - boxX;
var rawY = boxY - mouseY;
return {x: rawX, y: rawY};
};

View File

@ -4,6 +4,7 @@ import { Calendar } from './calendar';
import { PlantInfo } from './plant_info';
import { CropInfo } from './crop_info';
import { GardenMap } from './garden_map';
import { Navbar } from './navbar';
const LEFT_MENU_CHOICES = {PlantInventory, PlantCatalog, PlantInfo, CropInfo}
@ -33,23 +34,26 @@ export class DesignerMain extends React.Component {
render(){
return (
<div className="farm-designer-body">
<div className="farm-designer-left">
<div id="designer-left">
{ this.renderLeft() }
<div className="farm-designer">
<Navbar/>
<div className="farm-designer-body">
<div className="farm-designer-left">
<div id="designer-left">
{ this.renderLeft() }
</div>
</div>
<div className="farm-designer-middle">
{ this.renderMiddle() }
</div>
<div className="farm-designer-right">
<div id="designer-right">
<Calendar />
</div>
</div>
</div>
</div>
<div className="farm-designer-middle">
{ this.renderMiddle() }
</div>
<div className="farm-designer-right">
<div id="designer-right">
<Calendar />
</div>
</div>
</div>
);
}
}

View File

@ -1,35 +1,51 @@
export class MapPointView extends React.Component {
export class MapPoint extends React.Component {
select() {
this.props.dispatch({type: "CROP_SELECT", payload: this.props.plant});
}
selected() {
return (!!this.props.selected);
}
render() {
var style = {
position: 'absolute',
left: (this.props.plant.x - 20),
top: (this.props.plant.y - 40)
};
if (!!this.props.selected) { style.border = "1px solid green"; };
return <div onClick={ this.select.bind(this) }>
<img style={style} src="/designer_icons/pin.png"></img>
</div>
var { length } = this.props.planting_area;
var fill = this.selected() ? "red" : "black";
return <circle cx={ this.props.plant.x }
cy={ (-1 * this.props.plant.y) + length }
onClick={ this.select.bind(this) }
fill={ fill }
r="5" />;
}
};
export class GardenMap extends React.Component {
plants() {
return this.props.plants.map(
(p, k) => <MapPointView plant={ p }
(p, k) => <MapPoint plant={ p }
key={ k }
planting_area={ this.props.planting_area }
selected={ (this.props.selectedPlant._id === p._id) }
dispatch={ this.props.dispatch }/>
);
}
render() {
var style = {
fill: 'rgb(136, 119, 102)',
strokeWidth: 1,
stroke: 'rgb(0,0,0)'
}
var {width, length} = this.props.planting_area;
return <div>
<div id="drop-area">
{ this.plants() }
<div id="drop-area" style={ {marginLeft: '10px', marginTop: '10px'} }>
<svg width={ width }
height={ length } >
<rect width={ width }
height={ length }
style={ style } />
{ this.plants() }
</svg>
</div>
</div>;
}

View File

@ -0,0 +1,22 @@
export class Navbar extends React.Component {
render() {
return <div className="row">
<nav className="drop-shadow">
<div className="small-menu-title">MENU</div>
<a href="/">Home</a>
<a href="/pages/farm_designer">Farm Designer</a>
<a href="/dashboard#/movement">Controls</a>
<a href="/dashboard#/devices">Devices</a>
<a href="/dashboard#/sequence">Sequences</a>
<a href="/dashboard#/schedule">Schedules</a>
<a className="large-menu-right" href="/users/sign_out">Sign out</a>
<a className="large-menu-right" href="/users/edit">My Account</a>
<button className="red button-like" type="button">Stop*</button>
<button className="yellow button-like" type="button">
Sync <i className="fa fa-upload"></i>*
</button>
LAST SYNC: Never
</nav>
</div>
}
}

View File

@ -1,8 +1,13 @@
import { Plant } from '../plant';
import { fromScreenToGarden } from '../geometry/coordinates';
export class PlantInfo extends React.Component {
drop (e) {
var plant = new Plant({x: e.clientX, y: e.clientY});
var box = document
.querySelector('#drop-area > svg > rect')
.getBoundingClientRect();
var coords = fromScreenToGarden(e.pageX, e.pageY, box.left, box.bottom)
var plant = new Plant(coords);
this.props.dispatch({type: "CROP_ADD_REQUEST", payload: plant});
}

View File

@ -33,6 +33,9 @@ export class Item extends React.Component {
};
export class Plants extends React.Component {
wow() {
this.props.dispatch({type: "EXPERIMENTAL"});
}
render() {
var d = this.props.dispatch;
return(

View File

@ -9,7 +9,6 @@ export class Plant {
}
};
Plant.fakePlants = [
new Plant({name: "Blueberry", imgUrl: "/designer_icons/blueberry.svg"}),
new Plant({name: "Cabbage", imgUrl: "/designer_icons/cabbage.svg"}),

View File

@ -1,45 +1,48 @@
//actually, these are 'action creators'.
import { store } from './store';
import { addons } from 'react/addons';
let actions = {};
actions['@@redux/INIT'] = empty;
actions.DEFAULT = function (s, a) {
actions.DEFAULT = function(s, a) {
console.warn("Unknown action fired.");
console.trace();
return s;
};
actions.CROP_SELECT = function(s, a) {
var select_crop = update(s, {global: {selectedPlant: a.payload}});
var select_crop = update(s, {
global: {
selectedPlant: a.payload
}
});
var change_menu = actions.CROP_INFO_SHOW(select_crop, a);
return _.merge({}, select_crop, change_menu);
};
actions.CROP_ADD_REQUEST = function (s, a) {
actions.CROP_ADD_REQUEST = function(s, a) {
// TODO: Add some sort of Redux Async handler.
$.ajax({method: "POST", url: "/api/plants", data: a.payload})
.fail(function (a, b, c) {
alert("Failed to add crop. Refresh page.");
});
$.ajax({
method: "POST",
url: "/api/plants",
data: a.payload
})
.fail((a, b, c) => alert("Failed to add crop. Refresh page."))
.then((aa,bb,cc,dd) => store.dispatch({type: "CROP_ADD_FINISH"}));
var plants = _.cloneDeep(s.global.plants);
var selectedPlant = _.cloneDeep(a.payload);
var selectedPlant = _.cloneDeep(a.payload);
plants.push(selectedPlant);
return update(s, { global: { plants, selectedPlant } });
return update(s, {
global: {
plants,
selectedPlant
}
});
};
// function incrementAsync() {
// return dispatch => {
// setTimeout(() => {
// // Yay! Can invoke sync or async actions with `dispatch`
// dispatch(increment());
// }, 1000);
// };
// }
actions.CROP_ADD_FINISH = function(s, a) { return s; };
actions.CROP_REMOVE_REQUEST = function (s, a) {
actions.CROP_REMOVE_REQUEST = function(s, a) {
var s = _.cloneDeep(s);
var id = a.payload._id;
_.remove(s.global.plants, a.payload)
@ -66,36 +69,47 @@ actions.PLANT_INFO_SHOW = function(s, a) {
actions.CROP_INFO_SHOW = function(s, a) {
// TODO: add type system to check for presence of `crop` Object?
return update(s, {
leftMenu: {
component: 'CropInfo',
plant: a.payload
}
});
leftMenu: {
component: 'CropInfo',
plant: a.payload
}
});
};
actions.CATALOG_SHOW = function(s, a){
actions.CATALOG_SHOW = function(s, a) {
return changeLeftComponent(s, 'PlantCatalog');
};
actions.INVENTORY_SHOW = function(s, a){
actions.INVENTORY_SHOW = function(s, a) {
return changeLeftComponent(s, 'PlantInventory');
};
actions.INVENTORY_SHOW_TAB = function(s, a) {
return update(s, {leftMenu: {tab: a.payload}});
return update(s, {
leftMenu: {
tab: a.payload
}
});
}
function empty(s, a) {
return s;
};
}
;
function changeLeftComponent(state, name) {
return update(state, {leftMenu: {component: name}});
};
return update(state, {
leftMenu: {
component: name
}
});
}
;
function update(old_state, new_state) {
return _.merge({}, old_state, new_state);
};
}
;
export { actions };

View File

@ -2,11 +2,13 @@ import { actions } from './actions';
import { isFSA } from 'flux-standard-action';
export function reducer(state, action) {
if (isFSA(action)){
if (isFSA(action)) {
console.log(action.type);
console.dir(state);
return (actions[action.type] || actions.DEFAULT)(state, action);
} else {
console.error("Action does not conform to 'flux-standard-action", action);
};
};
}
;
}
;

View File

@ -2,8 +2,7 @@ import { createStore, applyMiddleware } from 'redux';
import { reducer } from './reducer';
import thunk from 'redux-thunk';
// var store = createStore(reducer, window.initialState);
var store = applyMiddleware(thunk)
(createStore)
(reducer, window.initialState);
var wrappedCreatedStore = applyMiddleware(thunk)(createStore);
var store = wrappedCreatedStore(reducer, window.initialState);
export { store };

View File

@ -31,11 +31,13 @@
"gulp": "^3.9.0",
"gulp-concat": "^2.6.0",
"gulp-util": "^3.0.6",
"react": "^0.13.3",
"react": "^0.13.0",
"react-dom": "^0.14.0",
"react-redux": "^2.1.2",
"reactify": "^1.1.1",
"redux": "^3.0.0",
"redux-thunk": "^1.0.0",
"vinyl-source-stream": "^1.1.0"
"vinyl-source-stream": "^1.1.0",
"whatwg-fetch": "^0.9.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB