diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..374993d
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,22 @@
+{
+ "presets": ["es2015", "stage-0", "react"],
+ "plugins": ["add-module-exports"],
+ "env": {
+ "production": {
+ "presets": ["react-optimize"],
+ "plugins": [
+ "transform-remove-console",
+ "transform-remove-debugger",
+ "dev-expression"
+ ]
+ },
+ "development": {
+ "presets": ["react-hmre"],
+ },
+ "test": {
+ "plugins": [
+ ["webpack-loaders", { "config": "webpack.config.node.js", "verbose": false }]
+ ]
+ }
+ }
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1addde2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+root = true
+
+[*]
+indent_style = tab
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.{json,js,jsx,html,css}]
+indent_style = space
+indent_size = 2
+
+[.eslintrc]
+indent_style = space
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..a9b203a
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1 @@
+main.js
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..c270cfc
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,27 @@
+{
+ "parser": "babel-eslint",
+ "extends": "airbnb",
+ "env": {
+ "browser": true,
+ "mocha": true,
+ "node": true
+ },
+ "rules": {
+ "semi": 0,
+ "max-len": 0,
+ "consistent-return": 0,
+ "comma-dangle": 0,
+ "no-use-before-define": 0,
+ "object-curly-spacing": 0,
+ "import/no-unresolved": [2, { ignore: ['electron'] }],
+ "react/prefer-es6-class": 0,
+ "react/prefer-stateless-function": 0,
+ },
+ "plugins": [
+ "import",
+ "react"
+ ],
+ "settings": {
+ "import/resolver": "webpack"
+ }
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..176a458
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..177a2d2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,36 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
+node_modules
+
+# OSX
+.DS_Store
+
+# App packaged
+dist
+release
+main.js
+main.js.map
diff --git a/.jsbeautifyrc b/.jsbeautifyrc
new file mode 100644
index 0000000..2c5f016
--- /dev/null
+++ b/.jsbeautifyrc
@@ -0,0 +1,47 @@
+{
+ "html": {
+ "allowed_file_extensions": ["htm", "html", "xhtml", "shtml", "xml", "svg"],
+ "brace_style": "end-expand",
+ "end_with_newline": false,
+ "indent_char": " ",
+ "indent_handlebars": false,
+ "indent_inner_html": false,
+ "indent_scripts": "keep",
+ "indent_size": 2,
+ "max_preserve_newlines": 0,
+ "preserve_newlines": true,
+ "unformatted": ["a", "span", "img", "code", "pre", "sub", "sup", "em", "strong", "b", "i", "u", "strike", "big", "small", "pre", "h1", "h2", "h3", "h4", "h5", "h6"],
+ "wrap_line_length": 0
+ },
+ "css": {
+ "allowed_file_extensions": ["css", "scss", "sass", "less"],
+ "end_with_newline": false,
+ "indent_char": " ",
+ "indent_size": 2,
+ "newline_between_rules": true,
+ "selector_separator": " ",
+ "selector_separator_newline": true
+ },
+ "js": {
+ "allowed_file_extensions": ["js", "json", "jshintrc", "jsbeautifyrc", "jsx"],
+ "brace_style": "collapse",
+ "break_chained_methods": false,
+ "e4x": true,
+ "end_with_newline": true,
+ "indent_char": " ",
+ "indent_level": 0,
+ "indent_size": 2,
+ "indent_with_tabs": false,
+ "jslint_happy": false,
+ "keep_array_indentation": false,
+ "keep_function_indentation": false,
+ "max_preserve_newlines": 2,
+ "preserve_newlines": true,
+ "space_after_anon_function": false,
+ "space_before_conditional": true,
+ "space_in_empty_paren": false,
+ "space_in_paren": false,
+ "unescape_strings": false,
+ "wrap_line_length": 0
+ }
+}
diff --git a/.tern-project b/.tern-project
new file mode 100644
index 0000000..b525ef6
--- /dev/null
+++ b/.tern-project
@@ -0,0 +1,15 @@
+{
+ "ecmaVersion": 6,
+ "libs": [],
+ "plugins": {
+ "complete_strings": {},
+ "node": {},
+ "lint": {},
+ "requirejs": {},
+ "modules": {},
+ "es_modules": {},
+ "doc_comment": {
+ "fullDocs": true
+ }
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8792bc4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-present C. T. Lin
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/app/App.global.scss b/app/App.global.scss
new file mode 100644
index 0000000..43a9e52
--- /dev/null
+++ b/app/App.global.scss
@@ -0,0 +1,38 @@
+$primary-color: darkgray;
+
+@import '~font-awesome/css/font-awesome.min.css';
+
+* {
+ -webkit-user-select: none;
+ cursor: default;
+}
+
+html, body, #root {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ color: #5a5a5a;
+ font-family: Helvetica;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+
+ tbody {
+ tr {
+ border-bottom: 1px solid #eee;
+
+ td {
+ padding: 8px 16px;
+ }
+ }
+ }
+}
diff --git a/app/App.js b/app/App.js
new file mode 100644
index 0000000..194445a
--- /dev/null
+++ b/app/App.js
@@ -0,0 +1,29 @@
+import React from 'react'
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import NavigationController from './NavigationController'
+
+import './App.global.scss'
+
+import RootLayout from './RootLayout'
+
+export default React.createClass({
+ propTypes: {
+ settings: React.PropTypes.object.isRequired,
+ watcher: React.PropTypes.object.isRequired,
+ trader: React.PropTypes.object.isRequired
+ },
+
+ mixins: [PureRendererMixin],
+
+ renderScene(route, navigation) {
+ return
+ },
+
+ render() {
+ const initialRoute = {component: RootLayout, props: this.props}
+
+ return (
+
+ )
+ }
+})
diff --git a/app/BalanceListView.js b/app/BalanceListView.js
new file mode 100644
index 0000000..2098af6
--- /dev/null
+++ b/app/BalanceListView.js
@@ -0,0 +1,58 @@
+import React from 'react'
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import math from 'mathjs'
+
+import {Pane, Body, Header} from './Pane'
+
+export default React.createClass({
+ propTypes: {
+ trader: React.PropTypes.any.isRequired,
+ watcher: React.PropTypes.any.isRequired,
+ },
+
+ mixins: [PureRendererMixin],
+
+ getInitialState() {
+ return {
+ balances: this.props.trader.balances,
+ }
+ },
+
+ componentWillMount() {
+ this.props.trader.on('balances', this.onBalances)
+ },
+
+ componentWillUnmount() {
+ this.props.trader.off('balances', this.onBalances)
+ },
+
+ onBalances(balances) {
+ this.setState({balances})
+ },
+
+ renderBalance(balance, currency) {
+ return (
+
+ {currency} |
+ {balance.toFixed(8)} |
+
+ )
+ },
+
+ render() {
+ const balances = this.state.balances.filter((balance) => balance > 0.0)
+
+ return (
+
+
+
+
+
+ {balances.map(this.renderBalance).toArray()}
+
+
+
+
+ )
+ }
+})
diff --git a/app/Blink.js b/app/Blink.js
new file mode 100644
index 0000000..193144f
--- /dev/null
+++ b/app/Blink.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import _ from 'underscore'
+
+import styles from './Blink.scss'
+
+export default React.createClass({
+ propTypes: {
+ children: React.PropTypes.any
+ },
+
+ mixins: [PureRendererMixin],
+
+ componentWillUpdate(nextProps) {
+ if (!_.isEqual(nextProps.children, this.props.children)) {
+ this.refs.target.classList.add(styles.in)
+ setTimeout(() => {
+ if (this.refs.target) {
+ this.refs.target.classList.remove(styles.in)
+ }
+ }, 500)
+ }
+ },
+
+ render() {
+ return
+ }
+})
diff --git a/app/Blink.scss b/app/Blink.scss
new file mode 100644
index 0000000..77b9464
--- /dev/null
+++ b/app/Blink.scss
@@ -0,0 +1,4 @@
+.in {
+ opacity: 0;
+ transition: all 500ms ease-in-out;
+}
diff --git a/app/Chart.js b/app/Chart.js
new file mode 100644
index 0000000..b49e47a
--- /dev/null
+++ b/app/Chart.js
@@ -0,0 +1,92 @@
+import React from 'react'
+import ReStock from 'react-stockcharts'
+import {format} from 'd3'
+
+const {ChartCanvas, Chart, EventCapture} = ReStock;
+
+const {
+ CandlestickSeries,
+ BarSeries,
+ LineSeries,
+ BollingerSeries
+} = ReStock.series;
+
+const {discontinuousTimeScaleProvider} = ReStock.scale;
+const {CrossHairCursor, MouseCoordinateY, CurrentCoordinate} = ReStock.coordinates;
+const {EdgeIndicator} = ReStock.coordinates;
+const {TooltipContainer, OHLCTooltip, MovingAverageTooltip, BollingerBandTooltip} = ReStock.tooltip;
+const {XAxis, YAxis} = ReStock.axes;
+const {ema, bollingerBand} = ReStock.indicator;
+const {fitWidth} = ReStock.helper;
+
+export default fitWidth(React.createClass({
+ propTypes: {
+ data: React.PropTypes.array.isRequired,
+ seriesName: React.PropTypes.string.isRequired,
+ width: React.PropTypes.number.isRequired
+ },
+
+ render() {
+ const {data, width} = this.props;
+ const margin = {
+ left: 70,
+ right: 70,
+ top: 20,
+ bottom: 30
+ };
+
+ const gridWidth = width - margin.left - margin.right;
+
+ const yGrid = {innerTickSize: -1 * gridWidth, tickStrokeOpacity: 0.2 }
+
+ const ema20 = ema()
+ .id(0)
+ .windowSize(20)
+ .merge((d, c) => { d.ema20 = c })
+ .accessor(d => d.ema20);
+
+ const ema50 = ema().id(2)
+ .windowSize(50)
+ .merge((d, c) => { d.ema50 = c })
+ .accessor(d => d.ema50)
+
+ const bb = bollingerBand()
+
+ return (
+ d.date} xScaleProvider={discontinuousTimeScaleProvider} >
+ [d.high, d.low]} padding={{ top: 10, bottom: 20 }}>
+
+
+
+
+
+
+ d.close > d.open ? '#6BA583' : '#DB0000'} fill={d => d.close > d.open ? '#6BA583' : '#DB0000'} />
+
+
+
+
+
+
+
+
+
+ d.close} fill={d => d.close > d.open ? '#6BA583' : '#DB0000'} />
+
+
+ d.volume} height={100} origin={(w, h) => [0, h - 100]}>
+
+ d.volume} fill={d => d.close > d.open ? '#6BA583' : '#DB0000'} />
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}))
diff --git a/app/CurrencyPairListItem.js b/app/CurrencyPairListItem.js
new file mode 100644
index 0000000..73bf68b
--- /dev/null
+++ b/app/CurrencyPairListItem.js
@@ -0,0 +1,121 @@
+import React from 'react'
+import {findDOMNode} from 'react-dom'
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import {Sparklines, SparklinesLine} from 'react-sparklines'
+import {ProgressCircle} from 'react-desktop/macOs'
+
+import {Row, Column, Gutter, Spacer} from './Layout'
+import FlexibleLayout from './FlexibleLayout'
+import Blink from './Blink'
+import Star from './Star'
+
+export default React.createClass({
+ propTypes: {
+ watcher: React.PropTypes.object.isRequired,
+ trader: React.PropTypes.object.isRequired,
+ currencyPair: React.PropTypes.object.isRequired,
+ isStarred: React.PropTypes.bool,
+ onClick: React.PropTypes.func.isRequired,
+ onToggle: React.PropTypes.func.isRequired
+ },
+
+ mixins: [PureRendererMixin],
+
+ getDefaultProps() {
+ return {
+ isStarred: false
+ }
+ },
+
+ getInitialState() {
+ return {
+ isLoading: true,
+ data: []
+ }
+ },
+
+ componentWillMount() {
+ this.fetchSparklineData()
+ this.interval = setInterval(this.fetchSparklineData, 5000)
+ },
+
+ componentWillUnmount() {
+ clearInterval(this.interval)
+ },
+
+ onClick(e) {
+ if (e.target !== findDOMNode(this.refs.star)) {
+ this.props.onClick(this.props.currencyPair)
+ }
+ },
+
+ onToggle() {
+ this.props.onToggle(this.props.currencyPair)
+ },
+
+ fetchSparklineData() {
+ this.props.watcher.fetchChartData(this.props.currencyPair.key, 60 * 60 * 24).then((data) => {
+ this.setState({isLoading: false, data: data.map(d => d.close)})
+ })
+ },
+
+ renderSparkline(layout) {
+ if (this.state.isLoading) {
+ return
+ }
+
+ return (
+
+
+
+ )
+ },
+
+ render() {
+ const {currencyPair, trader} = this.props
+ const {name, currency, market, last, percentChange} = currencyPair
+ const color = percentChange == 0 ? 'black' : (percentChange > 0 ? 'green' : 'red')
+ const trendSymbol = percentChange > 0 ? '+' : ''
+
+ return (
+
+
+
+
+
+ {name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {currency}
+ {market}
+
+
+
+ {trader.getAvailableBalance(currency).toFixed(8)}
+ {trader.getAvailableBalance(market).toFixed(8)}
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+})
diff --git a/app/CurrencyPairListView.js b/app/CurrencyPairListView.js
new file mode 100644
index 0000000..6a8fc96
--- /dev/null
+++ b/app/CurrencyPairListView.js
@@ -0,0 +1,104 @@
+import React from 'react'
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import Immutable from 'immutable'
+import firstBy from 'thenby'
+
+import Star from './Star'
+import CurrencyPairView from './CurrencyPairView'
+import CurrencyPairListItem from './CurrencyPairListItem'
+import {Pane, Body, Header} from './Pane'
+import {Gutter} from './Layout'
+
+export default React.createClass({
+ propTypes: {
+ watcher: React.PropTypes.object.isRequired,
+ settings: React.PropTypes.object.isRequired,
+ trader: React.PropTypes.object.isRequired,
+ navigation: React.PropTypes.object
+ },
+
+ mixins: [PureRendererMixin],
+
+ getInitialState() {
+ return {
+ filter: '',
+ shouldShowOnlyStarred: this.props.settings.get('shouldShowOnlyStarred', false),
+ starredCurrencyPairs: Immutable.Set(this.props.settings.get('starredCurrencyPairs', [])),
+ tickers: this.props.watcher.tickers,
+ }
+ },
+
+ componentWillMount() {
+ this.props.watcher.on('tickers', this.onTickers)
+ },
+
+ componentWillUnmount() {
+ this.props.watcher.off('tickers', this.onTickers)
+ },
+
+ onTickers(tickers) {
+ this.setState({tickers})
+ },
+
+ onToggleShouldShowOnlyStarred(value) {
+ this.props.settings.set('shouldShowOnlyStarred', value)
+ this.setState({shouldShowOnlyStarred: value})
+ },
+
+ onChangeFilter(e) {
+ this.setState({filter: e.target.value})
+ },
+
+ onToggleCurrencyPair(currencyPair) {
+ const {starredCurrencyPairs} = this.state
+ let newStarredCurrencyPairs
+
+ if (starredCurrencyPairs.includes(currencyPair.key)) {
+ newStarredCurrencyPairs = starredCurrencyPairs.delete(currencyPair.key)
+ } else {
+ newStarredCurrencyPairs = starredCurrencyPairs.add(currencyPair.key)
+ }
+
+ this.props.settings.set('starredCurrencyPairs', newStarredCurrencyPairs.toJS())
+ this.setState({starredCurrencyPairs: newStarredCurrencyPairs})
+ },
+
+ onClickRow(currencyPair) {
+ this.props.navigation.push({component: CurrencyPairView, props: {currencyPair}})
+ },
+
+ renderCurrencyPair(currencyPair) {
+ return (
+
+ )
+ },
+
+ render() {
+ const {trader} = this.props
+ const filter = this.state.filter.toLocaleLowerCase()
+ const matches = this.state.tickers
+ .filter(p => this.state.shouldShowOnlyStarred ? this.state.starredCurrencyPairs.includes(p.key) : true)
+ .filter(p => this.state.filter.length > 0 ? p.key.toLocaleLowerCase().indexOf(filter) > -1 : true)
+
+ return (
+
+
+
+ {matches.sort(firstBy('percentChange', -1)).map(this.renderCurrencyPair).toArray()}
+
+
+ )
+ }
+})
diff --git a/app/CurrencyPairView.js b/app/CurrencyPairView.js
new file mode 100644
index 0000000..6d015e6
--- /dev/null
+++ b/app/CurrencyPairView.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import plnx from 'plnx';
+import LoadingView from './LoadingView'
+import Chart from './Chart'
+import {Pane, Header, Body} from './Pane'
+
+export default React.createClass({
+ propTypes: {
+ currencyPair: React.PropTypes.object.isRequired,
+ navigation: React.PropTypes.object
+ },
+
+ mixins: [PureRendererMixin],
+
+ getInitialState() {
+ return {
+ isLoading: true,
+ data: []
+ }
+ },
+
+ componentWillMount() {
+ const params = {
+ currencyPair: this.props.currencyPair.key,
+ start: Math.floor((Date.now() / 1000) - 60 * 60 * 24 * 7),
+ end: Math.floor(Date.now() / 1000),
+ period: 14400
+ }
+
+ plnx.returnChartData(params, (err, data) => {
+ if (err) { throw err }
+ this.setState({
+ isLoading: false,
+ data: data.map((d) => {
+ d.date = new Date(d.date * 1000)
+ return d
+ })
+ })
+ })
+ },
+
+ onClickBack() {
+ this.props.navigation.pop()
+ },
+
+ renderChart() {
+ if (this.state.isLoading) {
+ return
+ }
+ return
+ },
+
+ render() {
+ return (
+
+
+
+ {this.props.currencyPair.name}
+
+ {this.renderChart()}
+
+ );
+ }
+})
diff --git a/app/FlexibleLayout.js b/app/FlexibleLayout.js
new file mode 100644
index 0000000..a6b285b
--- /dev/null
+++ b/app/FlexibleLayout.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import {findDOMNode} from 'react-dom';
+
+export default React.createClass({
+ propTypes: {
+ render: React.PropTypes.func.isRequired
+ },
+
+ getInitialState() {
+ return {}
+ },
+
+ componentDidMount() {
+ this.updateLayout()
+ window.addEventListener('resize', this.updateLayout);
+ },
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.updateLayout);
+ },
+
+ updateLayout() {
+ this.setState({
+ isUpdating: true
+ }, () => {
+ requestAnimationFrame(() => {
+ const el = findDOMNode(this)
+ if (el) {
+ this.setState({
+ isUpdating: false,
+ layout: {
+ width: el.parentNode.clientWidth,
+ height: el.parentNode.clientHeight,
+ }
+ })
+ }
+ })
+ })
+ },
+
+ render() {
+ if (!this.state.layout || this.state.isUpdating) { return }
+ return this.props.render(this.state.layout)
+ }
+})
diff --git a/app/IconTemplate.png b/app/IconTemplate.png
new file mode 100644
index 0000000..3aecc99
Binary files /dev/null and b/app/IconTemplate.png differ
diff --git a/app/IconTemplate@2x.png b/app/IconTemplate@2x.png
new file mode 100644
index 0000000..3aecc99
Binary files /dev/null and b/app/IconTemplate@2x.png differ
diff --git a/app/Layout.js b/app/Layout.js
new file mode 100644
index 0000000..fb35984
--- /dev/null
+++ b/app/Layout.js
@@ -0,0 +1,7 @@
+import Row from './Layout/Row'
+import Column from './Layout/Column'
+import Spacer from './Layout/Spacer'
+import Divider from './Layout/Divider'
+import Gutter from './Layout/Gutter'
+
+export default {Row, Column, Spacer, Gutter, Divider}
diff --git a/app/Layout/Column.js b/app/Layout/Column.js
new file mode 100644
index 0000000..c768f6c
--- /dev/null
+++ b/app/Layout/Column.js
@@ -0,0 +1,14 @@
+import React from 'react'
+
+export default React.createClass({
+ propTypes: {
+ flex: React.PropTypes.any
+ },
+
+ render() {
+ const {flex, ...props} = this.props
+ return (
+
+ )
+ }
+})
diff --git a/app/Layout/Divider.js b/app/Layout/Divider.js
new file mode 100644
index 0000000..d76e7e2
--- /dev/null
+++ b/app/Layout/Divider.js
@@ -0,0 +1,18 @@
+import React from 'react'
+
+const styles = {
+ horizontal: {height: 1},
+ vertical: {width: 1}
+}
+
+export default React.createClass({
+ propTypes: {
+ direction: React.PropTypes.oneOf(Object.keys(styles)).isRequired
+ },
+
+ render() {
+ return (
+
+ )
+ }
+})
diff --git a/app/Layout/Gutter.js b/app/Layout/Gutter.js
new file mode 100644
index 0000000..ce27a1f
--- /dev/null
+++ b/app/Layout/Gutter.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+export default React.createClass({
+ render() {
+ return (
+
+ )
+ }
+})
diff --git a/app/Layout/Row.js b/app/Layout/Row.js
new file mode 100644
index 0000000..9945a25
--- /dev/null
+++ b/app/Layout/Row.js
@@ -0,0 +1,14 @@
+import React from 'react'
+
+export default React.createClass({
+ propTypes: {
+ flex: React.PropTypes.any
+ },
+
+ render() {
+ const {flex, ...props} = this.props
+ return (
+
+ )
+ }
+})
diff --git a/app/Layout/Spacer.js b/app/Layout/Spacer.js
new file mode 100644
index 0000000..8b9f212
--- /dev/null
+++ b/app/Layout/Spacer.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+export default React.createClass({
+ render() {
+ return (
+
+ )
+ }
+})
diff --git a/app/LoadingView.js b/app/LoadingView.js
new file mode 100644
index 0000000..a2455df
--- /dev/null
+++ b/app/LoadingView.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import {ProgressCircle} from 'react-desktop/macOs'
+import {Row} from './Layout'
+
+export default React.createClass({
+ mixins: [PureRendererMixin],
+
+ render() {
+ return (
+
+
+
+ );
+ }
+})
diff --git a/app/NavigationController.js b/app/NavigationController.js
new file mode 100644
index 0000000..689af91
--- /dev/null
+++ b/app/NavigationController.js
@@ -0,0 +1,35 @@
+import React from 'react'
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import Immutable from 'immutable'
+
+export default React.createClass({
+ propTypes: {
+ renderScene: React.PropTypes.func.isRequired
+ },
+
+ mixins: [PureRendererMixin],
+
+ getInitialState() {
+ return {
+ routeStack: Immutable.List([this.props.initialRoute])
+ }
+ },
+
+ pop() {
+ this.setState({
+ routeStack: this.state.routeStack.pop()
+ })
+ },
+
+ push(route) {
+ this.setState({
+ routeStack: this.state.routeStack.push(route)
+ })
+ },
+
+ render() {
+ return (
+ this.props.renderScene(this.state.routeStack.last(), this)
+ )
+ }
+})
diff --git a/app/OrderListView.js b/app/OrderListView.js
new file mode 100644
index 0000000..3685e1f
--- /dev/null
+++ b/app/OrderListView.js
@@ -0,0 +1,61 @@
+import React from 'react'
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import {Pane, Body, Header} from './Pane'
+
+export default React.createClass({
+ propTypes: {
+ trader: React.PropTypes.object.isRequired,
+ watcher: React.PropTypes.object.isRequired,
+ },
+
+ mixins: [PureRendererMixin],
+
+ getInitialState() {
+ return {
+ orders: this.props.trader.orders,
+ }
+ },
+
+ componentWillMount() {
+ this.props.trader.on('orders', this.onOrders)
+ },
+
+ componentWillUnmount() {
+ this.props.trader.off('orders', this.onOrders)
+ },
+
+ onOrders(orders) {
+ this.setState({orders})
+ },
+
+ renderOrder(order) {
+ const [market, currency] = order.currencyPair.split('_', 2)
+ return (
+
+ {order.type.toLocaleUpperCase()} |
+ {order.amount.toFixed(8)} {currency} |
+ {order.rate.toFixed(8)} {currency}/{market} |
+ {(order.rate * order.amount).toFixed(8)} {market} |
+
+ )
+ },
+
+ render() {
+ const orders = this.state.orders.map((currencyPairOrders, currencyPair) => {
+ return currencyPairOrders.map((order) => {
+ return {...order, currencyPair}
+ })
+ }).toList().flatten()
+
+ return (
+
+
+
+
+ {orders.map(this.renderOrder).toArray()}
+
+
+
+ )
+ }
+})
diff --git a/app/Pane.js b/app/Pane.js
new file mode 100644
index 0000000..4d6dc56
--- /dev/null
+++ b/app/Pane.js
@@ -0,0 +1,6 @@
+import Pane from './Pane/Pane'
+import Header from './Pane/Header'
+import Body from './Pane/Body'
+import Footer from './Pane/Footer'
+
+export default {Pane, Header, Body, Footer}
diff --git a/app/Pane/Body.js b/app/Pane/Body.js
new file mode 100644
index 0000000..1067cce
--- /dev/null
+++ b/app/Pane/Body.js
@@ -0,0 +1,11 @@
+import React from 'react'
+
+export default class extends React.Component {
+ render() {
+ return (
+
+ {this.props.children}
+
+ )
+ }
+}
diff --git a/app/Pane/Footer.js b/app/Pane/Footer.js
new file mode 100644
index 0000000..fcfe75a
--- /dev/null
+++ b/app/Pane/Footer.js
@@ -0,0 +1,28 @@
+import React from 'react'
+
+export default class extends React.Component {
+ static propTypes = {
+ children: React.PropTypes.any,
+ onClick: React.PropTypes.func
+ }
+
+ render() {
+ const style = {
+ display: 'flex',
+ flexDirection: 'row',
+ padding: '8px 16px',
+ backgroundColor: '#eee',
+ borderTop: '1px solid #bbb',
+ alignItems: 'center',
+ height: 20
+ }
+
+ const {onClick} = this.props
+
+ return (
+
+ {this.props.children}
+
+ )
+ }
+}
diff --git a/app/Pane/Header.js b/app/Pane/Header.js
new file mode 100644
index 0000000..983e252
--- /dev/null
+++ b/app/Pane/Header.js
@@ -0,0 +1,29 @@
+import React from 'react'
+
+export default class extends React.Component {
+ static propTypes = {
+ children: React.PropTypes.any,
+ onClick: React.PropTypes.func
+ }
+
+ render() {
+ const style = {
+ display: 'flex',
+ flexDirection: 'row',
+ padding: '8px 16px',
+ backgroundColor: '#eee',
+ fontSize: 14,
+ borderBottom: '1px solid #bbb',
+ alignItems: 'center',
+ height: 20
+ }
+
+ const {onClick} = this.props
+
+ return (
+
+ {this.props.children}
+
+ )
+ }
+}
diff --git a/app/Pane/Pane.js b/app/Pane/Pane.js
new file mode 100644
index 0000000..23c8908
--- /dev/null
+++ b/app/Pane/Pane.js
@@ -0,0 +1,11 @@
+import React from 'react'
+
+export default class extends React.Component {
+ render() {
+ return (
+
+ {this.props.children}
+
+ )
+ }
+}
diff --git a/app/RootLayout.js b/app/RootLayout.js
new file mode 100644
index 0000000..15eab76
--- /dev/null
+++ b/app/RootLayout.js
@@ -0,0 +1,56 @@
+import React from 'react'
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+
+import NavigationController from './NavigationController'
+import {Footer} from './Pane'
+import SettingsView from './SettingsView'
+import OrderListView from './OrderListView'
+import BalanceListView from './BalanceListView'
+import CurrencyPairListView from './CurrencyPairListView'
+
+import {Row, Column, Divider, Spacer} from './Layout'
+
+export default React.createClass({
+ propTypes: {
+ rootNavigation: React.PropTypes.object.isRequired,
+ settings: React.PropTypes.object.isRequired,
+ watcher: React.PropTypes.object.isRequired,
+ trader: React.PropTypes.object.isRequired
+ },
+
+ mixins: [PureRendererMixin],
+
+ onClickSettings() {
+ this.props.rootNavigation.push({component: SettingsView, props: this.props})
+ },
+
+ renderScene(route, navigation) {
+ return
+ },
+
+ render() {
+ const {watcher, trader} = this.props
+ const initialRoute = {component: CurrencyPairListView, props: this.props}
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+})
diff --git a/app/Settings.js b/app/Settings.js
new file mode 100644
index 0000000..16c6248
--- /dev/null
+++ b/app/Settings.js
@@ -0,0 +1,19 @@
+import {Emitter} from 'event-kit'
+
+export default class extends Emitter {
+ set(key, value) {
+ localStorage.setItem(key, JSON.stringify(value))
+ this.emit('item', {key, value})
+ this.emit('update')
+ }
+
+ get(key, defaultValue = null) {
+ const value = localStorage.getItem(key)
+
+ if (value) {
+ return JSON.parse(value)
+ }
+
+ return defaultValue
+ }
+}
diff --git a/app/SettingsView.js b/app/SettingsView.js
new file mode 100644
index 0000000..0f2dd91
--- /dev/null
+++ b/app/SettingsView.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+import {Pane, Body, Header} from './Pane'
+import {TextInput, Button, Box} from 'react-desktop/macOs';
+
+export default React.createClass({
+ propTypes: {
+ settings: React.PropTypes.object.isRequired,
+ rootNavigation: React.PropTypes.object
+ },
+
+ mixins: [PureRendererMixin],
+
+ getInitialState() {
+ const {key, secret} = this.props.settings.get('credentials', {
+ key: '',
+ secret: ''
+ })
+ return {key, secret}
+ },
+
+ onClickBack() {
+ this.props.rootNavigation.pop()
+ },
+
+ onClickSave() {
+ const {key, secret} = this.state
+ this.props.settings.set('credentials', {key, secret})
+ this.props.rootNavigation.pop()
+ },
+
+ onChangeKey(e) {
+ this.setState({key: e.target.value})
+ },
+
+ onChangeSecret(e) {
+ this.setState({secret: e.target.value})
+ },
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+})
diff --git a/app/Star.js b/app/Star.js
new file mode 100644
index 0000000..ae1e3d0
--- /dev/null
+++ b/app/Star.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PureRendererMixin from 'react-addons-pure-render-mixin'
+
+export default React.createClass({
+ propTypes: {
+ isStarred: React.PropTypes.bool,
+ onToggle: React.PropTypes.func.isRequired,
+ },
+
+ mixins: [PureRendererMixin],
+
+ getDefaultProps() {
+ return {
+ isStarred: false
+ }
+ },
+
+ onClick() {
+ this.props.onToggle(!this.props.isStarred)
+ },
+
+ render() {
+ const className = this.props.isStarred ? 'fa fa-star' : 'fa fa-star-o'
+ return
+ }
+})
diff --git a/app/Trader.js b/app/Trader.js
new file mode 100644
index 0000000..7bf63df
--- /dev/null
+++ b/app/Trader.js
@@ -0,0 +1,118 @@
+import Immutable from 'immutable'
+import plnx from 'plnx'
+import {Emitter} from 'event-kit'
+import math from 'mathjs'
+
+export default class extends Emitter {
+ windowSize = 100 // points
+
+ constructor(watcher, settings) {
+ super()
+
+ this.watcher = watcher
+ this.settings = settings
+ this.orders = Immutable.Map()
+ this.balances = Immutable.Map()
+ this.priceHistory = Immutable.Map()
+ this.tradeHistory = Immutable.Map()
+
+ this.refreshBalances()
+ this.refreshOrders()
+ setInterval(this.refreshBalances.bind(this), 5000)
+ setInterval(this.refreshOrders.bind(this), 5000)
+
+ watcher.on('ticker', this.onTicker.bind(this))
+ }
+
+ fetchTradeHistory(currencyPair) {
+ return new Promise((resolve, reject) => {
+ const credentials = this.settings.get('credentials')
+ plnx.returnTradeHistory({currencyPair, ...credentials}, (err, data) => {
+ if (err) {
+ reject(err)
+ } else {
+ resolve(data)
+ }
+ })
+ })
+ }
+
+ refreshBalances() {
+ const credentials = this.settings.get('credentials')
+ if (credentials) {
+ plnx.returnBalances(credentials, (err, data) => {
+ if (err) {
+ throw err
+ }
+
+ this.balances = this.balances.withMutations((b) => {
+ return Object.keys(data).map((key) => {
+ return b.set(key, parseFloat(data[key]))
+ })
+ })
+
+ this.emit('balances', this.balances)
+ })
+ }
+ }
+
+ refreshOrders() {
+ const credentials = this.settings.get('credentials')
+ if (credentials) {
+ plnx.returnOpenOrders({currencyPair: 'all', ...credentials}, (err, data) => {
+ if (err) {
+ throw err
+ }
+
+ this.orders = this.orders.withMutations((orders) => {
+ return Object.keys(data).map((currencyPair) => {
+ const currencyPairOrders = data[currencyPair].map((order) => {
+ return {
+ number: order.orderNumber,
+ type: order.type,
+ rate: parseFloat(order.rate),
+ amount: parseFloat(order.amount),
+ total: parseFloat(order.total),
+ date: Date.parse(order.date)
+ }
+ })
+ return orders.set(currencyPair, Immutable.List(currencyPairOrders))
+ })
+ })
+
+ this.emit('orders', this.orders)
+ })
+ }
+ }
+
+ getAvailableBalance(currency) {
+ return this.balances.get(currency) || 0.0
+ }
+
+ getPriceHistory(currencyPair) {
+ return this.priceHistory.get(currencyPair) || Immutable.List()
+ }
+
+ getChangePercent(currencyPair) {
+ const priceHistory = this.getPriceHistory(currencyPair)
+ return (priceHistory.last() - priceHistory.first()) * 100
+ }
+
+ getStdDeviation(currencyPair) {
+ return math.std(...this.getPriceHistory(currencyPair).toArray())
+ }
+
+
+ getOpenOrdersForCurrencyPair(currencyPair) {
+ return this.orders.filter((order) => order.currencyPair == currencyPair)
+ }
+
+ updatePriceHistory(currencyPair, price) {
+ this.priceHistory = this.priceHistory.set(currencyPair, this.getPriceHistory(currencyPair).push(price).slice(-this.windowSize))
+ this.emit('priceHistory', this.priceHistory)
+ }
+
+ onTicker(currencyPair) {
+ this.updatePriceHistory(currencyPair.key, parseFloat(currencyPair.last))
+ }
+}
diff --git a/app/Watcher.js b/app/Watcher.js
new file mode 100644
index 0000000..1d25a02
--- /dev/null
+++ b/app/Watcher.js
@@ -0,0 +1,82 @@
+import {Emitter} from 'event-kit'
+import Immutable from 'immutable'
+import plnx from 'plnx'
+import rateLimit from 'function-rate-limit'
+
+export default class extends Emitter {
+ constructor() {
+ super()
+ this.throttle = rateLimit(6, 1000, (fn) => fn())
+ this.tickers = Immutable.Map()
+
+ plnx.returnTicker({}, (err, data) => {
+ Object.keys(data).forEach((key) => {
+ const d = data[key]
+ const [market, currency] = key.split('_', 2)
+ const ticker = {
+ key,
+ market,
+ currency,
+ name: `${currency} (${market})`,
+ last: parseFloat(d.last),
+ lowestAsk: parseFloat(d.lowestAsk),
+ highestBid: parseFloat(d.highestBid),
+ percentChange: parseFloat(d.percentChange),
+ baseVolume: parseFloat(d.baseVolume),
+ quoteVolume: parseFloat(d.quoteVolume),
+ updatedAt: Date.now(),
+ }
+ this.tickers = this.tickers.set(key, ticker)
+ })
+
+ plnx.push((session) => {
+ session.subscribe('ticker', (args) => {
+ const [key, last, lowestAsk, highestBid, percentChange, baseVolume, quoteVolume, isFrozen, dailyHigh, dailyLow] = args
+ const [market, currency] = key.split('_', 2)
+
+ const ticker = {
+ key,
+ market,
+ currency,
+ name: `${currency} (${market})`,
+ last: parseFloat(last),
+ lowestAsk: parseFloat(lowestAsk),
+ highestBid: parseFloat(highestBid),
+ percentChange: parseFloat(percentChange),
+ baseVolume: parseFloat(baseVolume),
+ quoteVolume: parseFloat(quoteVolume),
+ dailyHigh: parseFloat(dailyHigh),
+ dailyLow: parseFloat(dailyLow),
+ updatedAt: Date.now(),
+ }
+
+ this.tickers = this.tickers.set(key, ticker)
+ this.emit('ticker', ticker)
+ this.emit('tickers', this.tickers)
+ })
+ })
+ })
+ }
+
+ fetchChartData(currencyPair, period) {
+ return new Promise((resolve, reject) => {
+ const params = {
+ currencyPair,
+ start: Math.floor((Date.now() / 1000) - period),
+ end: Math.floor(Date.now() / 1000),
+ period: 300 // 300, 900, 1800, 7200, 14400, and 86400
+ }
+
+ this.throttle(() => {
+ plnx.returnChartData(params, (err, data) => {
+ if (err) { reject(err) }
+ resolve(data)
+ })
+ })
+ })
+ }
+
+ getTicker(currencyPair) {
+ return this.tickers.get(currencyPair)
+ }
+}
diff --git a/app/app.html b/app/app.html
new file mode 100644
index 0000000..6b9141e
--- /dev/null
+++ b/app/app.html
@@ -0,0 +1,27 @@
+
+
+
+
+ Poloniex
+
+
+
+
+
+
+
diff --git a/app/app.icns b/app/app.icns
new file mode 100644
index 0000000..453c210
Binary files /dev/null and b/app/app.icns differ
diff --git a/app/index.js b/app/index.js
new file mode 100644
index 0000000..1a027c6
--- /dev/null
+++ b/app/index.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import {render} from 'react-dom';
+import App from './App'
+import Watcher from './Watcher'
+import Settings from './Settings'
+import Trader from './Trader'
+
+const watcher = new Watcher()
+const settings = new Settings()
+const trader = new Trader(watcher, settings)
+
+render(, document.getElementById('root'));
diff --git a/main.development.js b/main.development.js
new file mode 100644
index 0000000..f2b95df
--- /dev/null
+++ b/main.development.js
@@ -0,0 +1,14 @@
+import menuBar from 'menubar'
+import Path from 'path'
+
+if (process.env.NODE_ENV === 'development') {
+ require('electron-debug')({showDevTools: true}) // eslint-disable-line global-require
+}
+
+menuBar({
+ index: `file://${__dirname}/app/app.html`,
+ icon: Path.join(__dirname, 'app/IconTemplate.png'),
+ preloadWindow: true,
+ width: 800,
+ height: 600
+})
diff --git a/package.js b/package.js
new file mode 100644
index 0000000..b29e7c9
--- /dev/null
+++ b/package.js
@@ -0,0 +1,129 @@
+/* eslint strict: 0, no-shadow: 0, no-unused-vars: 0, no-console: 0 */
+'use strict';
+
+require('babel-polyfill');
+const os = require('os');
+const webpack = require('webpack');
+const electronCfg = require('./webpack.config.electron.js');
+const cfg = require('./webpack.config.production.js');
+const packager = require('electron-packager');
+const del = require('del');
+const exec = require('child_process').exec;
+const argv = require('minimist')(process.argv.slice(2));
+const pkg = require('./package.json');
+const deps = Object.keys(pkg.dependencies);
+const devDeps = Object.keys(pkg.devDependencies);
+
+const appName = argv.name || argv.n || pkg.productName;
+const shouldUseAsar = argv.asar || argv.a || false;
+const shouldBuildAll = argv.all || false;
+
+const DEFAULT_OPTS = {
+ dir: './',
+ name: appName,
+ asar: shouldUseAsar,
+ ignore: [
+ '^/test($|/)',
+ '^/release($|/)',
+ '^/main.development.js'
+ ].concat(devDeps.map(name => `/node_modules/${name}($|/)`))
+ .concat(
+ deps.filter(name => !electronCfg.externals.includes(name))
+ .map(name => `/node_modules/${name}($|/)`)
+ )
+};
+
+const icon = argv.icon || argv.i || 'app/app';
+
+if (icon) {
+ DEFAULT_OPTS.icon = icon;
+}
+
+const version = argv.version || argv.v;
+
+if (version) {
+ DEFAULT_OPTS.version = version;
+ startPack();
+} else {
+ // use the same version as the currently-installed electron-prebuilt
+ exec('npm list electron-prebuilt --dev', (err, stdout) => {
+ if (err) {
+ DEFAULT_OPTS.version = '1.2.0';
+ } else {
+ DEFAULT_OPTS.version = stdout.split('electron-prebuilt@')[1].replace(/\s/g, '');
+ }
+
+ startPack();
+ });
+}
+
+
+function build(cfg) {
+ return new Promise((resolve, reject) => {
+ webpack(cfg, (err, stats) => {
+ if (err) return reject(err);
+ resolve(stats);
+ });
+ });
+}
+
+function startPack() {
+ console.log('start pack...');
+ build(electronCfg)
+ .then(() => build(cfg))
+ .then(() => del('release'))
+ .then(paths => {
+ if (shouldBuildAll) {
+ // build for all platforms
+ const archs = ['ia32', 'x64'];
+ const platforms = ['linux', 'win32', 'darwin'];
+
+ platforms.forEach(plat => {
+ archs.forEach(arch => {
+ pack(plat, arch, log(plat, arch));
+ });
+ });
+ } else {
+ // build for current platform only
+ pack(os.platform(), os.arch(), log(os.platform(), os.arch()));
+ }
+ })
+ .catch(err => {
+ console.error(err);
+ });
+}
+
+function pack(plat, arch, cb) {
+ // there is no darwin ia32 electron
+ if (plat === 'darwin' && arch === 'ia32') return;
+
+ const iconObj = {
+ icon: DEFAULT_OPTS.icon + (() => {
+ let extension = '.png';
+ if (plat === 'darwin') {
+ extension = '.icns';
+ } else if (plat === 'win32') {
+ extension = '.ico';
+ }
+ return extension;
+ })()
+ };
+
+ const opts = Object.assign({}, DEFAULT_OPTS, iconObj, {
+ platform: plat,
+ arch,
+ prune: true,
+ 'app-version': pkg.version || DEFAULT_OPTS.version,
+ out: `release/${plat}-${arch}`
+ });
+
+ packager(opts, cb);
+}
+
+
+function log(plat, arch) {
+ return (err, filepath) => {
+ if (err) return console.error(err);
+ console.log(`${plat}-${arch} finished!`);
+ };
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c75814b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,93 @@
+{
+ "name": "poloniex-watch",
+ "productName": "Poloniex Watch",
+ "version": "0.1.0",
+ "description": "Poloniex ticker on your Menu Bar",
+ "main": "main.js",
+ "scripts": {
+ "hot-server": "node -r babel-register server.js",
+ "build-main": "cross-env NODE_ENV=production node -r babel-register ./node_modules/webpack/bin/webpack --config webpack.config.electron.js --progress --profile --colors",
+ "build-renderer": "cross-env NODE_ENV=production node -r babel-register ./node_modules/webpack/bin/webpack --config webpack.config.production.js --progress --profile --colors",
+ "build": "npm run build-main && npm run build-renderer",
+ "start": "cross-env NODE_ENV=production electron ./",
+ "start-hot": "cross-env HOT=1 NODE_ENV=development electron -r babel-register -r babel-polyfill ./main.development",
+ "package": "cross-env NODE_ENV=production node -r babel-register package.js",
+ "package-all": "npm run package -- --all",
+ "release": "npm run build && npm run package",
+ "release-all": "npm run build && npm run package-all",
+ "dev": "concurrently --kill-others \"npm run hot-server\" \"npm run start-hot\""
+ },
+ "bin": {
+ "electron": "./node_modules/.bin/electron"
+ },
+ "dependencies": {
+ "plnx": "0.0.9",
+ "menubar": "^5.1.0",
+ "electron-debug": "^1.0.1",
+ "source-map-support": "^0.4.2"
+ },
+ "devDependencies": {
+ "asar": "^0.12.1",
+ "axios": "^0.13.1",
+ "babel-core": "^6.11.4",
+ "babel-loader": "^6.2.4",
+ "babel-plugin-add-module-exports": "^0.2.1",
+ "babel-plugin-dev-expression": "^0.2.1",
+ "babel-plugin-transform-remove-console": "^6.8.0",
+ "babel-plugin-transform-remove-debugger": "^6.8.0",
+ "babel-plugin-webpack-loaders": "^0.7.1",
+ "babel-polyfill": "^6.9.1",
+ "babel-preset-es2015": "^6.9.0",
+ "babel-preset-react": "^6.11.1",
+ "babel-preset-react-hmre": "^1.1.1",
+ "babel-preset-react-optimize": "^1.0.1",
+ "babel-preset-stage-0": "^6.5.0",
+ "babel-register": "^6.11.6",
+ "concurrently": "^2.2.0",
+ "cross-env": "^2.0.0",
+ "css-loader": "^0.23.1",
+ "css-modules-require-hook": "^4.0.1",
+ "d3": "^4.2.1",
+ "del": "^2.2.1",
+ "electron-packager": "^7.4.0",
+ "electron-prebuilt": "^1.3.2",
+ "electron-rebuild": "^1.1.5",
+ "eslint": "^3.2.2",
+ "eslint-config-airbnb": "^10.0.0",
+ "eslint-import-resolver-webpack": "^0.4.0",
+ "eslint-plugin-import": "^1.12.0",
+ "eslint-plugin-jsx-a11y": "^2.0.1",
+ "eslint-plugin-react": "^6.0.0",
+ "event-kit": "^2.0.0",
+ "express": "^4.14.0",
+ "extract-text-webpack-plugin": "^1.0.1",
+ "file-loader": "^0.9.0",
+ "font-awesome": "^4.6.3",
+ "function-rate-limit": "^1.1.0",
+ "json-loader": "^0.5.4",
+ "mathjs": "^3.3.0",
+ "minimist": "^1.2.0",
+ "node-libs-browser": "^1.0.0",
+ "node-sass": "^3.8.0",
+ "postcss": "^5.1.1",
+ "react": "^15.3.0",
+ "react-addons-pure-render-mixin": "^15.3.0",
+ "react-desktop": "^0.2.7",
+ "react-dom": "^15.3.0",
+ "react-loaders": "^2.1.1",
+ "react-sparklines": "^1.6.0",
+ "react-stockcharts": "^0.5.0",
+ "sass-loader": "^4.0.0",
+ "shortid": "^2.2.6",
+ "sinon": "^1.17.5",
+ "spectron": "^3.3.0",
+ "style-loader": "^0.13.1",
+ "thenby": "^1.2.1",
+ "underscore": "^1.8.3",
+ "url-loader": "^0.5.7",
+ "webpack": "^1.13.1",
+ "webpack-dev-middleware": "^1.6.1",
+ "webpack-hot-middleware": "^2.12.2",
+ "webpack-merge": "^0.14.1"
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..95156c0
--- /dev/null
+++ b/server.js
@@ -0,0 +1,40 @@
+/* eslint no-console: 0 */
+
+import express from 'express';
+import webpack from 'webpack';
+import webpackDevMiddleware from 'webpack-dev-middleware';
+import webpackHotMiddleware from 'webpack-hot-middleware';
+
+import config from './webpack.config.development';
+
+const app = express();
+const compiler = webpack(config);
+const PORT = 3000;
+
+const wdm = webpackDevMiddleware(compiler, {
+ publicPath: config.output.publicPath,
+ stats: {
+ colors: true
+ }
+});
+
+app.use(wdm);
+
+app.use(webpackHotMiddleware(compiler));
+
+const server = app.listen(PORT, 'localhost', err => {
+ if (err) {
+ console.error(err);
+ return;
+ }
+
+ console.log(`Listening at http://localhost:${PORT}`);
+});
+
+process.on('SIGTERM', () => {
+ console.log('Stopping dev server');
+ wdm.close();
+ server.close(() => {
+ process.exit(0);
+ });
+});
diff --git a/webpack.config.base.js b/webpack.config.base.js
new file mode 100644
index 0000000..1bf33c0
--- /dev/null
+++ b/webpack.config.base.js
@@ -0,0 +1,53 @@
+import path from 'path';
+
+export default {
+ module: {
+ loaders: [
+ {
+ test: /\.global\.scss$/,
+ loaders: [
+ 'style-loader',
+ 'css-loader?sourceMap',
+ 'sass'
+ ]
+ },
+ {
+ test: /^((?!\.global).)*\.scss$/,
+ loaders: [
+ 'style-loader',
+ 'css-loader?modules&sourceMap&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]',
+ 'sass'
+ ]
+ },
+ {
+ test: /\.jsx?$/,
+ loaders: ['babel-loader'],
+ exclude: /node_modules/
+ }, {
+ test: /\.json$/,
+ loader: 'json-loader'
+ },
+ {
+ test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
+ loader: 'url-loader?limit=10000&minetype=application/font-woff'
+ },
+ {
+ test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
+ loader: 'file-loader'
+ }
+ ]
+ },
+ output: {
+ path: path.join(__dirname, 'dist'),
+ filename: 'bundle.js',
+ libraryTarget: 'commonjs2'
+ },
+ resolve: {
+ extensions: ['', '.js', '.jsx', '.json'],
+ packageMains: ['webpack', 'browser', 'web', 'browserify', ['jam', 'main'], 'main']
+ },
+ plugins: [
+
+ ],
+ externals: ['plnx']
+};
diff --git a/webpack.config.development.js b/webpack.config.development.js
new file mode 100644
index 0000000..7212257
--- /dev/null
+++ b/webpack.config.development.js
@@ -0,0 +1,28 @@
+import webpack from 'webpack';
+import merge from 'webpack-merge';
+import baseConfig from './webpack.config.base';
+
+export default merge(baseConfig, {
+ debug: true,
+
+ devtool: 'cheap-module-eval-source-map',
+
+ entry: [
+ 'webpack-hot-middleware/client?path=http://localhost:3000/__webpack_hmr',
+ './app/index'
+ ],
+
+ output: {
+ publicPath: 'http://localhost:3000/dist/'
+ },
+
+ plugins: [
+ new webpack.HotModuleReplacementPlugin(),
+ new webpack.NoErrorsPlugin(),
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': JSON.stringify('development')
+ })
+ ],
+
+ target: 'electron-renderer'
+});
diff --git a/webpack.config.electron.js b/webpack.config.electron.js
new file mode 100644
index 0000000..34efead
--- /dev/null
+++ b/webpack.config.electron.js
@@ -0,0 +1,42 @@
+import webpack from 'webpack';
+import merge from 'webpack-merge';
+import baseConfig from './webpack.config.base';
+
+export default merge(baseConfig, {
+ devtool: 'source-map',
+
+ entry: ['babel-polyfill', './main.development'],
+
+ output: {
+ path: __dirname,
+ filename: './main.js'
+ },
+
+ plugins: [
+ new webpack.optimize.UglifyJsPlugin({
+ compressor: {
+ warnings: false
+ }
+ }),
+ new webpack.BannerPlugin(
+ 'require("source-map-support").install();',
+ { raw: true, entryOnly: false }
+ ),
+ new webpack.DefinePlugin({
+ 'process.env': {
+ NODE_ENV: JSON.stringify('production')
+ }
+ })
+ ],
+
+ target: 'electron-main',
+
+ node: {
+ __dirname: false,
+ __filename: false
+ },
+
+ externals: [
+ 'source-map-support'
+ ]
+});
diff --git a/webpack.config.node.js b/webpack.config.node.js
new file mode 100644
index 0000000..0cb39ea
--- /dev/null
+++ b/webpack.config.node.js
@@ -0,0 +1,8 @@
+require('babel-register');
+const devConfigs = require('./webpack.config.development');
+
+module.exports = {
+ output: {
+ libraryTarget: 'commonjs2'
+ }
+};
diff --git a/webpack.config.production.js b/webpack.config.production.js
new file mode 100644
index 0000000..8a2d266
--- /dev/null
+++ b/webpack.config.production.js
@@ -0,0 +1,32 @@
+import webpack from 'webpack';
+import ExtractTextPlugin from 'extract-text-webpack-plugin';
+import merge from 'webpack-merge';
+import baseConfig from './webpack.config.base';
+
+const config = merge(baseConfig, {
+ devtool: 'cheap-module-source-map',
+
+ entry: './app/index',
+
+ output: {
+ publicPath: '../dist/'
+ },
+
+ plugins: [
+ new webpack.optimize.OccurrenceOrderPlugin(),
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': JSON.stringify('production')
+ }),
+ new webpack.optimize.UglifyJsPlugin({
+ compressor: {
+ screw_ie8: true,
+ warnings: false
+ }
+ }),
+ new ExtractTextPlugin('style.css', { allChunks: true })
+ ],
+
+ target: 'electron-renderer'
+});
+
+export default config;