Admin (#164)
* admin backend * admin ui * tslint ignore contracts * fix name * build & serve
This commit is contained in:
parent
2d75150dff
commit
d4298e62cc
|
@ -0,0 +1,4 @@
|
||||||
|
# admin listen port
|
||||||
|
PORT=3500
|
||||||
|
# backend url
|
||||||
|
BACKEND_URL=http://localhost:5000
|
|
@ -0,0 +1,12 @@
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.idea
|
||||||
|
build
|
||||||
|
out
|
||||||
|
src/build
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
*.pid
|
||||||
|
client/lib/contracts
|
||||||
|
.vscode
|
|
@ -0,0 +1 @@
|
||||||
|
.gitignore
|
|
@ -0,0 +1 @@
|
||||||
|
package-lock=false
|
|
@ -0,0 +1 @@
|
||||||
|
8.11.4
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"printWidth": 90,
|
||||||
|
"singleQuote": true,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxBracketSameLine": false
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Grant.io Admin UI
|
||||||
|
|
||||||
|
This is the admin component of [Grant.io](http://grant.io).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
1. Install local project dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local dependencies
|
||||||
|
yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Make sure ganache is running and contracts have been built for the dev network (if frontend dev is running this should be the case).
|
||||||
|
|
||||||
|
1. Run the webpack build for the admin ui:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Open a web browser to localhost:3500.
|
|
@ -0,0 +1,113 @@
|
||||||
|
{
|
||||||
|
"name": "grant",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"main": "index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "cross-env NODE_ENV=production webpack",
|
||||||
|
"dev": "cross-env NODE_ENV=development BACKEND_URL=http://localhost:5000 webpack-dev-server",
|
||||||
|
"lint": "tslint --project ./tsconfig.json --config ./tslint.json -e \"**/build/**\"",
|
||||||
|
"start": "NODE_ENV=production node ./server.js",
|
||||||
|
"tsc": "tsc"
|
||||||
|
},
|
||||||
|
"husky": {
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "lint-staged",
|
||||||
|
"pre-push": "yarn run lint && yarn run tsc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx}": [
|
||||||
|
"prettier --write --config ./.prettierrc --config-precedence file-override",
|
||||||
|
"git add"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.0.1",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.0.0",
|
||||||
|
"@babel/plugin-proposal-object-rest-spread": "^7.0.0",
|
||||||
|
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.0.0",
|
||||||
|
"@babel/polyfill": "^7.0.0",
|
||||||
|
"@babel/preset-env": "^7.0.0",
|
||||||
|
"@babel/preset-react": "^7.0.0",
|
||||||
|
"@babel/preset-typescript": "^7.0.0",
|
||||||
|
"@babel/register": "^7.0.0",
|
||||||
|
"@svgr/webpack": "^2.4.0",
|
||||||
|
"@types/classnames": "^2.2.6",
|
||||||
|
"@types/dotenv": "^4.0.3",
|
||||||
|
"@types/lodash": "^4.14.112",
|
||||||
|
"@types/numeral": "^0.0.25",
|
||||||
|
"@types/react": "16.4.18",
|
||||||
|
"@types/react-dom": "16.0.9",
|
||||||
|
"@types/react-helmet": "^5.0.7",
|
||||||
|
"@types/react-redux": "^6.0.2",
|
||||||
|
"@types/react-router": "^4.0.31",
|
||||||
|
"@types/react-router-dom": "^4.3.1",
|
||||||
|
"@types/showdown": "^1.7.5",
|
||||||
|
"@types/webpack": "4.4.17",
|
||||||
|
"@types/webpack-env": "^1.13.6",
|
||||||
|
"ant-design-pro": "2.0.0",
|
||||||
|
"antd": "3.9.3",
|
||||||
|
"axios": "^0.18.0",
|
||||||
|
"babel-core": "^7.0.0-bridge.0",
|
||||||
|
"babel-loader": "^8.0.2",
|
||||||
|
"babel-plugin-dynamic-import-node": "^2.1.0",
|
||||||
|
"babel-plugin-dynamic-import-webpack": "^1.0.2",
|
||||||
|
"babel-plugin-import": "^1.8.0",
|
||||||
|
"babel-plugin-module-resolver": "^3.1.1",
|
||||||
|
"bn.js": "4.11.8",
|
||||||
|
"classnames": "^2.2.6",
|
||||||
|
"clean-webpack-plugin": "^0.1.19",
|
||||||
|
"core-js": "^2.5.7",
|
||||||
|
"cross-env": "^5.2.0",
|
||||||
|
"css-loader": "^1.0.0",
|
||||||
|
"dotenv": "^6.0.0",
|
||||||
|
"ethereum-blockies-base64": "1.0.2",
|
||||||
|
"ethereumjs-util": "5.2.0",
|
||||||
|
"file-loader": "^2.0.0",
|
||||||
|
"font-awesome": "^4.7.0",
|
||||||
|
"fork-ts-checker-webpack-plugin": "^0.4.2",
|
||||||
|
"global": "4.3.2",
|
||||||
|
"html-webpack-plugin": "^3.2.0",
|
||||||
|
"husky": "^1.0.0-rc.8",
|
||||||
|
"less": "^3.7.1",
|
||||||
|
"less-loader": "^4.1.0",
|
||||||
|
"lint-staged": "^7.2.2",
|
||||||
|
"lodash": "^4.17.10",
|
||||||
|
"mini-css-extract-plugin": "^0.4.2",
|
||||||
|
"moment": "^2.22.2",
|
||||||
|
"prettier": "^1.13.4",
|
||||||
|
"prettier-package-json": "^1.6.0",
|
||||||
|
"query-string": "6.1.0",
|
||||||
|
"react": "16.5.2",
|
||||||
|
"react-dev-utils": "^5.0.2",
|
||||||
|
"react-dom": "16.5.2",
|
||||||
|
"react-easy-state": "^6.0.4",
|
||||||
|
"react-hot-loader": "^4.3.8",
|
||||||
|
"react-router": "^4.3.1",
|
||||||
|
"react-router-dom": "^4.3.1",
|
||||||
|
"showdown": "^1.8.7",
|
||||||
|
"style-loader": "^0.23.0",
|
||||||
|
"ts-loader": "^5.1.1",
|
||||||
|
"tslint": "^5.10.0",
|
||||||
|
"tslint-config-airbnb": "^5.9.2",
|
||||||
|
"tslint-config-prettier": "^1.13.0",
|
||||||
|
"tslint-eslint-rules": "^5.3.1",
|
||||||
|
"tslint-react": "^3.6.0",
|
||||||
|
"typescript": "3.0.3",
|
||||||
|
"url-loader": "^1.1.1",
|
||||||
|
"web3": "^1.0.0-beta.34",
|
||||||
|
"webpack": "^4.19.0",
|
||||||
|
"webpack-cli": "^3.1.0",
|
||||||
|
"webpack-dev-server": "^3.1.8",
|
||||||
|
"webpack-hot-middleware": "^2.24.0",
|
||||||
|
"xss": "1.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bn.js": "4.11.1",
|
||||||
|
"@types/ethereumjs-util": "5.2.0",
|
||||||
|
"@types/query-string": "6.1.0",
|
||||||
|
"@types/web3": "1.0.3"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const PORT = process.env.PORT || 3500;
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.static(__dirname + '/build'));
|
||||||
|
|
||||||
|
app.get('*', function(request, response) {
|
||||||
|
response.sendFile(path.resolve(__dirname, 'build', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/favicon.ico', (req, res) => {
|
||||||
|
res.send('');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`App is listening on port ${PORT} `);
|
||||||
|
});
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { hot } from 'react-hot-loader';
|
||||||
|
import { Switch, Route, RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
|
||||||
|
import Template from 'components/Template';
|
||||||
|
import store from './store';
|
||||||
|
import Login from 'components/Login';
|
||||||
|
import Home from 'components/Home';
|
||||||
|
import Users from 'components/Users';
|
||||||
|
import Proposals from 'components/Proposals';
|
||||||
|
|
||||||
|
import 'styles/style.less';
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
class Routes extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { hasCheckedLogin, isLoggedIn } = store;
|
||||||
|
if (!hasCheckedLogin) {
|
||||||
|
return <div>checking auth status...</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Template>
|
||||||
|
{!isLoggedIn ? (
|
||||||
|
<Login />
|
||||||
|
) : (
|
||||||
|
<Switch>
|
||||||
|
<Route path="/" exact={true} component={Home} />
|
||||||
|
<Route path="/users/:id?" exact={true} component={Users} />
|
||||||
|
<Route path="/proposals/:id?" component={Proposals} />
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</Template>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectedRoutes = withRouter(view(Routes));
|
||||||
|
export default hot(module)(ConnectedRoutes);
|
|
@ -0,0 +1,25 @@
|
||||||
|
.Field {
|
||||||
|
&-title {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ant collapse
|
||||||
|
& .ant-collapse-item {
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
& > .ant-collapse-header {
|
||||||
|
padding: 0rem 0 0rem 15px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .anticon {
|
||||||
|
left: 0;
|
||||||
|
transform: translateY(-45%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Collapse } from 'antd';
|
||||||
|
import moment from 'moment';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string | JSX.Element;
|
||||||
|
value: string | number | JSX.Element;
|
||||||
|
isTime?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(n: number) {
|
||||||
|
return moment(n).format('YYYY/MM/DD h:mm a');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class CollapseField extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { title, value } = this.props;
|
||||||
|
|
||||||
|
if (null === value || ['string', 'number'].indexOf(typeof value) > -1) {
|
||||||
|
return (
|
||||||
|
<div className="Field">
|
||||||
|
<div className="Field-value">
|
||||||
|
{this.props.isTime ? fmtTime(Number(value)) : value || 'n/a'}
|
||||||
|
<span className="Field-title">({title})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapse className="Field" bordered={false}>
|
||||||
|
<Collapse.Panel header={title} key="1">
|
||||||
|
{value}
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
.Home {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import store from '../../store';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
class Home extends React.Component {
|
||||||
|
componentDidMount() {
|
||||||
|
store.fetchStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { userCount, proposalCount } = store.stats;
|
||||||
|
return (
|
||||||
|
<div className="Home">
|
||||||
|
<h1>Home</h1>
|
||||||
|
<div>isLoggedIn: {store.isLoggedIn ? 'true' : 'false'}</div>
|
||||||
|
<div>web3 type: {store.web3Type}</div>
|
||||||
|
<div>ethereum network: {store.ethNetId}</div>
|
||||||
|
<div>ethereum account: {store.ethAccount}</div>
|
||||||
|
<div>CrowdFundFactory: {store.crowdFundFactoryDefinitionStatus}</div>
|
||||||
|
{userCount > -1 && (
|
||||||
|
<>
|
||||||
|
<div>user count: {userCount}</div>
|
||||||
|
<div>proposal count: {proposalCount}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(Home);
|
|
@ -0,0 +1,12 @@
|
||||||
|
.Login {
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { Input, Button, Alert } from 'antd';
|
||||||
|
import store from '../../store';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
class Login extends React.Component {
|
||||||
|
state = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="Login">
|
||||||
|
<h1>Login</h1>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
value={this.state.username}
|
||||||
|
onChange={e => this.setState({ username: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={this.state.password}
|
||||||
|
onChange={e => this.setState({ password: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{store.loginError && (
|
||||||
|
<div>
|
||||||
|
<Alert message={store.loginError} type="warning" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => store.login(this.state.username, this.state.password)}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default view(Login);
|
|
@ -0,0 +1,196 @@
|
||||||
|
@controls-height: 40px;
|
||||||
|
|
||||||
|
.Proposals {
|
||||||
|
margin-top: @controls-height + 0.5rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
height: @controls-height;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
margin-left: -1rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 216px;
|
||||||
|
z-index: 5;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
&-status {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.2rem;
|
||||||
|
right: 0.2rem;
|
||||||
|
color: white;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-proposal {
|
||||||
|
display: flex;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgb(214, 214, 214);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
margin: 0 0.5rem 0.2rem 0;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
& img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 0.3rem 0 0;
|
||||||
|
outline: none !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-body {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: 30px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-milestones {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: wrap;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
width: 316px;
|
||||||
|
margin: 0 1rem 1rem 0;
|
||||||
|
|
||||||
|
& > div > span {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-comments {
|
||||||
|
border-left: 1rem solid rgba(0, 0, 0, 0.1);
|
||||||
|
& > div {
|
||||||
|
margin: 0.2rem 0 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-contract {
|
||||||
|
&-method {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-array {
|
||||||
|
margin-left: 0.7rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
|
||||||
|
&-milestones,
|
||||||
|
&-contributors {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-status {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
|
||||||
|
&.is-unloaded {
|
||||||
|
background-color: rgb(197, 197, 197);
|
||||||
|
}
|
||||||
|
&.is-loading {
|
||||||
|
background-color: rgb(241, 177, 0);
|
||||||
|
}
|
||||||
|
&.is-waiting {
|
||||||
|
background-color: rgb(0, 226, 230);
|
||||||
|
}
|
||||||
|
&.is-loaded {
|
||||||
|
background-color: rgb(0, 165, 25);
|
||||||
|
}
|
||||||
|
&.is-error {
|
||||||
|
background-color: rgb(194, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-method {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-inputs {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 300px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.23rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-input {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
&.is-wei,
|
||||||
|
&.is-string,
|
||||||
|
&.is-integer {
|
||||||
|
width: 13rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ant-alert {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0.5rem 0 0.5rem 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,406 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { Icon, Button, Popover, InputNumber, Checkbox, Alert, Input } from 'antd';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import Showdown from 'showdown';
|
||||||
|
import moment from 'moment';
|
||||||
|
import store from 'src/store';
|
||||||
|
import {
|
||||||
|
Proposal,
|
||||||
|
Contract,
|
||||||
|
ContractMethod as TContractMethod,
|
||||||
|
ContractMilestone,
|
||||||
|
ContractContributor,
|
||||||
|
} from 'src/types';
|
||||||
|
import './index.less';
|
||||||
|
import Field from 'components/Field';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const showdownConverter = new Showdown.Converter({
|
||||||
|
simplifiedAutoLink: true,
|
||||||
|
tables: true,
|
||||||
|
strikethrough: true,
|
||||||
|
disableForced4SpacesIndentedSublists: true,
|
||||||
|
openLinksInNewWindow: true,
|
||||||
|
excludeTrailingPunctuationFromURLs: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
class ProposalsNaked extends React.Component<Props> {
|
||||||
|
componentDidMount() {
|
||||||
|
store.fetchProposals();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const id = this.props.match.params.id;
|
||||||
|
const { proposals, proposalsFetched } = store;
|
||||||
|
|
||||||
|
if (!proposalsFetched) {
|
||||||
|
return 'loading proposals...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const singleProposal = proposals.find(p => p.proposalId === id);
|
||||||
|
if (singleProposal) {
|
||||||
|
return (
|
||||||
|
<div className="Proposals">
|
||||||
|
<div className="Proposals-controls">
|
||||||
|
<Link to="/proposals">proposals</Link> <Icon type="right" /> {id}{' '}
|
||||||
|
<Button
|
||||||
|
title="refresh"
|
||||||
|
icon="reload"
|
||||||
|
onClick={() => store.fetchProposals()}
|
||||||
|
/>
|
||||||
|
<div className="Proposals-controls-status">
|
||||||
|
{store.crowdFundGeneralStatus}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProposalItem key={singleProposal.proposalId} {...singleProposal} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return `could not find proposal: ${id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Proposals">
|
||||||
|
<div className="Proposals-controls">
|
||||||
|
<Button title="refresh" icon="reload" onClick={() => store.fetchProposals()} />
|
||||||
|
<div className="Proposals-controls-status">{store.crowdFundGeneralStatus}</div>
|
||||||
|
</div>
|
||||||
|
{proposals.length === 0 && <div>no proposals</div>}
|
||||||
|
{proposals.length > 0 &&
|
||||||
|
proposals.map(p => <ProposalItem key={p.proposalId} {...p} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tslint:disable-next-line:max-classes-per-file
|
||||||
|
class ProposalItemNaked extends React.Component<Proposal> {
|
||||||
|
state = {
|
||||||
|
showDelete: false,
|
||||||
|
};
|
||||||
|
render() {
|
||||||
|
const p = this.props;
|
||||||
|
const body = showdownConverter.makeHtml(p.body);
|
||||||
|
return (
|
||||||
|
<div key={p.proposalId} className="Proposals-proposal">
|
||||||
|
<div>
|
||||||
|
<div className="Proposals-proposal-controls">
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<Button type="primary" onClick={this.handleDelete}>
|
||||||
|
delete {p.title}
|
||||||
|
</Button>{' '}
|
||||||
|
<Button onClick={() => this.setState({ showDelete: false })}>
|
||||||
|
cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title="Permanently delete proposal?"
|
||||||
|
trigger="click"
|
||||||
|
visible={this.state.showDelete}
|
||||||
|
onVisibleChange={showDelete => this.setState({ showDelete })}
|
||||||
|
>
|
||||||
|
<Button icon="delete" shape="circle" size="small" title="delete" />
|
||||||
|
</Popover>
|
||||||
|
{/* TODO: implement disable payments on BE */}
|
||||||
|
<Button
|
||||||
|
icon="dollar"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
title={false ? 'allow payments' : 'disable payments'}
|
||||||
|
type={false ? 'danger' : 'default'}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<b>{p.title}</b> {p.proposalId} <Field title="category" value={p.category} />
|
||||||
|
<Field title="dateCreated" value={p.dateCreated * 1000} isTime={true} />
|
||||||
|
<Field title="stage" value={p.stage} />
|
||||||
|
<Field
|
||||||
|
title={`team (${p.team.length})`}
|
||||||
|
value={
|
||||||
|
<div>
|
||||||
|
{p.team.map(u => (
|
||||||
|
<div key={u.userid}>
|
||||||
|
{u.displayName} (
|
||||||
|
<Link to={`/users/${u.accountAddress}`}>{u.accountAddress}</Link>)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
title={`comments (${p.comments.length})`}
|
||||||
|
value={<div>TODO: comments</div>}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
title={`body (${body.length}chr)`}
|
||||||
|
value={
|
||||||
|
<div
|
||||||
|
className="Proposals-proposal-body"
|
||||||
|
dangerouslySetInnerHTML={{ __html: body }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
title={`milestones (${p.milestones.length})`}
|
||||||
|
value={
|
||||||
|
<div className="Proposals-proposal-milestones">
|
||||||
|
{p.milestones.map((ms, idx) => (
|
||||||
|
<div key={idx}>
|
||||||
|
<div>
|
||||||
|
<b>
|
||||||
|
{idx}. {ms.title}
|
||||||
|
</b>
|
||||||
|
<span>(title)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{moment(ms.dateCreated).format('YYYY/MM/DD h:mm a')}
|
||||||
|
<span>(dateCreated)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{moment(ms.dateEstimated).format('YYYY/MM/DD h:mm a')}
|
||||||
|
<span>(dateEstimated)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{ms.stage}
|
||||||
|
<span>(stage)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{JSON.stringify(ms.immediatePayout)}
|
||||||
|
<span>(immediatePayout)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{ms.payoutPercent}
|
||||||
|
<span>(payoutPercent)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{ms.body}
|
||||||
|
<span>(body)</span>
|
||||||
|
</div>
|
||||||
|
{/* <small>content</small>
|
||||||
|
<div>{ms.content}</div> */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
title={`web3 (${p.contractStatus || 'not loaded'})`}
|
||||||
|
value={
|
||||||
|
<div className="Proposals-proposal-contract">
|
||||||
|
<Button
|
||||||
|
icon="reload"
|
||||||
|
size="small"
|
||||||
|
title="refresh contract"
|
||||||
|
onClick={() => store.populateProposalContract(p.proposalId)}
|
||||||
|
>
|
||||||
|
refresh contract
|
||||||
|
</Button>
|
||||||
|
{Object.keys(p.contract)
|
||||||
|
.map(k => k as keyof Contract)
|
||||||
|
.map(k => (
|
||||||
|
<ContractMethod
|
||||||
|
key={k}
|
||||||
|
proposalId={p.proposalId}
|
||||||
|
name={k}
|
||||||
|
{...p.contract[k]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private handleDelete = () => {
|
||||||
|
store.deleteProposal(this.props.proposalId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ProposalItem = view(ProposalItemNaked);
|
||||||
|
|
||||||
|
// tslint:disable-next-line:max-classes-per-file
|
||||||
|
class ContractMethodNaked extends React.Component<
|
||||||
|
TContractMethod & { proposalId: string; name: string }
|
||||||
|
> {
|
||||||
|
state = {};
|
||||||
|
render() {
|
||||||
|
const { name, value, status, type, format } = this.props;
|
||||||
|
const isObj = typeof value === 'object' && value !== null;
|
||||||
|
const isArray = Array.isArray(value);
|
||||||
|
const fmt = (val: any) => {
|
||||||
|
if (val && format === 'time') {
|
||||||
|
const asNumber = Number(val) * 1000;
|
||||||
|
return `${moment(asNumber).format()} (${moment(asNumber).fromNow()})`;
|
||||||
|
} else if (val && format === 'duration') {
|
||||||
|
const asNumber = Number(val) * 1000;
|
||||||
|
return `${asNumber} (${moment.duration(asNumber).humanize()})`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
if (type === 'send') {
|
||||||
|
return <ContractMethodSend {...this.props} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`Proposals-proposal-contract-status is-${status || ''}`} />
|
||||||
|
<span className="Proposals-proposal-contract-method">{name}:</span>
|
||||||
|
{(!isObj && <span> {fmt(value)}</span>) || (
|
||||||
|
<div className="Proposals-proposal-contract-array">
|
||||||
|
{isArray &&
|
||||||
|
name !== 'milestones' &&
|
||||||
|
name !== 'contributors' &&
|
||||||
|
(value as string[]).map((x, i) => (
|
||||||
|
<div key={x}>
|
||||||
|
{i}: {x}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isArray &&
|
||||||
|
name === 'milestones' && (
|
||||||
|
<div className="Proposals-proposal-contract-array-milestones">
|
||||||
|
{(value as ContractMilestone[]).map((cm, idx) => (
|
||||||
|
<div key={idx}>
|
||||||
|
<div>
|
||||||
|
<span>paid:</span> {JSON.stringify(cm.paid)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>amount:</span> {cm.amount}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>payoutRequestVoteDeadline:</span>{' '}
|
||||||
|
{Number(cm.payoutRequestVoteDeadline) < 2
|
||||||
|
? cm.payoutRequestVoteDeadline
|
||||||
|
: moment(Number(cm.payoutRequestVoteDeadline) * 1000).fromNow()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>amountVotingAgainstPayout:</span>{' '}
|
||||||
|
{cm.amountVotingAgainstPayout}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isArray &&
|
||||||
|
name === 'contributors' && (
|
||||||
|
<div className="Proposals-proposal-contract-array-contributors">
|
||||||
|
{(value as ContractContributor[]).map(c => (
|
||||||
|
<div key={c.address}>
|
||||||
|
<div>
|
||||||
|
<span>address:</span> {c.address}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>milestoneNoVotes:</span>{' '}
|
||||||
|
{JSON.stringify(c.milestoneNoVotes)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>contributionAmount:</span> {c.contributionAmount}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>refundVote:</span> {JSON.stringify(c.refundVote)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>refunded:</span> {JSON.stringify(c.refunded)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ContractMethod = view(ContractMethodNaked);
|
||||||
|
|
||||||
|
// tslint:disable-next-line:max-classes-per-file
|
||||||
|
class ContractMethodSendNaked extends React.Component<
|
||||||
|
TContractMethod & { proposalId: string; name: string }
|
||||||
|
> {
|
||||||
|
state = {
|
||||||
|
args: this.props.input.map(i => (i.type === 'boolean' ? false : '')) as any[],
|
||||||
|
};
|
||||||
|
render() {
|
||||||
|
const { name, status, input, proposalId, error } = this.props;
|
||||||
|
return (
|
||||||
|
<div className="Proposals-proposal-contract-method">
|
||||||
|
<div className={`Proposals-proposal-contract-status is-${status || ''}`} />
|
||||||
|
<div className="Proposals-proposal-contract-inputs">
|
||||||
|
{input.length === 0 && 'no input'}
|
||||||
|
{input.map(
|
||||||
|
(x, idx) =>
|
||||||
|
((x.type === 'wei' || x.type === 'integer') && (
|
||||||
|
<InputNumber
|
||||||
|
size="small"
|
||||||
|
key={x.name}
|
||||||
|
name={x.name}
|
||||||
|
placeholder={`${x.name} (${x.type})`}
|
||||||
|
onChange={val => {
|
||||||
|
const args = [...this.state.args];
|
||||||
|
args[idx] = val;
|
||||||
|
this.setState({ args });
|
||||||
|
}}
|
||||||
|
value={this.state.args[idx]}
|
||||||
|
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
|
||||||
|
/>
|
||||||
|
)) ||
|
||||||
|
(x.type === 'string' && (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
key={x.name}
|
||||||
|
name={x.name}
|
||||||
|
placeholder={`${x.name} (${x.type})`}
|
||||||
|
onChange={evt => {
|
||||||
|
const args = [...this.state.args];
|
||||||
|
args[idx] = evt.currentTarget.value;
|
||||||
|
this.setState({ args });
|
||||||
|
}}
|
||||||
|
value={this.state.args[idx]}
|
||||||
|
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
|
||||||
|
/>
|
||||||
|
)) || (
|
||||||
|
<Checkbox
|
||||||
|
key={x.name}
|
||||||
|
onChange={evt => {
|
||||||
|
const args = [...this.state.args];
|
||||||
|
args[idx] = evt.target.checked;
|
||||||
|
this.setState({ args });
|
||||||
|
}}
|
||||||
|
value={this.state.args[idx]}
|
||||||
|
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
|
||||||
|
>
|
||||||
|
{x.name}
|
||||||
|
</Checkbox>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="arrow-right"
|
||||||
|
size="default"
|
||||||
|
loading={status === 'loading' || status === 'waiting'}
|
||||||
|
onClick={() =>
|
||||||
|
store.proposalContractSend(
|
||||||
|
proposalId,
|
||||||
|
name as keyof Contract,
|
||||||
|
input,
|
||||||
|
this.state.args,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
{error && <Alert message={error} type="error" closable={true} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ContractMethodSend = view(ContractMethodSendNaked);
|
||||||
|
|
||||||
|
const Proposals = withRouter(view(ProposalsNaked));
|
||||||
|
export default Proposals;
|
|
@ -0,0 +1,40 @@
|
||||||
|
.Template {
|
||||||
|
&-sider {
|
||||||
|
overflow: auto;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
&-logo {
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-layout {
|
||||||
|
margin-left: 200px;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-errors {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
position: fixed;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
top: 0;
|
||||||
|
padding: 0.8rem;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
& > div {
|
||||||
|
margin: 0.2rem;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { hot } from 'react-hot-loader';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Layout, Menu, Icon, Alert } from 'antd';
|
||||||
|
import './index.less';
|
||||||
|
import store from 'src/store';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
|
||||||
|
const { Content, Sider } = Layout;
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
class Template extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { pathname } = this.props.location;
|
||||||
|
return (
|
||||||
|
<Layout className="Template">
|
||||||
|
{store.generalError.length > 0 && (
|
||||||
|
<div className="Template-errors">
|
||||||
|
{store.generalError.map((e, i) => (
|
||||||
|
<Alert
|
||||||
|
key={i}
|
||||||
|
message={e}
|
||||||
|
type="error"
|
||||||
|
closable={true}
|
||||||
|
onClose={() => store.removeGeneralError(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Sider className="Template-sider">
|
||||||
|
<div className="Template-sider-logo">grant.io</div>
|
||||||
|
<Menu theme="dark" mode="inline" selectedKeys={[pathname]}>
|
||||||
|
<Menu.Item key="/">
|
||||||
|
<Link to="/">
|
||||||
|
<Icon type="home" />
|
||||||
|
<span className="nav-text">home</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item key="/users">
|
||||||
|
<Link to="/users">
|
||||||
|
<Icon type="user" />
|
||||||
|
<span className="nav-text">users</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item key="/proposals">
|
||||||
|
<Link to="/proposals">
|
||||||
|
<Icon type="file" />
|
||||||
|
<span className="nav-text">proposals</span>
|
||||||
|
</Link>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item key="logout" onClick={store.logout}>
|
||||||
|
<Icon type="logout" />
|
||||||
|
<span className="nav-text">logout</span>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
</Sider>
|
||||||
|
<Layout className="Template-layout">
|
||||||
|
<Content className="Template-layout-content">{this.props.children}</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectedTemplate = withRouter(view(Template));
|
||||||
|
export default hot(module)(ConnectedTemplate);
|
|
@ -0,0 +1,57 @@
|
||||||
|
@controls-height: 40px;
|
||||||
|
|
||||||
|
.Users {
|
||||||
|
margin-top: @controls-height + 0.5rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
height: @controls-height;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
margin-left: -1rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 216px;
|
||||||
|
z-index: 5;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-user {
|
||||||
|
display: flex;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgb(214, 214, 214);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&-controls {
|
||||||
|
margin: 0 0.5rem 0.5rem 0;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
& img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0 0.3rem 0 0;
|
||||||
|
outline: none !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { view } from 'react-easy-state';
|
||||||
|
import { Button, Popover, Icon } from 'antd';
|
||||||
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import store from 'src/store';
|
||||||
|
import { User } from 'src/types';
|
||||||
|
import './index.less';
|
||||||
|
import Field from 'components/Field';
|
||||||
|
|
||||||
|
type Props = RouteComponentProps<any>;
|
||||||
|
|
||||||
|
class UsersNaked extends React.Component<Props> {
|
||||||
|
componentDidMount() {
|
||||||
|
store.fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const id = this.props.match.params.id;
|
||||||
|
const { users, usersFetched } = store;
|
||||||
|
|
||||||
|
if (!usersFetched) {
|
||||||
|
return 'loading users...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const singleUser = users.find(u => u.accountAddress === id);
|
||||||
|
if (singleUser) {
|
||||||
|
return (
|
||||||
|
<div className="Users">
|
||||||
|
<div className="Users-controls">
|
||||||
|
<Link to="/users">users</Link> <Icon type="right" /> {id}{' '}
|
||||||
|
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
|
||||||
|
</div>
|
||||||
|
<UserItem key={singleUser.userid} {...singleUser} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return `could not find user: ${id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Users">
|
||||||
|
<div className="Users-controls">
|
||||||
|
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
|
||||||
|
</div>
|
||||||
|
{users.length === 0 && <div>no users</div>}
|
||||||
|
{users.length > 0 && users.map(u => <UserItem key={u.userid} {...u} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tslint:disable-next-line:max-classes-per-file
|
||||||
|
class UserItemNaked extends React.Component<User> {
|
||||||
|
state = {
|
||||||
|
showProposals: false,
|
||||||
|
activeProposal: '',
|
||||||
|
showDelete: false,
|
||||||
|
};
|
||||||
|
render() {
|
||||||
|
const u = this.props;
|
||||||
|
return (
|
||||||
|
<div key={u.userid} className="Users-user">
|
||||||
|
<div>
|
||||||
|
<div className="Users-user-controls">
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<Button type="primary" onClick={this.handleDelete}>
|
||||||
|
delete {u.emailAddress}
|
||||||
|
</Button>{' '}
|
||||||
|
<Button onClick={() => this.setState({ showDelete: false })}>
|
||||||
|
cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title="Permanently delete user?"
|
||||||
|
trigger="click"
|
||||||
|
visible={this.state.showDelete}
|
||||||
|
onVisibleChange={showDelete => this.setState({ showDelete })}
|
||||||
|
>
|
||||||
|
<Button icon="delete" shape="circle" size="small" title="delete" />
|
||||||
|
</Popover>
|
||||||
|
{/* TODO: implement silence user on BE */}
|
||||||
|
<Button
|
||||||
|
icon="notification"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
title={false ? 'allow commenting' : 'disable commenting'}
|
||||||
|
type={false ? 'danger' : 'default'}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="Users-user-img">
|
||||||
|
{u.avatar ? <img src={u.avatar.imageUrl} /> : 'n/a'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Field title="displayName" value={u.displayName} />
|
||||||
|
<Field title="title" value={u.title} />
|
||||||
|
<Field title="emailAddress" value={u.emailAddress} />
|
||||||
|
<Field title="accountAddress" value={u.accountAddress} />
|
||||||
|
<Field title="userid" value={u.userid} />
|
||||||
|
<Field
|
||||||
|
title="avatar.imageUrl"
|
||||||
|
value={(u.avatar && u.avatar.imageUrl) || 'n/a'}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
title={`proposals (${u.proposals.length})`}
|
||||||
|
value={
|
||||||
|
<div className="Users-user-proposals">
|
||||||
|
{u.proposals.map(p => (
|
||||||
|
<div key={p.proposalId}>
|
||||||
|
{p.title} (
|
||||||
|
<Link to={`/proposals/${p.proposalId}`}>{p.proposalId}</Link>)
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
title={`comments (${u.comments.length})`}
|
||||||
|
value={<div>TODO: comments</div>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private handleDelete = () => {
|
||||||
|
store.deleteUser(this.props.accountAddress);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const UserItem = view(UserItemNaked);
|
||||||
|
|
||||||
|
const Users = withRouter(view(UsersNaked));
|
||||||
|
export default Users;
|
|
@ -0,0 +1,14 @@
|
||||||
|
import '@babel/polyfill';
|
||||||
|
import React from 'react';
|
||||||
|
import { hot } from 'react-hot-loader';
|
||||||
|
import { render } from 'react-dom';
|
||||||
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
|
import Routes from './Routes';
|
||||||
|
|
||||||
|
const App = hot(module)(() => (
|
||||||
|
<Router>
|
||||||
|
<Routes />
|
||||||
|
</Router>
|
||||||
|
));
|
||||||
|
|
||||||
|
render(<App />, document.getElementById('root'));
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Admin - Grant.io</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id='root'>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { store } from 'react-easy-state';
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { User, Proposal, INITIAL_CONTRACT, Contract, ContractMethodInput } from './types';
|
||||||
|
import {
|
||||||
|
initializeWeb3,
|
||||||
|
populateProposalContract,
|
||||||
|
proposalContractSend,
|
||||||
|
} from './web3helper';
|
||||||
|
|
||||||
|
// API
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: process.env.BACKEND_URL + '/api/v1',
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function login(username: string, password: string) {
|
||||||
|
const { data } = await api.post('/admin/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return data.isLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
const { data } = await api.get('/admin/logout');
|
||||||
|
return data.isLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkLogin() {
|
||||||
|
const { data } = await api.get('/admin/checklogin');
|
||||||
|
return data.isLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
const { data } = await api.get('/admin/stats');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
const { data } = await api.get('/admin/users');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id: string) {
|
||||||
|
const { data } = await api.delete('/admin/users/' + id);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProposals() {
|
||||||
|
const { data } = await api.get('/admin/proposals');
|
||||||
|
data.forEach((p: Proposal) => (p.contract = cloneDeep(INITIAL_CONTRACT)));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProposal(id: string) {
|
||||||
|
const { data } = await api.delete('/admin/proposals/' + id);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STORE
|
||||||
|
const app = store({
|
||||||
|
hasCheckedLogin: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
loginError: '',
|
||||||
|
generalError: [] as string[],
|
||||||
|
stats: {
|
||||||
|
userCount: -1,
|
||||||
|
proposalCount: -1,
|
||||||
|
},
|
||||||
|
usersFetched: false,
|
||||||
|
users: [] as User[],
|
||||||
|
proposalsFetched: false,
|
||||||
|
proposals: [] as Proposal[],
|
||||||
|
web3Type: '',
|
||||||
|
ethNetId: -1,
|
||||||
|
ethAccount: '',
|
||||||
|
crowdFundFactoryDefinitionStatus: '',
|
||||||
|
crowdFundGeneralStatus: 'idle',
|
||||||
|
|
||||||
|
removeGeneralError(i: number) {
|
||||||
|
app.generalError.splice(i, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkLogin() {
|
||||||
|
app.isLoggedIn = await checkLogin();
|
||||||
|
app.hasCheckedLogin = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
try {
|
||||||
|
app.isLoggedIn = await login(username, password);
|
||||||
|
} catch (e) {
|
||||||
|
app.loginError = e.response.data.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
app.isLoggedIn = await logout();
|
||||||
|
} catch (e) {
|
||||||
|
app.generalError.push(e.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchStats() {
|
||||||
|
try {
|
||||||
|
app.stats = await fetchStats();
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchUsers() {
|
||||||
|
try {
|
||||||
|
app.users = await fetchUsers();
|
||||||
|
app.usersFetched = true;
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUser(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteUser(id);
|
||||||
|
app.users = app.users.filter(u => u.accountAddress !== id && u.emailAddress !== id);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchProposals() {
|
||||||
|
try {
|
||||||
|
app.proposals = await fetchProposals();
|
||||||
|
app.proposalsFetched = true;
|
||||||
|
// for (const p of app.proposals) {
|
||||||
|
// TODO: partial populate contributorList
|
||||||
|
// await app.populateProposalContract(p.proposalId);
|
||||||
|
// }
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async populateProposalContract(proposalId: string) {
|
||||||
|
if (web3) {
|
||||||
|
await populateProposalContract(app, web3, proposalId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async proposalContractSend(
|
||||||
|
proposalId: string,
|
||||||
|
methodName: keyof Contract,
|
||||||
|
inputs: ContractMethodInput[],
|
||||||
|
args: any[],
|
||||||
|
) {
|
||||||
|
if (web3) {
|
||||||
|
await proposalContractSend(app, web3, proposalId, methodName, inputs, args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProposal(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteProposal(id);
|
||||||
|
app.proposals = app.proposals.filter(p => p.proposalId === id);
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleApiError(e: AxiosError) {
|
||||||
|
if (e.response && e.response.data!.message) {
|
||||||
|
app.generalError.push(e.response!.data.message);
|
||||||
|
} else if (e.response && e.response.data!.data!) {
|
||||||
|
app.generalError.push(e.response!.data.data);
|
||||||
|
} else {
|
||||||
|
app.generalError.push(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).appStore = app;
|
||||||
|
|
||||||
|
// check login status periodically
|
||||||
|
app.checkLogin();
|
||||||
|
window.setInterval(app.checkLogin, 10000);
|
||||||
|
|
||||||
|
const web3 = initializeWeb3(app);
|
||||||
|
|
||||||
|
export type TApp = typeof app;
|
||||||
|
export default app;
|
|
@ -0,0 +1,16 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
// backend
|
||||||
|
export interface SocialMedia {
|
||||||
|
socialMediaLink: string;
|
||||||
|
}
|
||||||
|
export interface Milestone {
|
||||||
|
body: string;
|
||||||
|
content: string;
|
||||||
|
dateCreated: string;
|
||||||
|
dateEstimated: string;
|
||||||
|
immediatePayout: boolean;
|
||||||
|
payoutPercent: string;
|
||||||
|
stage: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
export interface Proposal {
|
||||||
|
proposalId: string;
|
||||||
|
dateCreated: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
stage: string;
|
||||||
|
category: string;
|
||||||
|
milestones: Milestone[];
|
||||||
|
team: User[];
|
||||||
|
comments: Comment[];
|
||||||
|
contractStatus: string;
|
||||||
|
contract: Contract;
|
||||||
|
}
|
||||||
|
export interface Comment {
|
||||||
|
commentId: string;
|
||||||
|
dateCreated: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export interface User {
|
||||||
|
accountAddress: string;
|
||||||
|
avatar: null | { imageUrl: string };
|
||||||
|
displayName: string;
|
||||||
|
emailAddress: string;
|
||||||
|
socialMedias: SocialMedia[];
|
||||||
|
title: string;
|
||||||
|
userid: number;
|
||||||
|
proposals: Proposal[];
|
||||||
|
comments: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// web3 contract
|
||||||
|
export const INITIAL_CONTRACT_CONTRIBUTOR = {
|
||||||
|
address: '',
|
||||||
|
milestoneNoVotes: [] as string[],
|
||||||
|
contributionAmount: '',
|
||||||
|
refundVote: false,
|
||||||
|
refunded: false,
|
||||||
|
};
|
||||||
|
export type ContractContributor = typeof INITIAL_CONTRACT_CONTRIBUTOR;
|
||||||
|
export const INITIAL_CONTRACT_MILESTONE = {
|
||||||
|
amount: '',
|
||||||
|
payoutRequestVoteDeadline: '',
|
||||||
|
amountVotingAgainstPayout: '',
|
||||||
|
paid: '',
|
||||||
|
};
|
||||||
|
export type ContractMilestone = typeof INITIAL_CONTRACT_MILESTONE;
|
||||||
|
export interface ContractMethodInput {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
export const INITIAL_CONTRACT_METHOD = {
|
||||||
|
updated: '',
|
||||||
|
status: 'unloaded',
|
||||||
|
value: '' as string | string[] | ContractMilestone[] | ContractContributor[],
|
||||||
|
type: '',
|
||||||
|
input: [] as ContractMethodInput[],
|
||||||
|
error: '',
|
||||||
|
format: '',
|
||||||
|
};
|
||||||
|
export type ContractMethod = typeof INITIAL_CONTRACT_METHOD;
|
||||||
|
export const INITIAL_CONTRACT = {
|
||||||
|
isCallerTrustee: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
immediateFirstMilestonePayout: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
beneficiary: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
amountRaised: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
raiseGoal: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
isRaiseGoalReached: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
amountVotingForRefund: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
milestoneVotingPeriod: { ...INITIAL_CONTRACT_METHOD, format: 'duration' },
|
||||||
|
deadline: { ...INITIAL_CONTRACT_METHOD, format: 'time' },
|
||||||
|
isFailed: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
getBalance: { ...INITIAL_CONTRACT_METHOD, type: 'eth' },
|
||||||
|
frozen: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
getFreezeReason: { ...INITIAL_CONTRACT_METHOD },
|
||||||
|
trustees: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
|
||||||
|
contributorList: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
|
||||||
|
contributors: { ...INITIAL_CONTRACT_METHOD, type: 'deep' },
|
||||||
|
milestones: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
|
||||||
|
contribute: {
|
||||||
|
...INITIAL_CONTRACT_METHOD,
|
||||||
|
type: 'send',
|
||||||
|
input: [{ name: 'value', type: 'wei' }],
|
||||||
|
},
|
||||||
|
refund: {
|
||||||
|
...INITIAL_CONTRACT_METHOD,
|
||||||
|
type: 'send',
|
||||||
|
input: [],
|
||||||
|
},
|
||||||
|
withdraw: {
|
||||||
|
...INITIAL_CONTRACT_METHOD,
|
||||||
|
type: 'send',
|
||||||
|
input: [{ name: 'address', type: 'string' }],
|
||||||
|
},
|
||||||
|
requestMilestonePayout: {
|
||||||
|
...INITIAL_CONTRACT_METHOD,
|
||||||
|
type: 'send',
|
||||||
|
input: [{ name: 'index', type: 'integer' }],
|
||||||
|
},
|
||||||
|
voteMilestonePayout: {
|
||||||
|
...INITIAL_CONTRACT_METHOD,
|
||||||
|
type: 'send',
|
||||||
|
input: [{ name: 'index', type: 'integer' }, { name: 'vote', type: 'boolean' }],
|
||||||
|
},
|
||||||
|
payMilestonePayout: {
|
||||||
|
...INITIAL_CONTRACT_METHOD,
|
||||||
|
type: 'send',
|
||||||
|
input: [{ name: 'index', type: 'integer' }],
|
||||||
|
},
|
||||||
|
voteRefund: {
|
||||||
|
...INITIAL_CONTRACT_METHOD,
|
||||||
|
type: 'send',
|
||||||
|
input: [{ name: 'vote', type: 'boolean' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export type Contract = typeof INITIAL_CONTRACT;
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
import Web3 from 'web3';
|
||||||
|
import { TransactionObject } from 'web3/eth/types';
|
||||||
|
import EthContract from 'web3/eth/contract';
|
||||||
|
import { TApp } from './store';
|
||||||
|
import CrowdFundFactory from 'contracts/contracts/CrowdFundFactory.json';
|
||||||
|
import CrowdFund from 'contracts/contracts/CrowdFund.json';
|
||||||
|
import {
|
||||||
|
Proposal,
|
||||||
|
Contract,
|
||||||
|
INITIAL_CONTRACT,
|
||||||
|
ContractMilestone,
|
||||||
|
ContractContributor,
|
||||||
|
ContractMethodInput,
|
||||||
|
INITIAL_CONTRACT_CONTRIBUTOR,
|
||||||
|
INITIAL_CONTRACT_MILESTONE,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
type Web3Method<T> = (index: number) => TransactionObject<T>;
|
||||||
|
|
||||||
|
export function initializeWeb3(app: TApp) {
|
||||||
|
let web3 = (window as any).web3;
|
||||||
|
if (web3) {
|
||||||
|
app.web3Type = 'injected';
|
||||||
|
web3 = new Web3(web3.currentProvider);
|
||||||
|
} else if (process.env.NODE_ENV !== 'production') {
|
||||||
|
const localProviderString = 'http://localhost:8545';
|
||||||
|
const provider = new Web3.providers.HttpProvider(localProviderString);
|
||||||
|
web3 = new Web3(provider);
|
||||||
|
app.web3Type = 'local - ' + localProviderString;
|
||||||
|
} else {
|
||||||
|
console.error('No web3 detected!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getNetwork(app, web3);
|
||||||
|
getAccount(app, web3);
|
||||||
|
checkCrowdFundFactory(app, web3);
|
||||||
|
window.setInterval(() => getAccount(app, web3), 10000);
|
||||||
|
return web3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNetwork(app: TApp, web3: Web3) {
|
||||||
|
web3.eth.net.getId((_, netId) => {
|
||||||
|
app.ethNetId = netId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccount(app: TApp, web3: Web3) {
|
||||||
|
await web3.eth.getAccounts((_, accounts) => {
|
||||||
|
app.ethAccount = (accounts.length && accounts[0]) || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCrowdFundFactory(app: TApp, web3: Web3) {
|
||||||
|
web3.eth.net.getId((_, netId) => {
|
||||||
|
const networks = Object.keys((CrowdFundFactory as any).networks).join(', ');
|
||||||
|
if (!(CrowdFundFactory as any).networks[netId]) {
|
||||||
|
app.crowdFundFactoryDefinitionStatus = `network mismatch (has ${networks})`;
|
||||||
|
} else {
|
||||||
|
app.crowdFundFactoryDefinitionStatus = `loaded, has correct network (${networks})`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proposalContractSend(
|
||||||
|
app: TApp,
|
||||||
|
web3: Web3,
|
||||||
|
proposalId: string,
|
||||||
|
methodName: keyof Contract,
|
||||||
|
inputs: ContractMethodInput[],
|
||||||
|
args: any[],
|
||||||
|
) {
|
||||||
|
const storeProposal = app.proposals.find(p => p.proposalId === proposalId);
|
||||||
|
if (storeProposal) {
|
||||||
|
await getAccount(app, web3);
|
||||||
|
const storeMethod = storeProposal.contract[methodName];
|
||||||
|
const contract = new web3.eth.Contract(CrowdFund.abi, proposalId);
|
||||||
|
app.crowdFundGeneralStatus = `calling (${storeProposal.title}).${methodName}...`;
|
||||||
|
try {
|
||||||
|
console.log(args);
|
||||||
|
storeMethod.status = 'loading';
|
||||||
|
storeMethod.error = '';
|
||||||
|
if (inputs.length === 1 && inputs[0].name === 'value') {
|
||||||
|
await contract.methods[methodName]()
|
||||||
|
.send({
|
||||||
|
from: app.ethAccount,
|
||||||
|
value: args[0],
|
||||||
|
})
|
||||||
|
.once('transactionHash', () => {
|
||||||
|
storeMethod.status = 'waiting';
|
||||||
|
})
|
||||||
|
.once('confirmation', () => {
|
||||||
|
storeMethod.status = 'loaded';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await contract.methods[methodName](...args)
|
||||||
|
.send({ from: app.ethAccount })
|
||||||
|
.once('transactionHash', () => {
|
||||||
|
storeMethod.status = 'waiting';
|
||||||
|
})
|
||||||
|
.once('confirmation', () => {
|
||||||
|
storeMethod.status = 'loaded';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
storeMethod.error = e.message || e.toString();
|
||||||
|
storeMethod.status = 'error';
|
||||||
|
}
|
||||||
|
app.crowdFundGeneralStatus = `idle`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function populateProposalContract(
|
||||||
|
app: TApp,
|
||||||
|
web3: Web3,
|
||||||
|
proposalId: string,
|
||||||
|
) {
|
||||||
|
const storeProposal = app.proposals.find(p => p.proposalId === proposalId);
|
||||||
|
const contract = new web3.eth.Contract(CrowdFund.abi, proposalId);
|
||||||
|
|
||||||
|
if (storeProposal) {
|
||||||
|
storeProposal.contractStatus = 'loading...';
|
||||||
|
const methods = Object.keys(INITIAL_CONTRACT).map(k => k as keyof Contract);
|
||||||
|
for (const method of methods) {
|
||||||
|
const methodType = INITIAL_CONTRACT[method].type;
|
||||||
|
if (methodType !== 'deep' && methodType !== 'send') {
|
||||||
|
app.crowdFundGeneralStatus = `calling (${storeProposal.title}).${method}...`;
|
||||||
|
const storeMethod = storeProposal.contract[method];
|
||||||
|
const contractMethod = contract.methods[method];
|
||||||
|
try {
|
||||||
|
storeMethod.status = 'loading';
|
||||||
|
if (methodType === 'eth' && method === 'getBalance') {
|
||||||
|
storeMethod.value = (await web3.eth.getBalance(proposalId)) + '';
|
||||||
|
} else if (methodType === 'array') {
|
||||||
|
const result = await collectArrayElements(contractMethod, app.ethAccount);
|
||||||
|
if (method === 'milestones') {
|
||||||
|
storeMethod.value = result.map(r =>
|
||||||
|
// clean-up incoming object before attaching to store
|
||||||
|
cleanClone(INITIAL_CONTRACT_MILESTONE, r),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
storeMethod.value = result.map(r => r + '');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
storeMethod.value =
|
||||||
|
(await contractMethod().call({ from: app.ethAccount })) + '';
|
||||||
|
}
|
||||||
|
storeMethod.status = 'loaded';
|
||||||
|
} catch (e) {
|
||||||
|
console.error(proposalId, method, e);
|
||||||
|
storeMethod.status = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await populateProposalContractDeep(storeProposal.contract, contract, app.ethAccount);
|
||||||
|
storeProposal.contractStatus = 'updated @ ' + new Date().toISOString();
|
||||||
|
app.crowdFundGeneralStatus = `idle`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateProposalContractDeep(
|
||||||
|
storeContract: Proposal['contract'],
|
||||||
|
contract: EthContract,
|
||||||
|
fromAcct: string,
|
||||||
|
) {
|
||||||
|
storeContract.contributors.status = 'loading';
|
||||||
|
const milestones = storeContract.milestones.value as ContractMilestone[];
|
||||||
|
const contributorList = storeContract.contributorList.value as string[];
|
||||||
|
const contributors = await Promise.all(
|
||||||
|
contributorList.map(async addr => {
|
||||||
|
const contributor = await contract.methods
|
||||||
|
.contributors(addr)
|
||||||
|
.call({ from: fromAcct });
|
||||||
|
contributor.address = addr;
|
||||||
|
contributor.milestoneNoVotes = await Promise.all(
|
||||||
|
milestones.map(
|
||||||
|
async (_, idx) =>
|
||||||
|
await contract.methods
|
||||||
|
.getContributorMilestoneVote(addr, idx)
|
||||||
|
.call({ from: fromAcct }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
contributor.contributionAmount = await contract.methods
|
||||||
|
.getContributorContributionAmount(addr)
|
||||||
|
.call({ from: fromAcct });
|
||||||
|
// clean-up incoming object before attaching to store
|
||||||
|
return cleanClone(INITIAL_CONTRACT_CONTRIBUTOR, contributor);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
storeContract.contributors.value = contributors as ContractContributor[];
|
||||||
|
storeContract.contributors.status = 'loaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone and filter keys by keySource object's keys
|
||||||
|
export function cleanClone<T extends object>(keySource: T, target: Partial<T>) {
|
||||||
|
const sourceKeys = Object.keys(keySource);
|
||||||
|
const fullClone = { ...(target as object) };
|
||||||
|
const clone = pick(fullClone, sourceKeys);
|
||||||
|
return clone as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectArrayElements<T>(
|
||||||
|
method: Web3Method<T>,
|
||||||
|
account: string,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const arrayElements = [];
|
||||||
|
let noError = true;
|
||||||
|
let index = 0;
|
||||||
|
while (noError) {
|
||||||
|
try {
|
||||||
|
arrayElements.push(await method(index).call({ from: account }));
|
||||||
|
index += 1;
|
||||||
|
} catch (e) {
|
||||||
|
noError = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arrayElements;
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "esnext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"jsx": "react",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"preserveConstEnums": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": "src",
|
||||||
|
"lib": ["dom", "es2017"],
|
||||||
|
"paths": {
|
||||||
|
"src/*": ["./*"],
|
||||||
|
"contracts/*": ["../../contract/build/*"],
|
||||||
|
"components/*": ["./components/*"],
|
||||||
|
"styles/*": ["./styles/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["./src/**/*"],
|
||||||
|
"exclude": ["./src/static"]
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
|
||||||
|
"rules": {
|
||||||
|
"ordered-imports": false,
|
||||||
|
"object-literal-sort-keys": false,
|
||||||
|
"jsx-boolean-value": false,
|
||||||
|
"jsx-no-lambda": false,
|
||||||
|
"member-access": false,
|
||||||
|
"interface-name": [true, "never-prefix"],
|
||||||
|
"curly": [true, "ignore-same-line"],
|
||||||
|
"no-console": false
|
||||||
|
},
|
||||||
|
"linterOptions": {
|
||||||
|
"exclude": ["../contract/build/contracts/**/*.json"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
const path = require('path');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
const CleanWebpackPlugin = require('clean-webpack-plugin');
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: isDev ? 'development' : 'production',
|
||||||
|
entry: {
|
||||||
|
bundle: './src/index.tsx',
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, 'build'),
|
||||||
|
filename: 'bundle.js',
|
||||||
|
publicPath: '/',
|
||||||
|
chunkFilename: isDev ? '[name].chunk.js' : '[name].[chunkhash:8].chunk.js',
|
||||||
|
},
|
||||||
|
devtool: 'inline-source-map',
|
||||||
|
devServer: {
|
||||||
|
port: 3500,
|
||||||
|
contentBase: './build',
|
||||||
|
hot: true,
|
||||||
|
historyApiFallback: {
|
||||||
|
disableDotRule: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
// typescript
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: 'babel-loader',
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
'react-hot-loader/babel',
|
||||||
|
'@babel/plugin-proposal-object-rest-spread',
|
||||||
|
'@babel/plugin-proposal-class-properties',
|
||||||
|
['import', { libraryName: 'antd', style: true }],
|
||||||
|
],
|
||||||
|
presets: ['@babel/react', ['@babel/env', { useBuiltIns: 'entry' }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: 'ts-loader',
|
||||||
|
options: { transpileOnly: isDev },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// less
|
||||||
|
{
|
||||||
|
test: /\.less$/,
|
||||||
|
// exclude: [/node_modules/],
|
||||||
|
use: [
|
||||||
|
isDev && 'style-loader',
|
||||||
|
!isDev && MiniCssExtractPlugin.loader,
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: 'less-loader',
|
||||||
|
options: { javascriptEnabled: true },
|
||||||
|
},
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
|
// images (url loader)
|
||||||
|
{
|
||||||
|
test: /\.(png|jpe?g|gif)$/,
|
||||||
|
loader: require.resolve('url-loader'),
|
||||||
|
options: {
|
||||||
|
limit: 2048,
|
||||||
|
name: 'assets/[name].[hash:8].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// svg
|
||||||
|
{
|
||||||
|
test: /\.svg$/,
|
||||||
|
issuer: {
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
},
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: '@svgr/webpack',
|
||||||
|
options: {
|
||||||
|
svgoConfig: {
|
||||||
|
plugins: [{ inlineStyles: { onlyMatchedOnce: false } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], // svg -> react component
|
||||||
|
},
|
||||||
|
// other files (file loader)
|
||||||
|
{
|
||||||
|
exclude: [/\.(js|ts|tsx|css|less|mjs|html|json|ejs)$/],
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: 'file-loader',
|
||||||
|
options: {
|
||||||
|
name: 'assets/[name].[hash:8].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.ts', '.tsx', '.js', '.mjs', '.json'],
|
||||||
|
// resolveModules = [ './src', path.join(__dirname, 'node_modules')]
|
||||||
|
// tsconfig.compilerOptions.paths should sync with these
|
||||||
|
alias: {
|
||||||
|
src: path.resolve(__dirname, 'src'),
|
||||||
|
contracts: path.resolve(__dirname, '../contract/build'),
|
||||||
|
components: path.resolve(__dirname, 'src/components'),
|
||||||
|
styles: path.resolve(__dirname, 'src/styles'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.HotModuleReplacementPlugin(),
|
||||||
|
new CleanWebpackPlugin(['build']),
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
'process.env.BACKEND_URL': JSON.stringify(process.env.BACKEND_URL),
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: './src/static/index.html',
|
||||||
|
}),
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename:
|
||||||
|
process.env.NODE_ENV === 'development' ? '[name].css' : '[name].[hash:8].css',
|
||||||
|
chunkFilename:
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? '[name].chunk.css'
|
||||||
|
: '[name].[chunkhash:8].chunk.css',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1 @@
|
||||||
|
from . import views
|
|
@ -0,0 +1,133 @@
|
||||||
|
from functools import wraps
|
||||||
|
from flask import Blueprint, g, jsonify, session
|
||||||
|
from flask_yoloapi import endpoint, parameter
|
||||||
|
from hashlib import sha256
|
||||||
|
from uuid import uuid4
|
||||||
|
from flask_cors import CORS, cross_origin
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from grant.extensions import db
|
||||||
|
from grant.user.models import User, users_schema
|
||||||
|
from grant.proposal.models import Proposal, proposals_schema
|
||||||
|
from grant.comment.models import Comment, comments_schema
|
||||||
|
|
||||||
|
|
||||||
|
blueprint = Blueprint('admin', __name__, url_prefix='/api/v1/admin')
|
||||||
|
|
||||||
|
|
||||||
|
admin_auth = {
|
||||||
|
"username": "admin",
|
||||||
|
"password": "79994491a17ec1d817fb0330303ea88880835961fbab1d12329f5d720602fbb3",
|
||||||
|
"salt": "ad01deb1ccba4d0e8b831ed3d1e82c10"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def auth_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if 'username' in session:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
return {"message": "Authentication required"}, 401
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/checklogin", methods=["GET"])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api()
|
||||||
|
def loggedin():
|
||||||
|
if 'username' in session:
|
||||||
|
return {"isLoggedIn": True}
|
||||||
|
if 'username' not in session:
|
||||||
|
return {"isLoggedIn": False}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/login", methods=["POST"])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('username', type=str, required=False),
|
||||||
|
parameter('password', type=str, required=False),
|
||||||
|
)
|
||||||
|
def login(username, password):
|
||||||
|
pass_salt = ('%s%s' % (password, admin_auth['salt'])).encode('utf-8')
|
||||||
|
pass_hash = sha256(pass_salt).hexdigest()
|
||||||
|
if username == admin_auth['username'] and pass_hash == admin_auth['password']:
|
||||||
|
session['username'] = username
|
||||||
|
return {"isLoggedIn": True}
|
||||||
|
else:
|
||||||
|
return {"message": "Username or password incorrect."}, 401
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/logout", methods=["GET"])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api()
|
||||||
|
def logout():
|
||||||
|
del session['username']
|
||||||
|
return {"isLoggedIn": False}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/stats", methods=["GET"])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api()
|
||||||
|
@auth_required
|
||||||
|
def stats():
|
||||||
|
user_count = db.session.query(func.count(User.id)).scalar()
|
||||||
|
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
|
||||||
|
return {
|
||||||
|
"userCount": user_count,
|
||||||
|
"proposalCount": proposal_count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/users/<id>', methods=['DELETE'])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api()
|
||||||
|
@auth_required
|
||||||
|
def delete_user(id):
|
||||||
|
user = User.get_by_email_or_account_address(email_address=id, account_address=id)
|
||||||
|
# TODO: fix delete
|
||||||
|
if user:
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
return {}, 204
|
||||||
|
return {"message": "No such user."}, 404
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/users", methods=["GET"])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api()
|
||||||
|
@auth_required
|
||||||
|
def get_users():
|
||||||
|
users = User.query.all()
|
||||||
|
result = users_schema.dump(users)
|
||||||
|
for user in result:
|
||||||
|
user_proposals = Proposal.query.filter(Proposal.team.any(id=user['userid'])).all()
|
||||||
|
user['proposals'] = proposals_schema.dump(user_proposals)
|
||||||
|
user_comments = Comment.query.filter(Comment.user_id == user['userid']).all()
|
||||||
|
user['comments'] = comments_schema.dump(user_comments)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/proposals", methods=["GET"])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api()
|
||||||
|
@auth_required
|
||||||
|
def get_proposals():
|
||||||
|
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
|
||||||
|
dumped_proposals = proposals_schema.dump(proposals)
|
||||||
|
return dumped_proposals
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/proposals/<id>', methods=['DELETE'])
|
||||||
|
@cross_origin(supports_credentials=True)
|
||||||
|
@endpoint.api()
|
||||||
|
@auth_required
|
||||||
|
def delete_proposal(id):
|
||||||
|
proposal = Proposal.query.filter_by(proposal_id=id).first()
|
||||||
|
if proposal:
|
||||||
|
# TODO: fix delete function
|
||||||
|
db.session.delete(proposal)
|
||||||
|
db.session.commit()
|
||||||
|
return {}, 204
|
||||||
|
return {"message": "No such proposal."}, 404
|
|
@ -3,7 +3,7 @@
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from grant import commands, proposal, user, comment, milestone
|
from grant import commands, proposal, user, comment, milestone, admin
|
||||||
from grant.extensions import bcrypt, migrate, db, ma, mail
|
from grant.extensions import bcrypt, migrate, db, ma, mail
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,6 +34,7 @@ def register_blueprints(app):
|
||||||
app.register_blueprint(proposal.views.blueprint)
|
app.register_blueprint(proposal.views.blueprint)
|
||||||
app.register_blueprint(user.views.blueprint)
|
app.register_blueprint(user.views.blueprint)
|
||||||
app.register_blueprint(milestone.views.blueprint)
|
app.register_blueprint(milestone.views.blueprint)
|
||||||
|
app.register_blueprint(admin.views.blueprint)
|
||||||
|
|
||||||
|
|
||||||
def register_shellcontext(app):
|
def register_shellcontext(app):
|
||||||
|
|
Loading…
Reference in New Issue