diff --git a/Makefile b/Makefile
index c4b02483..65d7a393 100644
--- a/Makefile
+++ b/Makefile
@@ -3,8 +3,9 @@ WHITE := $(shell tput -Txterm setaf 7)
YELLOW := $(shell tput -Txterm setaf 3)
RESET := $(shell tput -Txterm sgr0)
-STATIC_FOLDER?=themes\/react\/static
-TEMPLATE_FOLDER?=themes\/react\/templates
+THEME?=basic
+STATIC_FOLDER?=themes\/${THEME}\/static
+TEMPLATE_FOLDER?=themes\/${THEME}\/templates
.PHONY: \
all \
@@ -52,6 +53,15 @@ restart: ##@Service Restart service
setup: ##@Environment Setup dependency for service environment
bash scripts/setup.sh
+build-js: ##@Nodejs Build js files for react
+ bash scripts/build_reactjs.sh
+
+watch-mode: ##@Nodejs Run watch mode with js files for react
+ bash scripts/watch_mode.sh
+
+npm-install: ##@Nodejs Install modules with npm package management
+ bash scripts/npm_install.sh
+
HELP_FUN = \
%help; \
while(<>) { push @{$$help{$$2 // 'options'}}, [$$1, $$3] if /^([a-zA-Z\-]+)\s*:.*\#\#(?:@([a-zA-Z\-]+))?\s(.*)$$/ }; \
diff --git a/README.md b/README.md
index 1f090cf6..1c6e61fb 100644
--- a/README.md
+++ b/README.md
@@ -44,9 +44,10 @@ You can also find more [scenarios](docs/scenario.md).
* [Architecture Design](docs/arch.md)
* [Database Model](docs/db.md)
* [API](api/restserver_v2.md)
+* [Develop react js](docs/reactjs.md)
## Why named Cello?
Can u find anyone better at playing chains? :)
## License
-The Hyperledger Project uses the [Apache License Version 2.0](LICENSE) software license.
\ No newline at end of file
+The Hyperledger Project uses the [Apache License Version 2.0](LICENSE) software license.
diff --git a/docker-compose-build-js.yml b/docker-compose-build-js.yml
new file mode 100644
index 00000000..41de6b00
--- /dev/null
+++ b/docker-compose-build-js.yml
@@ -0,0 +1,16 @@
+# This compose file will deploy the services, and bootup a mongo server.
+# Local `/opt/cello/mongo` will be used for the db storage.
+# dashbard: dashbard service of cello, listen on 8080
+# app: app service of cello, listen on 80
+# nginx: front end
+# mongo: mongo db
+
+version: '2'
+services:
+ # cello dashbard service
+ build-js:
+ image: node
+ container_name: build-js
+ volumes: # This should be removed in product env
+ - ./src/themes/react/static:/app
+ command: bash -c "cd /app && npm run build"
diff --git a/docker-compose-npm-install.yml b/docker-compose-npm-install.yml
new file mode 100644
index 00000000..e411fafa
--- /dev/null
+++ b/docker-compose-npm-install.yml
@@ -0,0 +1,16 @@
+# This compose file will deploy the services, and bootup a mongo server.
+# Local `/opt/cello/mongo` will be used for the db storage.
+# dashbard: dashbard service of cello, listen on 8080
+# app: app service of cello, listen on 80
+# nginx: front end
+# mongo: mongo db
+
+version: '2'
+services:
+ # cello dashbard service
+ npm-install:
+ image: node
+ container_name: npm-install
+ volumes: # This should be removed in product env
+ - ./src/themes/react/static:/app
+ command: bash -c "cd /app && npm install --loglevel http"
diff --git a/docker-compose-watch-mode.yml b/docker-compose-watch-mode.yml
new file mode 100644
index 00000000..b11eb686
--- /dev/null
+++ b/docker-compose-watch-mode.yml
@@ -0,0 +1,16 @@
+# This compose file will deploy the services, and bootup a mongo server.
+# Local `/opt/cello/mongo` will be used for the db storage.
+# dashbard: dashbard service of cello, listen on 8080
+# app: app service of cello, listen on 80
+# nginx: front end
+# mongo: mongo db
+
+version: '2'
+services:
+ # cello dashbard service
+ watch-mode:
+ image: node
+ container_name: watch-mode
+ volumes: # This should be removed in product env
+ - ./src/themes/react/static:/app
+ command: bash -c "cd /app && npm run watch-mode"
diff --git a/docs/reactjs.md b/docs/reactjs.md
new file mode 100644
index 00000000..69ac5bb4
--- /dev/null
+++ b/docs/reactjs.md
@@ -0,0 +1,33 @@
+** **This is another Front-end implementation for cello dashboard, if you want to use this version, must change the theme into reactjs.
+
+How to start service with react theme?
+--------------------------------------
+
+```sh
+$ THEME=react make start
+```
+
+If you want to develop original js code for react, you must install node modules, and rebuild js after you change the js code.
+
+In the initialized state, must install node modules, the command is
+
+```sh
+$ make npm-install
+```
+
+If you want to add extra node modules, you need change the package.json file in src/themes/react/static directory, then rerun the command “make npm-install”.
+
+How to build react js?
+----------------------
+
+In the development phase
+
+```sh
+$ make watch-mode
+```
+
+In the production environment
+
+```sh
+$ make build-js
+```
\ No newline at end of file
diff --git a/scripts/build_reactjs.sh b/scripts/build_reactjs.sh
new file mode 100755
index 00000000..ab88f3f7
--- /dev/null
+++ b/scripts/build_reactjs.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# This script will (re)start all services.
+# It should be triggered at the upper directory, and safe to repeat.
+
+source scripts/header.sh
+
+echo_b "Start build react js files..."
+docker-compose -f docker-compose-build-js.yml up --no-recreate
+
+#echo "Restarting mongo_express"
+#[[ "$(docker ps -q --filter='name=mongo_express')" != "" ]] && docker restart mongo_express
\ No newline at end of file
diff --git a/scripts/npm_install.sh b/scripts/npm_install.sh
new file mode 100755
index 00000000..7d04f4a3
--- /dev/null
+++ b/scripts/npm_install.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# This script will (re)start all services.
+# It should be triggered at the upper directory, and safe to repeat.
+
+source scripts/header.sh
+
+echo_b "Start install npm packages..."
+docker-compose -f docker-compose-npm-install.yml up --no-recreate
+
+#echo "Restarting mongo_express"
+#[[ "$(docker ps -q --filter='name=mongo_express')" != "" ]] && docker restart mongo_express
\ No newline at end of file
diff --git a/scripts/watch_mode.sh b/scripts/watch_mode.sh
new file mode 100755
index 00000000..36c9f025
--- /dev/null
+++ b/scripts/watch_mode.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+# This script will (re)start all services.
+# It should be triggered at the upper directory, and safe to repeat.
+
+source scripts/header.sh
+
+echo_b "Run watch mode for react js files..."
+docker-compose -f docker-compose-watch-mode.yml up --no-recreate
+
+#echo "Restarting mongo_express"
+#[[ "$(docker ps -q --filter='name=mongo_express')" != "" ]] && docker restart mongo_express
\ No newline at end of file
diff --git a/src/themes/react/static/img/favicon.ico b/src/themes/react/static/img/favicon.ico
new file mode 100644
index 00000000..c6d2e93f
Binary files /dev/null and b/src/themes/react/static/img/favicon.ico differ
diff --git a/src/themes/react/static/js/components/layout/bread.js b/src/themes/react/static/js/components/layout/bread.js
new file mode 100644
index 00000000..1397892c
--- /dev/null
+++ b/src/themes/react/static/js/components/layout/bread.js
@@ -0,0 +1,62 @@
+import React, { PropTypes } from 'react'
+import { Breadcrumb, Icon } from 'antd'
+import styles from './main.less'
+import { menu } from '../../utils'
+
+let pathSet = []
+const getPathSet = function (menuArray, parentPath) {
+ parentPath = parentPath || '/'
+ menuArray.map(item => {
+ pathSet[(parentPath + item.key).replace(/\//g, '-').hyphenToHump()] = {
+ path: parentPath + item.key,
+ name: item.name,
+ icon: item.icon || '',
+ clickable: item.clickable === undefined
+ }
+ if (item.child) {
+ getPathSet(item.child, parentPath + item.key + '/')
+ }
+ })
+}
+getPathSet(menu)
+
+function Bread ({ location }) {
+ let pathNames = []
+ location.pathname.substr(1).split('/').map((item, key) => {
+ if (key > 0) {
+ pathNames.push((pathNames[key - 1] + '-' + item).hyphenToHump())
+ } else {
+ pathNames.push(('-' + item).hyphenToHump())
+ }
+ })
+ const breads = pathNames.map((item, key) => {
+ if (!(item in pathSet)) {
+ item = 'Overview'
+ }
+ return (
+
+ {pathSet[item].icon
+ ?
+ : ''}
+ {pathSet[item].name}
+
+ )
+ })
+
+ return (
+
+
+
+ Home
+
+ {breads}
+
+
+ )
+}
+
+Bread.propTypes = {
+ location: PropTypes.object
+}
+
+export default Bread
diff --git a/src/themes/react/static/js/components/layout/common.less b/src/themes/react/static/js/components/layout/common.less
new file mode 100644
index 00000000..8c56a010
--- /dev/null
+++ b/src/themes/react/static/js/components/layout/common.less
@@ -0,0 +1,102 @@
+body {
+ height: 100%;
+ overflow-y: hidden;
+ background-color: #f8f8f8;
+}
+// scrollbar
+
+::-webkit-scrollbar-corner {
+ background-color: transparent;
+}
+
+::-webkit-scrollbar-button {
+ width: 0;
+ height: 0;
+ display: none;
+}
+
+::-webkit-scrollbar-thumb {
+ width: 2px;
+ background-color: rgba(0,0,0,.2);
+ border-radius: 2px;
+}
+
+::-webkit-scrollbar {
+ width: 2px;
+ height: 2px;
+}
+
+::-webkit-scrollbar-track {
+ width: 5px;
+}
+
+::-webkit-scrollbar:hover {
+ background-color: transparent;
+}
+
+:global .ant-breadcrumb {
+ & > span {
+ &:last-child {
+ color: #999;
+ font-weight: normal;
+ }
+ }
+}
+
+:global .ant-breadcrumb-link {
+ .anticon + span {
+ margin-left: 4px;
+ }
+}
+
+:global .ant-table {
+ .ant-table-thead > tr > th {
+ text-align: center;
+ }
+
+ .ant-table-tbody > tr > td {
+ text-align: center;
+ }
+
+ &.ant-table-small {
+ .ant-table-thead > tr > th {
+ background: #f7f7f7;
+ }
+
+ .ant-table-body > table {
+ padding: 0;
+ }
+ }
+}
+
+:global .ant-table-pagination {
+ float: none!important;
+ display: table;
+ margin: 16px auto !important;
+}
+
+:global .ant-popover-inner {
+ border: none;
+ border-radius: 0;
+ box-shadow: 0 0 20px rgba(100, 100, 100, 0.2);
+}
+
+:global .vertical-center-modal {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .ant-modal {
+ top: 0;
+
+ .ant-modal-body {
+ max-height: 500px;
+ overflow-y: auto;
+ }
+ }
+}
+
+:global .ant-form-item-control {
+ vertical-align: middle;
+}
+@import "../skin.less";
diff --git a/src/themes/react/static/js/components/layout/footer.js b/src/themes/react/static/js/components/layout/footer.js
new file mode 100644
index 00000000..5b7332e3
--- /dev/null
+++ b/src/themes/react/static/js/components/layout/footer.js
@@ -0,0 +1,9 @@
+import React from 'react'
+import styles from './main.less'
+import { config } from '../../utils'
+
+const Footer = () =>
+ {config.footerText}
+
+
+export default Footer
diff --git a/src/themes/react/static/js/components/layout/header.js b/src/themes/react/static/js/components/layout/header.js
new file mode 100644
index 00000000..0c2c38b1
--- /dev/null
+++ b/src/themes/react/static/js/components/layout/header.js
@@ -0,0 +1,38 @@
+import React from 'react'
+import { Menu, Icon, Popover } from 'antd'
+import styles from './main.less'
+import Menus from './menu'
+
+const SubMenu = Menu.SubMenu
+
+function Header ({user, switchSider, siderFold, isNavbar, menuPopoverVisible, location, switchMenuPopover}) {
+ const menusProps = {
+ siderFold: false,
+ darkTheme: false,
+ isNavbar,
+ handleClickNavMenu: switchMenuPopover,
+ location
+ }
+ return (
+
+ {isNavbar
+ ?
}>
+
+
+
+
+ :
+
+
}
+
+
+
+
+
+
+ )
+}
+
+export default Header
diff --git a/src/themes/react/static/js/components/layout/main.less b/src/themes/react/static/js/components/layout/main.less
new file mode 100644
index 00000000..2d5a23a5
--- /dev/null
+++ b/src/themes/react/static/js/components/layout/main.less
@@ -0,0 +1,301 @@
+@import "../vars.less";
+
+.layout {
+ position: relative;
+ height: 100vh;
+
+ &.withnavbar {
+ .main {
+ margin-left: 0;
+ }
+ }
+
+ &.fold {
+ .sider {
+ width: 42px;
+
+ .logo {
+ img {
+ width: 28px;
+ margin: 6px 7px;
+ }
+ }
+
+ :global {
+ .ant-menu-root {
+ width: 100%;
+
+ & > .ant-menu-item {
+ padding: 0;
+ text-align: center;
+
+ .anticon {
+ font-size: 14px;
+ margin-right: 0;
+ }
+ }
+
+ & > .ant-menu-submenu {
+ & > .ant-menu-submenu-title {
+ padding: 0;
+ text-align: center;
+
+ .anticon {
+ font-size: 14px;
+ margin-right: 0;
+ }
+
+ &::after {
+ display: none;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .main {
+ margin-left: 42px;
+ }
+ }
+
+ .sider {
+ width: 224px;
+ background: #3e3e3e;
+ position: absolute;
+ overflow: visible;
+ padding-bottom: 24px;
+ height: 100vh;
+ transition: @transition-ease-out;
+ box-shadow: @shadow-1;
+ z-index: 1994;
+ color: #999;
+
+ &.light {
+ background: #fff;
+
+ .switchtheme {
+ background: #fff;
+ border-top: solid 1px #f8f8f8;
+ }
+ }
+
+ .logo {
+ text-align: center;
+ height: 40px;
+ line-height: 40px;
+ cursor: pointer;
+ margin: 28px 0;
+ transition: @transition-ease-out;
+ overflow: hidden;
+
+ img {
+ width: 40px;
+ margin-right: 8px;
+ transition: @transition-ease-out;
+ }
+
+ span {
+ vertical-align: text-bottom;
+ font-size: 16px;
+ text-transform: uppercase;
+ display: inline-block;
+ }
+
+ .anticon {
+ transition: @transition-ease-out;
+ }
+ }
+
+ .switchtheme {
+ width: 100%;
+ position: absolute;
+ bottom: 0;
+ height: 48px;
+ background-color: @dark-half;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 16px 0 24px;
+ overflow: hidden;
+ z-index: 1000;
+
+ span {
+ white-space: nowrap;
+ overflow: hidden;
+ }
+
+ :global {
+ .anticon {
+ min-width: 14px;
+ margin-right: 8px;
+ font-size: 14px;
+ }
+ }
+ }
+
+ :global {
+ .ant-menu-dark,
+ .ant-menu-dark .ant-menu-sub {
+ color: #999;
+ }
+
+ .ant-menu {
+ transition: @transition-ease-out;
+
+ .ant-menu-item,
+ .ant-menu-submenu-title {
+ overflow: auto;
+ white-space: normal;
+ }
+ }
+ }
+ }
+
+ .header {
+ :global .ant-menu-horizontal {
+ & > .ant-menu-submenu {
+ float: right;
+ }
+ border: none;
+ }
+ box-shadow: @shadow-2;
+ position: relative;
+
+ .siderbutton {
+ height: 47px;
+ width: 47px;
+ line-height: 47px;
+ text-align: center;
+ font-size: 18px;
+ cursor: pointer;
+ position: absolute;
+ transition: @transition-ease-in;
+
+ &:hover {
+ color: @primary-color;
+ background-color: fade(@primary-color, 15%);
+ }
+ }
+ }
+
+ .bread {
+ height: 64px;
+ line-height: 64px;
+ padding: 0 24px;
+ margin-bottom: -24px;
+ }
+
+ .main {
+ margin-left: 224px;
+ overflow: auto;
+ height: 100vh;
+ transition: @transition-ease-out;
+
+ .container {
+ margin: 24px;
+
+ .content {
+ min-height: e("calc(100vh - 184px)");
+ position: relative;
+
+ :global .content-inner {
+ background: #fff;
+ padding: 24px;
+ box-shadow: @shadow-1;
+ min-height: e("calc(100vh - 184px)");
+ }
+ }
+ }
+ }
+
+ .footer {
+ height: 48px;
+ line-height: 48px;
+ text-align: center;
+ font-size: 12px;
+ color: #999;
+ background: #fff;
+ box-shadow: @shadow-2;
+ width: 100%;
+ }
+}
+
+:global .ant-menu-dark {
+ color: #999;
+ background-color: #3e3e3e;
+
+ .ant-menu-submenu-title:hover,
+ .ant-menu-submenu:hover {
+ color: @primary-color;
+ }
+
+ .ant-menu-sub {
+ color: #999;
+ }
+
+ .ant-menu-submenu-selected {
+ color: @primary-color;
+
+ &:not(.ant-menu-submenu-open) {
+ background-color: @dark-half;
+ }
+ }
+
+ .ant-menu-item,
+ .ant-menu-submenu-title {
+ & > a {
+ color: #999;
+ }
+
+ &:hover > a {
+ color: @primary-color;
+ }
+ }
+
+ &.ant-menu-inline {
+ .ant-menu-item-selected {
+ background-color: @dark-half;
+ }
+ }
+
+ .ant-menu-item-selected {
+ color: @primary-color;
+ background-color: @dark-half;
+
+ & > a {
+ color: @primary-color;
+ }
+
+ &:hover {
+ background-color: @dark-half;
+ }
+ }
+}
+
+.popovermenu {
+ width: 280px;
+ left: 12px!important;
+
+ :global .ant-popover-inner-content {
+ padding: 0;
+
+ .ant-menu-inline .ant-menu-item,
+ .ant-menu-vertical .ant-menu-item {
+ border-right: 0;
+ height: 48px;
+ line-height: 48px;
+ }
+
+ .ant-menu-inline .ant-menu-item-selected,
+ .ant-menu-inline .ant-menu-selected {
+ border-right: 0;
+ }
+ }
+}
+
+.spin {
+ :global .ant-spin-container {
+ height: 100vh;
+ }
+}
diff --git a/src/themes/react/static/js/components/layout/menu.js b/src/themes/react/static/js/components/layout/menu.js
new file mode 100644
index 00000000..9344ccdb
--- /dev/null
+++ b/src/themes/react/static/js/components/layout/menu.js
@@ -0,0 +1,43 @@
+import React from 'react'
+import { Menu, Icon } from 'antd'
+import { Link } from 'dva/router'
+import { menu } from '../../utils'
+
+const topMenus = menu.map(item => item.key)
+const getMenus = function (menuArray, siderFold, parentPath) {
+ parentPath = parentPath || '/'
+ return menuArray.map(item => {
+ if (item.child) {
+ return (
+ {item.icon ? : ''}{siderFold && topMenus.indexOf(item.key) >= 0 ? '' : item.name}}>
+ {getMenus(item.child, siderFold, parentPath + item.key + '/')}
+
+ )
+ } else {
+ return (
+
+
+ {item.icon ? : ''}
+ {siderFold && topMenus.indexOf(item.key) >= 0 ? '' : item.name}
+
+
+ )
+ }
+ })
+}
+
+function Menus ({ siderFold, darkTheme, location, isNavbar, handleClickNavMenu }) {
+ const menuItems = getMenus(menu, siderFold)
+ return (
+ item.key) : []}
+ defaultSelectedKeys={[location.pathname.split('/')[location.pathname.split('/').length - 1] || 'dashboard']}>
+ {menuItems}
+
+ )
+}
+
+export default Menus
diff --git a/src/themes/react/static/js/components/layout/sider.js b/src/themes/react/static/js/components/layout/sider.js
new file mode 100644
index 00000000..083cc37b
--- /dev/null
+++ b/src/themes/react/static/js/components/layout/sider.js
@@ -0,0 +1,27 @@
+import React from 'react'
+import { Icon, Switch } from 'antd'
+import styles from './main.less'
+import { config } from '../../utils'
+import Menus from './menu'
+
+function Sider ({ siderFold, darkTheme, location, changeTheme }) {
+ const menusProps = {
+ siderFold,
+ darkTheme,
+ location
+ }
+ return (
+
+
+ {siderFold ? '' : {config.logoText} }
+
+
+ {!siderFold ?
+ Theme
+
+
: ''}
+
+ )
+}
+
+export default Sider
diff --git a/src/themes/react/static/js/components/skin.less b/src/themes/react/static/js/components/skin.less
new file mode 100644
index 00000000..d6db52e6
--- /dev/null
+++ b/src/themes/react/static/js/components/skin.less
@@ -0,0 +1,21 @@
+@import "./vars.less";
+
+:global {
+ .ant-modal-mask {
+ background-color: rgba(55, 55, 55, 0.2);
+ }
+
+ .ant-modal-content {
+ box-shadow: none;
+ }
+
+ .ant-card {
+ &:hover {
+ overflow: auto;
+ }
+ }
+
+ .ant-pagination-item {
+ margin-bottom: 8px;
+ }
+}
diff --git a/src/themes/react/static/js/components/vars.less b/src/themes/react/static/js/components/vars.less
new file mode 100644
index 00000000..db294191
--- /dev/null
+++ b/src/themes/react/static/js/components/vars.less
@@ -0,0 +1,188 @@
+@import "../../node_modules/antd/lib/style/themes/default.less"; // // Prefix
+// @ant-prefix : ant;
+//
+// // Color
+// @primary-color : #2db7f5;
+// @info-color : #2db7f5;
+// @success-color : #87d068;
+// @error-color : #f50;
+// @warning-color : #fa0;
+// @normal-color : #d9d9d9;
+//
+// // ------ Base & Require ------
+// @body-background : #fff;
+// @font-family : -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", SimSun, sans-serif;
+// @code-family : Consolas,Menlo,Courier,monospace;
+// @text-color : #666;
+// @heading-color : #404040;
+// @font-size-base : 12px;
+// @line-height-base : 1.5;
+// @line-height-computed : floor((@font-size-base * @line-height-base));
+// @border-radius-base : 6px;
+// @border-radius-sm : 4px;
+//
+// // ICONFONT
+// @iconfont-css-prefix : anticon;
+// @icon-url : "https://at.alicdn.com/t/font_1473840929_824008";
+//
+// // LINK
+// @link-color : #2db7f5;
+// @link-hover-color : tint(@link-color, 20%);
+// @link-active-color : shade(@link-color, 5%);
+// @link-hover-decoration : none;
+//
+// // Disabled cursor for form controls and buttons.
+// @cursor-disabled : not-allowed;
+//
+// // Animation
+// @ease-out : cubic-bezier(0.215, 0.61, 0.355, 1);
+// @ease-in : cubic-bezier(0.55, 0.055, 0.675, 0.19);
+// @ease-in-out : cubic-bezier(0.645, 0.045, 0.355, 1);
+// @ease-out-back : cubic-bezier(0.12, 0.4, 0.29, 1.46);
+// @ease-in-back : cubic-bezier(0.71, -0.46, 0.88, 0.6);
+// @ease-in-out-back : cubic-bezier(0.71, -0.46, 0.29, 1.46);
+// @ease-out-circ : cubic-bezier(0.08, 0.82, 0.17, 1);
+// @ease-in-circ : cubic-bezier(0.6, 0.04, 0.98, 0.34);
+// @ease-in-out-circ : cubic-bezier(0.78, 0.14, 0.15, 0.86);
+// @ease-out-quint : cubic-bezier(0.23, 1, 0.32, 1);
+// @ease-in-quint : cubic-bezier(0.755, 0.05, 0.855, 0.06);
+// @ease-in-out-quint : cubic-bezier(0.86, 0, 0.07, 1);
+//
+// // Border color
+// @border-color-base : #d9d9d9; // base border outline a component
+// @border-color-split : #e9e9e9; // split border inside a component
+//
+// // Outline
+// @outline-blur-size : 0;
+// @outline-width : 2px;
+// @outline-color : @primary-color;
+//
+// // Background color
+// @background-color-base : #f7f7f7; // basic gray background
+//
+// // Shadow
+// @shadow-color : rgba(100, 100, 100, .2);
+// @box-shadow-base : @shadow-1-down;
+// @shadow-1-up : 0 -1px 6px @shadow-color;
+// @shadow-1-down : 0 1px 6px @shadow-color;
+// @shadow-1-left : -1px 0 6px @shadow-color;
+// @shadow-1-right : 1px 0 6px @shadow-color;
+// @shadow-2 : 0 1px 8px @shadow-color;
+//
+// // Buttons
+// @btn-font-weight : 500;
+// @btn-border-radius-base : @border-radius-base;
+// @btn-border-radius-sm : @border-radius-sm;
+//
+// @btn-primary-color : #fff;
+// @btn-primary-bg : @primary-color;
+// @btn-group-border : shade(@primary-color, 5%);
+//
+// @btn-default-color : @text-color;
+// @btn-default-bg : @background-color-base;
+// @btn-default-border : @border-color-base;
+//
+// @btn-ghost-color : @text-color;
+// @btn-ghost-bg : transparent;
+// @btn-ghost-border : @border-color-base;
+//
+// @btn-disable-color : #ccc;
+// @btn-disable-bg : @background-color-base;
+// @btn-disable-border : @border-color-base;
+//
+// @btn-padding-base : 4px 15px;
+//
+// @btn-font-size-lg : 14px;
+// @btn-padding-lg : 4px 15px 5px 15px;
+//
+// @btn-padding-sm : 1px 7px;
+//
+// @btn-circle-size : 28px;
+// @btn-circle-size-lg : 32px;
+// @btn-circle-size-sm : 22px;
+//
+// // Media queries breakpoints
+// // Extra small screen / phone
+// @screen-xs : 480px;
+// @screen-xs-min : @screen-xs;
+// @screen-xs-max : (@screen-xs-min - 1);
+//
+// // Small screen / tablet
+// @screen-sm : 768px;
+// @screen-sm-min : @screen-sm;
+// @screen-sm-max : (@screen-sm-min - 1);
+//
+// // Medium screen / desktop
+// @screen-md : 992px;
+// @screen-md-min : @screen-md;
+// @screen-md-max : (@screen-md-min - 1);
+//
+// // Large screen / wide desktop
+// @screen-lg : 1200px;
+// @screen-lg-min : @screen-lg;
+// @screen-lg-max : (@screen-lg-min - 1);
+//
+// // Layout and Grid system
+// @grid-columns : 24;
+// @grid-gutter-width : 0;
+//
+// // Container sizes
+// @container-sm : (720px + @grid-gutter-width);
+// @container-md : (940px + @grid-gutter-width);
+// @container-lg : (1140px + @grid-gutter-width);
+//
+// // z-index list
+// @zindex-affix : 10;
+// @zindex-back-top : 10;
+// @zindex-modal-mask : 1000;
+// @zindex-modal : 1000;
+// @zindex-notification : 1010;
+// @zindex-message : 1010;
+// @zindex-popover : 1030;
+// @zindex-picker : 1050;
+// @zindex-dropdown : 1050;
+// @zindex-tooltip : 1060;
+//
+// // Form
+// // --------------------------------
+// // Legend
+// @legend-color : #999;
+// @legend-border-color : @border-color-base;
+// // Label
+// @label-required-color : #f50;
+// @label-color : @text-color;
+// // Input
+// @input-height-base: 28px;
+// @input-height-lg: 32px;
+// @input-height-sm: 22px;
+//
+// @input-padding-horizontal : 7px;
+// @input-padding-vertical-base : 4px;
+// @input-padding-vertical-sm : 1px;
+// @input-padding-vertical-lg : 6px;
+//
+// @input-placeholder-color : #ccc;
+// @input-color : @text-color;
+// @input-border-color : @border-color-base;
+// @input-bg : #fff;
+//
+// @input-hover-border-color : @primary-color;
+// @input-disabled-bg : @background-color-base;
+//
+// @form-item-margin-bottom : 24px;
+/*========================*/
+/*=======Antd Admin=======*/
+/*========================*/
+//Color
+@dark-half: #494949;
+@purple: #d897eb; // Shadow
+@shadow-1: 4px 4px 20px 0 rgba(0,0,0,0.01);
+@shadow-2: 4px 4px 40px 0 rgba(0,0,0,0.05); //transition
+@transition-ease-in: all 0.3s @ease-in;
+@transition-ease-out: all 0.3s @ease-out; //mix
+
+.text-overflow() {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/src/themes/react/static/js/index.js b/src/themes/react/static/js/index.js
new file mode 100644
index 00000000..cd42a915
--- /dev/null
+++ b/src/themes/react/static/js/index.js
@@ -0,0 +1,14 @@
+import dva from 'dva'
+
+// 1. Initialize
+const app = dva()
+
+// 2. Model
+
+app.model(require('./models/app'))
+
+// 3. Router
+app.router(require('./router'))
+
+// 4. Start
+app.start('#root')
diff --git a/src/themes/react/static/js/models/app.js b/src/themes/react/static/js/models/app.js
new file mode 100644
index 00000000..b9b9d1ba
--- /dev/null
+++ b/src/themes/react/static/js/models/app.js
@@ -0,0 +1,102 @@
+import {parse} from 'qs'
+
+export default {
+ namespace: 'app',
+ state: {
+ login: false,
+ loading: false,
+ user: {
+ name: 'admin'
+ },
+ loginButtonLoading: false,
+ menuPopoverVisible: false,
+ siderFold: localStorage.getItem('antdAdminSiderFold') === 'true',
+ darkTheme: localStorage.getItem('antdAdminDarkTheme') !== 'false',
+ isNavbar: document.body.clientWidth < 769
+ },
+ subscriptions: {
+ setup ({dispatch}) {
+ window.onresize = function () {
+ dispatch({type: 'changeNavbar'})
+ }
+ }
+ },
+ effects: {
+ *switchSider ({
+ payload
+ }, {put}) {
+ yield put({
+ type: 'handleSwitchSider'
+ })
+ },
+ *changeTheme ({
+ payload
+ }, {put}) {
+ yield put({
+ type: 'handleChangeTheme'
+ })
+ },
+ *changeNavbar ({
+ payload
+ }, {put}) {
+ if (document.body.clientWidth < 769) {
+ yield put({type: 'showNavbar'})
+ } else {
+ yield put({type: 'hideNavbar'})
+ }
+ },
+ *switchMenuPopver ({
+ payload
+ }, {put}) {
+ yield put({
+ type: 'handleSwitchMenuPopver'
+ })
+ }
+ },
+ reducers: {
+ showLoading (state) {
+ return {
+ ...state,
+ loading: true
+ }
+ },
+ hideLoading (state) {
+ return {
+ ...state,
+ loading: false
+ }
+ },
+ handleSwitchSider (state) {
+ localStorage.setItem('antdAdminSiderFold', !state.siderFold)
+ return {
+ ...state,
+ siderFold: !state.siderFold
+ }
+ },
+ handleChangeTheme (state) {
+ localStorage.setItem('antdAdminDarkTheme', !state.darkTheme)
+ return {
+ ...state,
+ darkTheme: !state.darkTheme
+ }
+ },
+ showNavbar (state) {
+ return {
+ ...state,
+ isNavbar: true
+ }
+ },
+ hideNavbar (state) {
+ return {
+ ...state,
+ isNavbar: false
+ }
+ },
+ handleSwitchMenuPopver (state) {
+ return {
+ ...state,
+ menuPopoverVisible: !state.menuPopoverVisible
+ }
+ }
+ }
+}
diff --git a/src/themes/react/static/js/router.js b/src/themes/react/static/js/router.js
new file mode 100644
index 00000000..7cc7f35a
--- /dev/null
+++ b/src/themes/react/static/js/router.js
@@ -0,0 +1,78 @@
+import React from 'react'
+import {Router} from 'dva/router'
+import App from './routes/app'
+
+export default function ({history, app}) {
+ const routes = [
+ {
+ path: '/',
+ component: App,
+ getIndexRoute (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, {component: require('./routes/overview')})
+ })
+ },
+ childRoutes: [
+ {
+ path: 'overview',
+ name: 'overview',
+ getComponent (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, require('./routes/overview'))
+ })
+ }
+ }, {
+ path: 'hosts',
+ name: 'hosts',
+ getComponent (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, require('./routes/hosts'))
+ })
+ }
+ }, {
+ path: 'chains/active',
+ name: 'chains/active',
+ getComponent (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, require('./routes/chains/active'))
+ })
+ }
+ }, {
+ path: 'chains/inused',
+ name: 'chains/inused',
+ getComponent (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, require('./routes/chains/inused'))
+ })
+ }
+ }, {
+ path: 'release',
+ name: 'release',
+ getComponent (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, require("./routes/release"))
+ })
+ }
+ }, {
+ path: 'about',
+ name: 'about',
+ getComponent (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, require("./routes/about"))
+ })
+ }
+ }, {
+ path: '*',
+ name: 'error',
+ getComponent (nextState, cb) {
+ require.ensure([], require => {
+ cb(null, require('./routes/error'))
+ })
+ }
+ }
+ ]
+ }
+ ]
+
+ return
+}
diff --git a/src/themes/react/static/js/routes/about.js b/src/themes/react/static/js/routes/about.js
new file mode 100644
index 00000000..fba66f05
--- /dev/null
+++ b/src/themes/react/static/js/routes/about.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+const About = () =>
+
+export default About
diff --git a/src/themes/react/static/js/routes/app.js b/src/themes/react/static/js/routes/app.js
new file mode 100644
index 00000000..7fe79500
--- /dev/null
+++ b/src/themes/react/static/js/routes/app.js
@@ -0,0 +1,68 @@
+import React, { PropTypes } from 'react'
+import { connect } from 'dva'
+import Header from '../components/layout/header'
+import Bread from '../components/layout/bread'
+import Footer from '../components/layout/footer'
+import Sider from '../components/layout/sider'
+import styles from '../components/layout/main.less'
+import { Spin } from 'antd'
+import { classnames } from '../utils'
+import '../components/layout/common.less'
+
+function App ({children, location, dispatch, app}) {
+ const {user, siderFold, darkTheme, isNavbar, menuPopoverVisible} = app
+
+ const headerProps = {
+ user,
+ siderFold,
+ location,
+ isNavbar,
+ menuPopoverVisible,
+ switchMenuPopover () {
+ dispatch({type: 'app/switchMenuPopver'})
+ },
+ switchSider () {
+ dispatch({type: 'app/switchSider'})
+ }
+ }
+
+ const siderProps = {
+ siderFold,
+ darkTheme,
+ location,
+ changeTheme () {
+ dispatch({type: 'app/changeTheme'})
+ }
+ }
+
+ return (
+
+ )
+}
+
+App.propTypes = {
+ children: PropTypes.element.isRequired,
+ location: PropTypes.object,
+ dispatch: PropTypes.func,
+ user: PropTypes.object,
+ siderFold: PropTypes.bool,
+ darkTheme: PropTypes.bool
+}
+
+export default connect(({app}) => ({app}))(App)
diff --git a/src/themes/react/static/js/routes/chains/active.js b/src/themes/react/static/js/routes/chains/active.js
new file mode 100644
index 00000000..5ec9220b
--- /dev/null
+++ b/src/themes/react/static/js/routes/chains/active.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+const Active = () =>
+
+export default Active
diff --git a/src/themes/react/static/js/routes/chains/inused.js b/src/themes/react/static/js/routes/chains/inused.js
new file mode 100644
index 00000000..9e24bc6a
--- /dev/null
+++ b/src/themes/react/static/js/routes/chains/inused.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+const Inused = () =>
+
+export default Inused
diff --git a/src/themes/react/static/js/routes/error.js b/src/themes/react/static/js/routes/error.js
new file mode 100644
index 00000000..52854786
--- /dev/null
+++ b/src/themes/react/static/js/routes/error.js
@@ -0,0 +1,12 @@
+import React from 'react'
+import {Icon} from 'antd'
+import styles from './error.less'
+
+const Error = () =>
+
+export default Error
diff --git a/src/themes/react/static/js/routes/error.less b/src/themes/react/static/js/routes/error.less
new file mode 100644
index 00000000..f5300b1f
--- /dev/null
+++ b/src/themes/react/static/js/routes/error.less
@@ -0,0 +1,19 @@
+.error {
+ color: black;
+ text-align: center;
+ position: absolute;
+ top: 30%;
+ margin-top: -50px;
+ left: 50%;
+ margin-left: -100px;
+ width: 200px;
+
+ :global .anticon {
+ font-size: 48px;
+ margin-bottom: 16px;
+ }
+
+ h1 {
+ font-family: cursive;
+ }
+}
diff --git a/src/themes/react/static/js/routes/hosts.js b/src/themes/react/static/js/routes/hosts.js
new file mode 100644
index 00000000..63b58211
--- /dev/null
+++ b/src/themes/react/static/js/routes/hosts.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+const Hosts = () =>
+
+export default Hosts
diff --git a/src/themes/react/static/js/routes/overview.js b/src/themes/react/static/js/routes/overview.js
new file mode 100644
index 00000000..00a5c80f
--- /dev/null
+++ b/src/themes/react/static/js/routes/overview.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+const Overview = () =>
+
+export default Overview
diff --git a/src/themes/react/static/js/routes/release.js b/src/themes/react/static/js/routes/release.js
new file mode 100644
index 00000000..3a98f35b
--- /dev/null
+++ b/src/themes/react/static/js/routes/release.js
@@ -0,0 +1,9 @@
+import React from 'react'
+
+const Release = () =>
+
+export default Release
diff --git a/src/themes/react/static/js/routes/users.js b/src/themes/react/static/js/routes/users.js
new file mode 100644
index 00000000..b4be09b4
--- /dev/null
+++ b/src/themes/react/static/js/routes/users.js
@@ -0,0 +1,100 @@
+import React, { PropTypes } from 'react'
+import { routerRedux } from 'dva/router'
+import { connect } from 'dva'
+import UserList from '../components/users/list'
+import UserSearch from '../components/users/search'
+import UserModal from '../components/users/modal'
+
+function Users ({ location, dispatch, users }) {
+ const { loading, list, pagination, currentItem, modalVisible, modalType } = users
+ const { field, keyword } = location.query
+
+ const userModalProps = {
+ item: modalType === 'create' ? {} : currentItem,
+ type: modalType,
+ visible: modalVisible,
+ onOk (data) {
+ dispatch({
+ type: `users/${modalType}`,
+ payload: data
+ })
+ },
+ onCancel () {
+ dispatch({
+ type: 'users/hideModal'
+ })
+ }
+ }
+
+ const userListProps = {
+ dataSource: list,
+ loading,
+ pagination: pagination,
+ onPageChange (page) {
+ dispatch(routerRedux.push({
+ pathname: '/users',
+ query: {
+ page: page.current,
+ pageSize: page.pageSize
+ }
+ }))
+ },
+ onDeleteItem (id) {
+ dispatch({
+ type: 'users/delete',
+ payload: id
+ })
+ },
+ onEditItem (item) {
+ dispatch({
+ type: 'users/showModal',
+ payload: {
+ modalType: 'update',
+ currentItem: item
+ }
+ })
+ }
+ }
+
+ const userSearchProps = {
+ field,
+ keyword,
+ onSearch (fieldsValue) {
+ dispatch({
+ type: 'users/query',
+ payload: fieldsValue
+ })
+ },
+ onAdd () {
+ dispatch({
+ type: 'users/showModal',
+ payload: {
+ modalType: 'create'
+ }
+ })
+ }
+ }
+
+ const UserModalGen = () =>
+
+
+ return (
+
+
+
+
+
+ )
+}
+
+Users.propTypes = {
+ users: PropTypes.object,
+ location: PropTypes.object,
+ dispatch: PropTypes.func
+}
+
+function mapStateToProps ({ users }) {
+ return { users }
+}
+
+export default connect(mapStateToProps)(Users)
diff --git a/src/themes/react/static/js/theme.js b/src/themes/react/static/js/theme.js
new file mode 100644
index 00000000..82ec4559
--- /dev/null
+++ b/src/themes/react/static/js/theme.js
@@ -0,0 +1,13 @@
+module.exports = () => {
+ return {
+ '@border-radius-base': '3px',
+ '@border-radius-sm': '2px',
+ '@shadow-color': 'rgba(0,0,0,0.05)',
+ '@shadow-1-down': '4px 4px 40px @shadow-color',
+ '@border-color-split': '#f4f4f4',
+ '@border-color-base': '#e5e5e5',
+ '@menu-dark-bg': '#3e3e3e',
+ '@text-color': '#666',
+ '@font-family': 'monospace,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", SimSun, sans-serif'
+ }
+}
diff --git a/src/themes/react/static/js/utils/config.js b/src/themes/react/static/js/utils/config.js
new file mode 100644
index 00000000..4d33982a
--- /dev/null
+++ b/src/themes/react/static/js/utils/config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ name: 'Cello Dashboard',
+ prefix: 'cello',
+ footerText: 'Cello Dashboard',
+ logoText: 'Cello'
+}
diff --git a/src/themes/react/static/js/utils/index.js b/src/themes/react/static/js/utils/index.js
new file mode 100644
index 00000000..02406440
--- /dev/null
+++ b/src/themes/react/static/js/utils/index.js
@@ -0,0 +1,48 @@
+import config from './config'
+import menu from './menu'
+import request from './request'
+import classnames from 'classnames'
+import {color} from './theme'
+
+// 连字符转驼峰
+String.prototype.hyphenToHump = function () {
+ return this.replace(/-(\w)/g, function () {
+ return arguments[1].toUpperCase()
+ })
+}
+
+// 驼峰转连字符
+String.prototype.humpToHyphen = function () {
+ return this.replace(/([A-Z])/g, '-$1').toLowerCase()
+}
+
+// 日期格式化
+Date.prototype.format = function (format) {
+ var o = {
+ 'M+': this.getMonth() + 1,
+ 'd+': this.getDate(),
+ 'h+': this.getHours(),
+ 'H+': this.getHours(),
+ 'm+': this.getMinutes(),
+ 's+': this.getSeconds(),
+ 'q+': Math.floor((this.getMonth() + 3) / 3),
+ 'S': this.getMilliseconds()
+ }
+ if (/(y+)/.test(format)) { format = format.replace(RegExp.$1, (this.getFullYear() + '').substr(4 - RegExp.$1.length)) }
+ for (var k in o) {
+ if (new RegExp('(' + k + ')').test(format)) {
+ format = format.replace(RegExp.$1, RegExp.$1.length === 1
+ ? o[k]
+ : ('00' + o[k]).substr(('' + o[k]).length))
+ }
+ }
+ return format
+}
+
+module.exports = {
+ config,
+ menu,
+ request,
+ color,
+ classnames
+}
diff --git a/src/themes/react/static/js/utils/menu.js b/src/themes/react/static/js/utils/menu.js
new file mode 100644
index 00000000..c3a9d371
--- /dev/null
+++ b/src/themes/react/static/js/utils/menu.js
@@ -0,0 +1,38 @@
+module.exports = [
+ {
+ key: 'overview',
+ name: 'Overview',
+ icon: 'laptop'
+ },
+ {
+ key: 'hosts',
+ name: 'Hosts',
+ icon: 'user'
+ },
+ {
+ key: 'chains',
+ name: 'Chains',
+ icon: 'camera-o',
+ clickable: false,
+ child: [
+ {
+ key: 'active',
+ name: 'Active Chains'
+ },
+ {
+ key: 'inused',
+ name: 'Inused Chains'
+ }
+ ]
+ },
+ {
+ key: 'release',
+ name: 'Release History',
+ icon: 'user'
+ },
+ {
+ key: 'about',
+ name: 'About',
+ icon: 'user'
+ }
+]
diff --git a/src/themes/react/static/js/utils/request.js b/src/themes/react/static/js/utils/request.js
new file mode 100644
index 00000000..279b9f75
--- /dev/null
+++ b/src/themes/react/static/js/utils/request.js
@@ -0,0 +1,27 @@
+const Ajax = require('robe-ajax')
+
+/**
+ * Requests a URL, returning a promise.
+ *
+ * @param {string} url The URL we want to request
+ * @param {object} [options] The options we want to pass to "fetch"
+ * @return {object} An object containing either "data" or "err"
+ */
+export default function request (url, options) {
+ if (options.cross) {
+ return Ajax.getJSON('http://query.yahooapis.com/v1/public/yql', {
+ q: "select * from json where url='" + url + '?' + Ajax.param(options.data) + "'",
+ format: 'json'
+ })
+ } else {
+ return Ajax.ajax({
+ url: url,
+ method: options.method || 'get',
+ data: options.data || {},
+ processData: options.method === 'get',
+ dataType: 'JSON'
+ }).done((data) => {
+ return data
+ })
+ }
+}
diff --git a/src/themes/react/static/js/utils/theme.js b/src/themes/react/static/js/utils/theme.js
new file mode 100644
index 00000000..85ed8b4f
--- /dev/null
+++ b/src/themes/react/static/js/utils/theme.js
@@ -0,0 +1,14 @@
+module.exports = {
+ color: {
+ green: '#64ea91',
+ blue: '#8fc9fb',
+ purple: '#d897eb',
+ red: '#f69899',
+ yellow: '#f8c82e',
+ peach: '#f797d6',
+ borderBase: '#e5e5e5',
+ borderSplit: '#f4f4f4',
+ grass: '#d6fbb5',
+ sky: '#c1e0fc'
+ }
+}
diff --git a/src/themes/react/static/package.json b/src/themes/react/static/package.json
new file mode 100644
index 00000000..9f9271a7
--- /dev/null
+++ b/src/themes/react/static/package.json
@@ -0,0 +1,56 @@
+{
+ "private": true,
+ "entry": {
+ "index": "./js/index.js"
+ },
+ "dependencies": {
+ "antd": "^2.6.0",
+ "classnames": "^2.2.5",
+ "dva": "^1.1.0",
+ "dva-loading": "^0.2.0",
+ "js-cookie": "^2.1.3",
+ "qs": "^6.2.0",
+ "react": "^15.4.1",
+ "react-countup": "^1.3.0",
+ "react-dom": "^15.4.1",
+ "recharts": "^0.19.0"
+ },
+ "devDependencies": {
+ "atool-build": "^0.7.6",
+ "babel-eslint": "^6.0.4",
+ "babel-plugin-dev-expression": "^0.2.1",
+ "babel-plugin-dva-hmr": "^0.1.0",
+ "babel-plugin-import": "^1.0.1",
+ "babel-plugin-transform-runtime": "^6.9.0",
+ "babel-runtime": "^6.9.2",
+ "dora": "0.3.x",
+ "dora-plugin-proxy": "^0.7.0",
+ "dora-plugin-webpack": "0.6.x",
+ "dora-plugin-webpack-hmr": "^0.1.0",
+ "eslint": "^2.13.1",
+ "eslint-config-airbnb": "^9.0.1",
+ "eslint-plugin-import": "^1.8.1",
+ "eslint-plugin-jsx-a11y": "^1.4.2",
+ "eslint-plugin-react": "^5.1.1",
+ "glob": "^7.0.5",
+ "mockjs": "^1.0.1-beta3",
+ "redbox-react": "^1.2.10",
+ "robe-ajax": "^1.0.0",
+ "watchjs": "^0.0.0"
+ },
+ "scripts": {
+ "start": "dora --plugins \"proxy,webpack,webpack-hmr\"",
+ "dev": "dora --plugins \"webpack,webpack-hmr\"",
+ "lint": "eslint --fix --ext .js,.jsx src/components/layout",
+ "build": "atool-build -o /app/js/dist --publicPath /static/js/dist/",
+ "watch-mode": "atool-build -w --no-compress -o /app/js/dist --publicPath /static/js/dist/"
+ },
+ "theme": "./js/theme.js",
+ "standard": {
+ "parser": "babel-eslint",
+ "globals": [
+ "location",
+ "localStorage"
+ ]
+ }
+}
diff --git a/src/themes/react/static/webpack.config.js b/src/themes/react/static/webpack.config.js
new file mode 100644
index 00000000..5b3521c5
--- /dev/null
+++ b/src/themes/react/static/webpack.config.js
@@ -0,0 +1,49 @@
+const webpack = require('atool-build/lib/webpack')
+
+module.exports = function (webpackConfig, env) {
+ webpackConfig.babel.plugins.push('transform-runtime')
+ webpackConfig.babel.plugins.push(['import', {
+ libraryName: 'antd',
+ style: true
+ }])
+
+ // Support hmr
+ if (env === 'development') {
+ webpackConfig.devtool = '#eval'
+ webpackConfig.babel.plugins.push(['dva-hmr', {
+ entries: [
+ './js/index.js'
+ ]
+ }])
+ } else {
+ webpackConfig.babel.plugins.push('dev-expression')
+ }
+
+ // Don't extract common.js and common.css
+ webpackConfig.plugins = webpackConfig.plugins.filter(function (plugin) {
+ return !(plugin instanceof webpack.optimize.CommonsChunkPlugin)
+ })
+
+ // Support CSS Modules
+ // Parse all less files as css module.
+ webpackConfig.module.loaders.forEach(function (loader, index) {
+ if (typeof loader.test === 'function' && loader.test.toString().indexOf('\\.less$') > -1) {
+ loader.include = /node_modules/
+ loader.test = /\.less$/
+ }
+ if (loader.test.toString() === '/\\.module\\.less$/') {
+ loader.exclude = /node_modules/
+ loader.test = /\.less$/
+ }
+ if (typeof loader.test === 'function' && loader.test.toString().indexOf('\\.css$') > -1) {
+ loader.include = /node_modules/
+ loader.test = /\.css$/
+ }
+ if (loader.test.toString() === '/\\.module\\.css$/') {
+ loader.exclude = /node_modules/
+ loader.test = /\.css$/
+ }
+ })
+
+ return webpackConfig
+}
diff --git a/src/themes/react/templates/index.html b/src/themes/react/templates/index.html
new file mode 100644
index 00000000..575af242
--- /dev/null
+++ b/src/themes/react/templates/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block head %}
+ Cello Dashboard
+ {% endblock %}
+
+
+
+
+
+
+
+
+
+