commit
35d7139a88
|
@ -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
|
@ -4,3 +4,4 @@ FLASK_ENV=development
|
|||
DATABASE_URL="sqlite:////tmp/dev.db"
|
||||
REDISTOGO_URL="redis://localhost:6379"
|
||||
SECRET_KEY="not-so-secret"
|
||||
SENDGRID_API_KEY="optional, but emails won't send without it"
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
This is the backend component of [Grant.io](http://grant.io).
|
||||
|
||||
|
||||
## Database Setup
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Run the following commands to bootstrap your environment.
|
||||
Note: db setup is configured in .env when running locally. SQLLite is used by default in /tmp/
|
||||
|
@ -19,6 +17,11 @@ Note: db setup is configured in .env when running locally. SQLLite is used by de
|
|||
# Create environment variables file, edit as needed
|
||||
cp .env.example .env
|
||||
|
||||
If you want emails to work properly, you'll both need a SendGrid secret api key in `.env`,
|
||||
and if you’re running Python 3.6+ on macOS, you'll need to
|
||||
[fix your certificates](https://stackoverflow.com/a/42334357).
|
||||
|
||||
## Database Setup
|
||||
|
||||
Once you have installed your DBMS, run the following to create your app's
|
||||
database tables and perform the initial migration
|
||||
|
@ -28,7 +31,7 @@ database tables and perform the initial migration
|
|||
|
||||
|
||||
## Running the App
|
||||
Depending on what you need to run, there are several servies that need to be started
|
||||
Depending on what you need to run, there are several services that need to be started
|
||||
|
||||
If you just need the API, you can run
|
||||
|
||||
|
@ -82,3 +85,11 @@ To create a proposal, run
|
|||
|
||||
flask create_proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"
|
||||
|
||||
## External Services
|
||||
To decode EIP-712 signed messages, a Grant.io deployed service was created `https://eip-712.herokuapp.com`.
|
||||
|
||||
To adjust this endpoint, simply export `AUTH_URL` with a new endpoint value:
|
||||
|
||||
export AUTH_URL=http://new-endpoint.com
|
||||
|
||||
To learn more about this auth service, you can visit the repo [here](https://github.com/grant-project/eip-712-server).
|
|
@ -1,72 +0,0 @@
|
|||
import copy
|
||||
import re
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def _camel_dict(dict_obj, deep=True):
|
||||
converted_dict_obj = {}
|
||||
for snake_case_k in dict_obj:
|
||||
camel_case_k = re.sub('_([a-z])', lambda match: match.group(1).upper(), snake_case_k)
|
||||
value = dict_obj[snake_case_k]
|
||||
|
||||
if type(value) == dict and deep:
|
||||
converted_dict_obj[camel_case_k] = camel(**value)
|
||||
elif type(value) == list and deep:
|
||||
converted_list_items = []
|
||||
for item in value:
|
||||
converted_list_items.append(camel(**item))
|
||||
converted_dict_obj[camel_case_k] = converted_list_items
|
||||
else:
|
||||
converted_dict_obj[camel_case_k] = dict_obj[snake_case_k]
|
||||
return converted_dict_obj
|
||||
|
||||
|
||||
def camel(dict_or_list_obj=None, **kwargs):
|
||||
dict_or_list_obj = kwargs if kwargs else dict_or_list_obj
|
||||
deep = True
|
||||
if type(dict_or_list_obj) == dict:
|
||||
return _camel_dict(dict_obj=dict_or_list_obj, deep=deep)
|
||||
elif type(dict_or_list_obj) == list or type(dict_or_list_obj) == tuple or type(dict_or_list_obj) == map:
|
||||
return list(map(_camel_dict, list(dict_or_list_obj)))
|
||||
else:
|
||||
raise ValueError("type {} is not supported!".format(type(dict_or_list_obj)))
|
||||
|
||||
|
||||
"""
|
||||
JSONResponse allows several argument formats:
|
||||
1. JSONResponse([{"userId": 1, "name": "John" }, {"userId": 2, "name": "Dave" }])
|
||||
2. JSONResponse(result=[my_results])
|
||||
|
||||
JSONResponse does not accept the following:
|
||||
1. Intermixed positional and keyword arguments: JSONResponse(some_data, wow=True)
|
||||
1a. The exception to this is _statusCode, which is allowed to be mixed.
|
||||
An HTTP Status code should be set here by the caller, or 200 will be used.
|
||||
1. Multiple positional arguments: JSONResponse(some_data, other_data)
|
||||
"""
|
||||
|
||||
|
||||
# TODO - use something standard. Insane that it's so hard to camelCase JSON output
|
||||
def JSONResponse(*args, **kwargs):
|
||||
if args:
|
||||
if len(args) > 1:
|
||||
raise ValueError("Only one positional arg supported")
|
||||
|
||||
if kwargs.get("_statusCode"):
|
||||
status = copy.copy(kwargs["_statusCode"])
|
||||
del kwargs["_statusCode"]
|
||||
else:
|
||||
status = 200
|
||||
|
||||
if args and kwargs:
|
||||
raise ValueError("Only positional args or keyword args supported, not both")
|
||||
|
||||
if not kwargs and not args:
|
||||
# TODO add log. This should never happen
|
||||
return jsonify({}), 500
|
||||
|
||||
if kwargs:
|
||||
return jsonify(camel(**kwargs)), status
|
||||
|
||||
else:
|
||||
return jsonify(camel(args[0])), status
|
|
@ -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,8 +3,8 @@
|
|||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
|
||||
from grant import commands, proposal, user, comment, milestone
|
||||
from grant.extensions import bcrypt, migrate, db, ma
|
||||
from grant import commands, proposal, user, comment, milestone, admin
|
||||
from grant.extensions import bcrypt, migrate, db, ma, mail
|
||||
|
||||
|
||||
def create_app(config_object="grant.settings"):
|
||||
|
@ -23,6 +23,7 @@ def register_extensions(app):
|
|||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
ma.init_app(app)
|
||||
mail.init_app(app)
|
||||
CORS(app)
|
||||
return None
|
||||
|
||||
|
@ -33,6 +34,8 @@ def register_blueprints(app):
|
|||
app.register_blueprint(proposal.views.blueprint)
|
||||
app.register_blueprint(user.views.blueprint)
|
||||
app.register_blueprint(milestone.views.blueprint)
|
||||
app.register_blueprint(admin.views.blueprint)
|
||||
|
||||
|
||||
def register_shellcontext(app):
|
||||
"""Register shell context objects."""
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from flask import Blueprint
|
||||
from grant import JSONResponse
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from animal_case import animalify
|
||||
|
||||
from .models import Comment, comments_schema
|
||||
|
||||
|
@ -11,4 +10,4 @@ blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
|
|||
def get_comments():
|
||||
all_comments = Comment.query.all()
|
||||
result = comments_schema.dump(all_comments)
|
||||
return JSONResponse(result)
|
||||
return jsonify(animalify(result))
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
from flask import render_template, Markup
|
||||
|
||||
from grant.extensions import mail
|
||||
|
||||
default_template_args = {
|
||||
'home_url': 'https://grant.io',
|
||||
'account_url': 'https://grant.io/user',
|
||||
'email_settings_url': 'https://grant.io/user/settings',
|
||||
'unsubscribe_url': 'https://grant.io/unsubscribe',
|
||||
}
|
||||
|
||||
email_template_args = {
|
||||
'signup': {
|
||||
'subject': 'Confirm your email on Grant.io',
|
||||
'title': 'Welcome to Grant.io!',
|
||||
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def send_email(to, type, email_args):
|
||||
try:
|
||||
body_text = render_template('emails/%s.txt' % (type), args=email_args)
|
||||
body_html = render_template('emails/%s.html' % (type), args=email_args)
|
||||
|
||||
html = render_template('emails/template.html', args={
|
||||
**default_template_args,
|
||||
**email_template_args[type],
|
||||
'body': Markup(body_html),
|
||||
})
|
||||
text = render_template('emails/template.txt', args={
|
||||
**default_template_args,
|
||||
**email_template_args[type],
|
||||
'body': body_text,
|
||||
})
|
||||
|
||||
res = mail.send_email(
|
||||
to_email=to,
|
||||
subject=email_template_args[type]['subject'],
|
||||
text=text,
|
||||
html=html,
|
||||
)
|
||||
print('Just sent an email to %s of type %s, response code: %s' % (to, type, res.status_code))
|
||||
except Exception as e:
|
||||
print('An error occured while sending an email to %s - %s: %s' % (to, e.__class__.__name__, e))
|
|
@ -4,8 +4,10 @@ from flask_bcrypt import Bcrypt
|
|||
from flask_marshmallow import Marshmallow
|
||||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_sendgrid import SendGrid
|
||||
|
||||
bcrypt = Bcrypt()
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
ma = Marshmallow()
|
||||
mail = SendGrid()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from flask import Blueprint
|
||||
from flask import Blueprint, jsonify
|
||||
from animal_case import animalify
|
||||
|
||||
|
||||
from grant import JSONResponse
|
||||
from .models import Milestone, milestones_schema
|
||||
|
||||
blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
|
||||
|
@ -10,4 +11,4 @@ blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
|
|||
def get_users():
|
||||
milestones = Milestone.query.all()
|
||||
result = milestones_schema.dump(milestones)
|
||||
return JSONResponse(result)
|
||||
return jsonify(animalify(result))
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request
|
||||
from animal_case import animalify
|
||||
from flask import Blueprint, jsonify
|
||||
from flask_yoloapi import endpoint, parameter
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from grant import JSONResponse
|
||||
from grant.comment.models import Comment, comment_schema
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.user.models import User, SocialMedia, Avatar
|
||||
|
@ -17,9 +18,9 @@ def get_proposal(proposal_id):
|
|||
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
|
||||
if proposal:
|
||||
dumped_proposal = proposal_schema.dump(proposal)
|
||||
return JSONResponse(dumped_proposal)
|
||||
return jsonify(animalify(dumped_proposal))
|
||||
else:
|
||||
return JSONResponse(message="No proposal matching id", _statusCode=404)
|
||||
return jsonify(message="No proposal matching id"), 404
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
|
||||
|
@ -27,22 +28,23 @@ def get_proposal_comments(proposal_id):
|
|||
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
|
||||
if proposal:
|
||||
dumped_proposal = proposal_schema.dump(proposal)
|
||||
return JSONResponse(
|
||||
return jsonify(animalify(
|
||||
proposal_id=proposal_id,
|
||||
total_comments=len(dumped_proposal["comments"]),
|
||||
comments=dumped_proposal["comments"]
|
||||
)
|
||||
))
|
||||
else:
|
||||
return JSONResponse(message="No proposal matching id", _statusCode=404)
|
||||
return jsonify(message="No proposal matching id", _statusCode=404)
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
||||
def post_proposal_comments(proposal_id):
|
||||
@endpoint.api(
|
||||
parameter('userId', type=int, required=True),
|
||||
parameter('content', type=str, required=True)
|
||||
)
|
||||
def post_proposal_comments(proposal_id, user_id, content):
|
||||
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
|
||||
if proposal:
|
||||
incoming = request.get_json()
|
||||
user_id = incoming["userId"]
|
||||
content = incoming["content"]
|
||||
user = User.query.filter_by(id=user_id).first()
|
||||
|
||||
if user:
|
||||
|
@ -54,17 +56,19 @@ def post_proposal_comments(proposal_id):
|
|||
db.session.add(comment)
|
||||
db.session.commit()
|
||||
dumped_comment = comment_schema.dump(comment)
|
||||
return JSONResponse(dumped_comment, _statusCode=201)
|
||||
return dumped_comment, 201
|
||||
|
||||
else:
|
||||
return JSONResponse(message="No user matching id", _statusCode=404)
|
||||
return {"message": "No user matching id"}, 404
|
||||
else:
|
||||
return JSONResponse(message="No proposal matching id", _statusCode=404)
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
|
||||
@blueprint.route("/", methods=["GET"])
|
||||
def get_proposals():
|
||||
stage = request.args.get("stage")
|
||||
@endpoint.api(
|
||||
parameter('stage', type=str, required=False)
|
||||
)
|
||||
def get_proposals(stage):
|
||||
if stage:
|
||||
proposals = (
|
||||
Proposal.query.filter_by(stage=stage)
|
||||
|
@ -74,24 +78,27 @@ def get_proposals():
|
|||
else:
|
||||
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
|
||||
dumped_proposals = proposals_schema.dump(proposals)
|
||||
return JSONResponse(dumped_proposals)
|
||||
return dumped_proposals
|
||||
|
||||
|
||||
@blueprint.route("/", methods=["POST"])
|
||||
def make_proposal():
|
||||
@endpoint.api(
|
||||
parameter('crowdFundContractAddress', type=str, required=True),
|
||||
parameter('content', type=str, required=True),
|
||||
parameter('title', type=str, required=True),
|
||||
parameter('milestones', type=list, required=True),
|
||||
parameter('category', type=str, required=True),
|
||||
parameter('team', type=list, required=True)
|
||||
)
|
||||
def make_proposal(crowd_fund_contract_address, content, title, milestones, category, team):
|
||||
from grant.user.models import User
|
||||
|
||||
incoming = request.get_json()
|
||||
|
||||
proposal_id = incoming["crowdFundContractAddress"]
|
||||
content = incoming["content"]
|
||||
title = incoming["title"]
|
||||
milestones = incoming["milestones"]
|
||||
category = incoming["category"]
|
||||
existing_proposal = Proposal.query.filter_by(proposal_id=crowd_fund_contract_address).first()
|
||||
if existing_proposal:
|
||||
return {"message": "Oops! Something went wrong."}, 409
|
||||
|
||||
proposal = Proposal.create(
|
||||
stage="FUNDING_REQUIRED",
|
||||
proposal_id=proposal_id,
|
||||
proposal_id=crowd_fund_contract_address,
|
||||
content=content,
|
||||
title=title,
|
||||
category=category
|
||||
|
@ -99,9 +106,8 @@ def make_proposal():
|
|||
|
||||
db.session.add(proposal)
|
||||
|
||||
team = incoming["team"]
|
||||
if not len(team) > 0:
|
||||
return JSONResponse(message="Team must be at least 1", _statusCode=400)
|
||||
return {"message": "Team must be at least 1"}, 400
|
||||
|
||||
for team_member in team:
|
||||
account_address = team_member.get("accountAddress")
|
||||
|
@ -149,7 +155,7 @@ def make_proposal():
|
|||
db.session.commit()
|
||||
except IntegrityError as e:
|
||||
print(e)
|
||||
return JSONResponse(message="Proposal with that hash already exists", _statusCode=409)
|
||||
return {"message": "Oops! Something went wrong."}, 409
|
||||
|
||||
results = proposal_schema.dump(proposal)
|
||||
return JSONResponse(results, _statusCode=201)
|
||||
return results, 201
|
||||
|
|
|
@ -13,6 +13,7 @@ env.read_env()
|
|||
|
||||
ENV = env.str("FLASK_ENV", default="production")
|
||||
DEBUG = ENV == "development"
|
||||
AUTH_URL = env.str('AUTH_URL', default='https://eip-712.herokuapp.com')
|
||||
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
|
||||
QUEUES = ["default"]
|
||||
SECRET_KEY = env.str("SECRET_KEY")
|
||||
|
@ -21,3 +22,5 @@ DEBUG_TB_ENABLED = DEBUG
|
|||
DEBUG_TB_INTERCEPT_REDIRECTS = False
|
||||
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
||||
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
|
|
@ -0,0 +1,31 @@
|
|||
<p style="margin: 0;">
|
||||
We're excited to have you get started. First, you need to confirm your email address. Just click the button below.
|
||||
</p>
|
||||
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="#1890ff">
|
||||
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 40px; border-radius: 4px; border: 1px solid #1890ff; display: inline-block;">
|
||||
Confirm Account
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px; font-size: 16px;">
|
||||
If that doesn't work, copy and paste the following link in your browser:
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; font-size: 12px;">
|
||||
<a href="{{ args.confirm_url }}" target="_blank" style="color: #1890ff;">{{ args.confirm_url }}</a>
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 10px; color: #AAA; text-align: center;">
|
||||
Don’t know why you got this email? Don’t worry, you can safely ignore it. We won’t send you anymore.
|
||||
</p>
|
|
@ -0,0 +1,5 @@
|
|||
We're excited to have you get started. First, you need to confirm your email address. Just go to the URL below:
|
||||
|
||||
{{ args.confirm_url }}
|
||||
|
||||
Don’t know why you got this email? Don’t worry, you can safely ignore it. We won’t send you anymore.
|
|
@ -0,0 +1,175 @@
|
|||
<!-- THIS EMAIL WAS BUILT AND TESTED WITH LITMUS http://litmus.com -->
|
||||
<!-- IT WAS RELEASED UNDER THE MIT LICENSE https://opensource.org/licenses/MIT -->
|
||||
<!-- QUESTIONS? TWEET US @LITMUSAPP -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<style type="text/css">
|
||||
/* FONTS */
|
||||
@import url('https://fonts.googleapis.com/css?family=Nunito+Sans');
|
||||
|
||||
/* CLIENT-SPECIFIC STYLES */
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; }
|
||||
|
||||
/* RESET STYLES */
|
||||
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||
table { border-collapse: collapse !important; }
|
||||
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||
|
||||
/* iOS BLUE LINKS */
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
}
|
||||
|
||||
/* MOBILE STYLES */
|
||||
@media screen and (max-width:600px){
|
||||
h1 {
|
||||
font-size: 32px !important;
|
||||
line-height: 32px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ANDROID CENTER FIX */
|
||||
div[style*="margin: 16px 0;"] { margin: 0 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: #f4f4f4; margin: 0 !important; padding: 0 !important;">
|
||||
|
||||
<!-- HIDDEN PREHEADER TEXT -->
|
||||
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
|
||||
{{ args.preview }}
|
||||
</div>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<!-- LOGO -->
|
||||
<tr>
|
||||
<td bgcolor="#4a4a4a" align="center">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
|
||||
<tr>
|
||||
<td align="center" valign="top" width="600">
|
||||
<![endif]-->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
|
||||
<tr>
|
||||
<td align="center" valign="top" style="padding: 40px 10px 40px 10px;">
|
||||
<a href="{{ args.home_url }}" target="_blank">
|
||||
<img alt="Logo" src="https://i.imgur.com/t0DPkyl.png" width="120" height="44" style="display: block; width: 150px; max-width: 150px; min-width: 150px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;" border="0">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<!-- TITLE -->
|
||||
<tr>
|
||||
<td bgcolor="#4a4a4a" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
|
||||
<tr>
|
||||
<td align="center" valign="top" width="600">
|
||||
<![endif]-->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
|
||||
<h1 style="font-size: 42px; font-weight: 400; margin: 0;">
|
||||
{{ args.title }}
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<!-- BODY -->
|
||||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
|
||||
<tr>
|
||||
<td align="center" valign="top" width="600">
|
||||
<![endif]-->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="padding: 20px 30px 40px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px; border-radius: 0px 0px 4px 4px;" >
|
||||
{{ args.body }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
|
||||
<tr>
|
||||
<td align="center" valign="top" width="600">
|
||||
<![endif]-->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
|
||||
<!-- NAVIGATION -->
|
||||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 30px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
|
||||
<p style="margin: 0;">
|
||||
<a href="{{ args.home_url }}" target="_blank" style="color: #111111; font-weight: 700;">Grant.io</a> -
|
||||
<a href="{{ args.account_url }}" target="_blank" style="color: #111111; font-weight: 700;">Your Account</a> -
|
||||
<a href="{{ args.email_settings_url }}" target="_blank" style="color: #111111; font-weight: 700;">Email Settings</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- UNSUBSCRIBE -->
|
||||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
|
||||
<p style="margin: 0;">
|
||||
Don’t want anymore emails?
|
||||
<a href="{{ args.unsubscribe_url }}" target="_blank" style="color: #111111; font-weight: 700;">
|
||||
Click here to unsubscribe
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ADDRESS -->
|
||||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 30px 30px 30px; color: #AAAAAA; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; line-height: 18px;" >
|
||||
<p style="margin: 0;">
|
||||
Grant.io Inc, 123 Address Street, Somewhere, NY 11211
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,9 @@
|
|||
{{ body }}
|
||||
|
||||
===============
|
||||
|
||||
Grant.io
|
||||
123 Address Street
|
||||
City, ST 12345
|
||||
|
||||
Unsubscribe here: https://grant.io/unsubscribe
|
|
@ -52,6 +52,16 @@ class User(db.Model):
|
|||
self.display_name = display_name
|
||||
self.title = title
|
||||
|
||||
@staticmethod
|
||||
def get_by_email_or_account_address(email_address: str = None, account_address: str = None):
|
||||
if not email_address and not account_address:
|
||||
raise ValueError("Either email_address or account_address is required to get a user")
|
||||
|
||||
return User.query.filter(
|
||||
(User.account_address == account_address) |
|
||||
(User.email_address == email_address)
|
||||
).first()
|
||||
|
||||
|
||||
class UserSchema(ma.Schema):
|
||||
class Meta:
|
||||
|
@ -86,6 +96,7 @@ class SocialMediaSchema(ma.Schema):
|
|||
# Fields to expose
|
||||
fields = ("social_media_link",)
|
||||
|
||||
|
||||
social_media_schema = SocialMediaSchema()
|
||||
social_media_schemas = SocialMediaSchema(many=True)
|
||||
|
||||
|
|
|
@ -1,52 +1,59 @@
|
|||
from flask import Blueprint, request
|
||||
from animal_case import animalify
|
||||
from flask import Blueprint, g, jsonify
|
||||
from flask_yoloapi import endpoint, parameter
|
||||
|
||||
from grant import JSONResponse
|
||||
from .models import User, users_schema, user_schema, db
|
||||
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
|
||||
from ..email.send import send_email
|
||||
from ..proposal.models import Proposal, proposal_team
|
||||
from ..utils.auth import requires_sm
|
||||
|
||||
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
||||
|
||||
|
||||
@blueprint.route("/", methods=["GET"])
|
||||
def get_users():
|
||||
proposal_query = request.args.get('proposalId')
|
||||
proposal = Proposal.query.filter_by(proposal_id=proposal_query).first()
|
||||
@endpoint.api(
|
||||
parameter('proposalId', type=str, required=False)
|
||||
)
|
||||
def get_users(proposal_id):
|
||||
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
|
||||
if not proposal:
|
||||
users = User.query.all()
|
||||
else:
|
||||
users = User.query.join(proposal_team).join(Proposal) \
|
||||
.filter(proposal_team.c.proposal_id == proposal.id).all()
|
||||
result = users_schema.dump(users)
|
||||
return JSONResponse(result)
|
||||
return result
|
||||
|
||||
|
||||
@blueprint.route("/me", methods=["GET"])
|
||||
@requires_sm
|
||||
def get_me():
|
||||
dumped_user = user_schema.dump(g.current_user)
|
||||
return jsonify(animalify(dumped_user))
|
||||
|
||||
|
||||
@blueprint.route("/<user_identity>", methods=["GET"])
|
||||
def get_user(user_identity):
|
||||
user = User.query.filter(
|
||||
(User.account_address == user_identity) | (User.email_address == user_identity)).first()
|
||||
user = User.get_by_email_or_account_address(email_address=user_identity, account_address=user_identity)
|
||||
if user:
|
||||
result = user_schema.dump(user)
|
||||
return JSONResponse(result)
|
||||
return jsonify(animalify(result))
|
||||
else:
|
||||
return JSONResponse(
|
||||
message="User with account_address or user_identity matching {} not found".format(user_identity),
|
||||
_statusCode=404)
|
||||
return jsonify(
|
||||
message="User with account_address or user_identity matching {} not found".format(user_identity)), 404
|
||||
|
||||
|
||||
@blueprint.route("/", methods=["POST"])
|
||||
def create_user():
|
||||
incoming = request.get_json()
|
||||
account_address = incoming["accountAddress"]
|
||||
email_address = incoming["emailAddress"]
|
||||
display_name = incoming["displayName"]
|
||||
title = incoming["title"]
|
||||
|
||||
# TODO: Move create and validation stuff into User model
|
||||
existing_user = User.query.filter(
|
||||
(User.account_address == account_address) | (User.email_address == email_address)).first()
|
||||
@endpoint.api(
|
||||
parameter('accountAddress', type=str, required=True),
|
||||
parameter('emailAddress', type=str, required=True),
|
||||
parameter('displayName', type=str, required=True),
|
||||
parameter('title', type=str, required=True),
|
||||
)
|
||||
def create_user(account_address, email_address, display_name, title):
|
||||
existing_user = User.get_by_email_or_account_address(email_address=email_address, account_address=account_address)
|
||||
if existing_user:
|
||||
return JSONResponse(
|
||||
message="User with that address or email already exists",
|
||||
_statusCode=400)
|
||||
return {"message": "User with that address or email already exists"}, 409
|
||||
|
||||
# TODO: Handle avatar & social stuff too
|
||||
user = User(
|
||||
|
@ -59,5 +66,49 @@ def create_user():
|
|||
db.session.flush()
|
||||
db.session.commit()
|
||||
|
||||
send_email(email_address, 'signup', {
|
||||
'display_name': display_name,
|
||||
# TODO: Make this dynamic
|
||||
'confirm_url': 'https://grant.io/user/confirm',
|
||||
})
|
||||
|
||||
result = user_schema.dump(user)
|
||||
return JSONResponse(result)
|
||||
return result
|
||||
|
||||
|
||||
@blueprint.route("/<user_identity>", methods=["PUT"])
|
||||
@endpoint.api(
|
||||
parameter('displayName', type=str, required=False),
|
||||
parameter('title', type=str, required=False),
|
||||
parameter('socialMedias', type=list, required=False),
|
||||
parameter('avatar', type=dict, required=False)
|
||||
)
|
||||
def update_user(user_identity, display_name, title, social_medias, avatar):
|
||||
user = User.get_by_email_or_account_address(email_address=user_identity, account_address=user_identity)
|
||||
if not user:
|
||||
return {"message": "User with that address or email not found"}, 404
|
||||
|
||||
if display_name is not None:
|
||||
user.display_name = display_name
|
||||
|
||||
if title is not None:
|
||||
user.title = title
|
||||
|
||||
if social_medias is not None:
|
||||
sm_query = SocialMedia.query.filter_by(user_id=user.id)
|
||||
sm_query.delete()
|
||||
for social_media in social_medias:
|
||||
sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id)
|
||||
db.session.add(sm)
|
||||
|
||||
if avatar is not None:
|
||||
Avatar.query.filter_by(user_id=user.id).delete()
|
||||
avatar_link = avatar.get('link')
|
||||
if avatar_link:
|
||||
avatar_obj = Avatar(image_url=avatar_link, user_id=user.id)
|
||||
db.session.add(avatar_obj)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = user_schema.dump(user)
|
||||
return result
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import ast
|
||||
import json
|
||||
from functools import wraps
|
||||
|
||||
import requests
|
||||
from flask import request, g, jsonify
|
||||
from itsdangerous import SignatureExpired, BadSignature
|
||||
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
|
||||
|
||||
from grant.settings import SECRET_KEY
|
||||
from grant.settings import SECRET_KEY, AUTH_URL
|
||||
from ..user.models import User
|
||||
|
||||
TWO_WEEKS = 1209600
|
||||
|
||||
|
@ -41,3 +45,33 @@ def requires_auth(f):
|
|||
return jsonify(message="Authentication is required to access this resource"), 401
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def requires_sm(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
typed_data = request.headers.get('RawTypedData', None)
|
||||
signature = request.headers.get('MsgSignature', None)
|
||||
|
||||
if typed_data and signature:
|
||||
loaded_typed_data = ast.literal_eval(typed_data)
|
||||
url = AUTH_URL + "/message/recover"
|
||||
payload = json.dumps({"sig": signature, "data": loaded_typed_data})
|
||||
headers = {'content-type': 'application/json'}
|
||||
response = requests.request("POST", url, data=payload, headers=headers)
|
||||
json_response = response.json()
|
||||
recovered_address = json_response.get('recoveredAddress')
|
||||
|
||||
if not recovered_address:
|
||||
return jsonify(message="No user exists with address: {}".format(recovered_address)), 401
|
||||
|
||||
user = User.get_by_email_or_account_address(account_address=recovered_address)
|
||||
if not user:
|
||||
return jsonify(message="No user exists with address: {}".format(recovered_address)), 401
|
||||
|
||||
g.current_user = user
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return jsonify(message="Authentication is required to access this resource"), 401
|
||||
|
||||
return decorated
|
||||
|
|
|
@ -1,77 +1,5 @@
|
|||
import datetime
|
||||
|
||||
import time
|
||||
from contextlib import closing
|
||||
|
||||
from requests import get
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
|
||||
|
||||
|
||||
def simple_get(url):
|
||||
"""
|
||||
Attempts to get the content at `url` by making an HTTP GET request.
|
||||
If the content-type of response is some kind of HTML/XML, return the
|
||||
text content, otherwise return None.
|
||||
"""
|
||||
try:
|
||||
with closing(get(url, stream=True)) as resp:
|
||||
if is_good_response(resp):
|
||||
return resp.content
|
||||
else:
|
||||
return None
|
||||
|
||||
except RequestException as e:
|
||||
log_error("Error during requests to {0} : {1}".format(url, str(e)))
|
||||
return None
|
||||
|
||||
|
||||
def is_good_response(resp):
|
||||
"""
|
||||
Returns True if the response seems to be HTML, False otherwise.
|
||||
"""
|
||||
content_type = resp.headers["Content-Type"].lower()
|
||||
return (
|
||||
resp.status_code == 200
|
||||
and content_type is not None
|
||||
and content_type.find("html") > -1
|
||||
)
|
||||
|
||||
|
||||
def log_error(e):
|
||||
"""
|
||||
It is always a good idea to log errors.
|
||||
This function just prints them, but you can
|
||||
make it do anything.
|
||||
"""
|
||||
print(e)
|
||||
|
||||
|
||||
def strip_number_formatting_from_string(string: str) -> str:
|
||||
return string.replace(",", "").replace(".", "").replace(" ", "").strip()
|
||||
|
||||
|
||||
def convert_monero_to_piconero(monero_string: str) -> int:
|
||||
monero_string = strip_number_formatting_from_string(monero_string)
|
||||
for _ in range(11):
|
||||
monero_string += "0"
|
||||
return int(monero_string)
|
||||
|
||||
|
||||
def convert_piconero_to_monero(piconero_string: str) -> str:
|
||||
reversed_piconero = piconero_string[::-1]
|
||||
added_decimal = reversed_piconero[:13] + "." + reversed_piconero[13:]
|
||||
unreversed_piconero = added_decimal[::-1]
|
||||
return unreversed_piconero
|
||||
|
||||
|
||||
def convert_string_money_to_float(money_string: str) -> float:
|
||||
reversed_money_string = money_string[::-1]
|
||||
added_decimal = reversed_money_string[:2] + "." + reversed_money_string[2:]
|
||||
unreversed_money_string = added_decimal[::-1]
|
||||
return float(unreversed_money_string)
|
||||
|
||||
|
||||
epoch = datetime.datetime.utcfromtimestamp(0)
|
||||
|
||||
|
|
|
@ -16,4 +16,5 @@ flake8-quotes==1.0.0
|
|||
isort==4.3.4
|
||||
pep8-naming==0.7.0
|
||||
pre-commit
|
||||
flask_testing
|
||||
flask_testing
|
||||
mock
|
|
@ -25,11 +25,9 @@ marshmallow==3.0.0b13
|
|||
flask-marshmallow==0.9.0
|
||||
marshmallow-sqlalchemy
|
||||
|
||||
|
||||
# CORS
|
||||
Flask-Cors==3.0.6
|
||||
|
||||
|
||||
# Deployment
|
||||
gunicorn>=19.1.1
|
||||
|
||||
|
@ -51,3 +49,10 @@ redis==2.10.6
|
|||
|
||||
# md
|
||||
markdownify
|
||||
|
||||
# email
|
||||
flask-sendgrid==0.6
|
||||
sendgrid==5.3.0
|
||||
|
||||
# input validation
|
||||
flask-yolo2API
|
|
@ -1,40 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Defines fixtures available to all tests."""
|
||||
|
||||
import pytest
|
||||
from webtest import TestApp
|
||||
|
||||
from grant.app import create_app
|
||||
from grant.app import db as _db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""An application for the tests."""
|
||||
_app = create_app('tests.settings')
|
||||
ctx = _app.test_request_context()
|
||||
ctx.push()
|
||||
|
||||
yield _app
|
||||
|
||||
ctx.pop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testapp(app):
|
||||
"""A Webtest app."""
|
||||
return TestApp(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(app):
|
||||
"""A database for the tests."""
|
||||
_db.app = app
|
||||
with app.app_context():
|
||||
_db.create_all()
|
||||
|
||||
yield _db
|
||||
|
||||
# Explicitly close DB connection
|
||||
_db.session.close()
|
||||
_db.drop_all()
|
|
@ -48,7 +48,7 @@ class TestAPI(BaseTestConfig):
|
|||
proposal_id=proposal["crowdFundContractAddress"]
|
||||
).first())
|
||||
|
||||
self.app.post(
|
||||
resp = self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
data=json.dumps(proposal),
|
||||
content_type='application/json'
|
||||
|
@ -92,3 +92,18 @@ class TestAPI(BaseTestConfig):
|
|||
)
|
||||
|
||||
self.assertTrue(comment_res.json)
|
||||
|
||||
def test_create_new_proposal_duplicate(self):
|
||||
proposal_res = self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
data=json.dumps(proposal),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
proposal_res2 = self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
data=json.dumps(proposal),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(proposal_res2.status_code, 409)
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
import json
|
||||
|
||||
from ..config import BaseTestConfig
|
||||
|
||||
account_address = '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'
|
||||
|
||||
message = {
|
||||
"sig": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c",
|
||||
"data": {"types": {"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
|
||||
{"name": "chainId", "type": "uint256"},
|
||||
{"name": "verifyingContract", "type": "address"}],
|
||||
"Person": [{"name": "name", "type": "string"}, {"name": "wallet", "type": "address"}],
|
||||
"Mail": [{"name": "from", "type": "Person"}, {"name": "to", "type": "Person"},
|
||||
{"name": "contents", "type": "string"}]}, "primaryType": "Mail",
|
||||
"domain": {"name": "Ether Mail", "version": "1", "chainId": 1,
|
||||
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},
|
||||
"message": {"from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},
|
||||
"to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},
|
||||
"contents": "Hello, Bob!"}}
|
||||
|
||||
}
|
||||
|
||||
user = {
|
||||
"accountAddress": account_address,
|
||||
"displayName": 'Groot',
|
||||
"emailAddress": 'iam@groot.com',
|
||||
"title": 'I am Groot!',
|
||||
"avatar": {
|
||||
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
|
||||
},
|
||||
"socialMedias": [
|
||||
{
|
||||
"link": 'https://github.com/groot'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class TestRequiredSignedMessageDecorator(BaseTestConfig):
|
||||
def test_required_sm_aborts_without_data_and_sig_headers(self):
|
||||
self.app.post(
|
||||
"/api/v1/users/",
|
||||
data=json.dumps(user),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
response = self.app.get(
|
||||
"/api/v1/users/me",
|
||||
headers={
|
||||
"MsgSignature": message["sig"],
|
||||
# "RawTypedData: message["data"]
|
||||
}
|
||||
)
|
||||
|
||||
self.assert401(response)
|
||||
|
||||
response = self.app.get(
|
||||
"/api/v1/users/me",
|
||||
headers={
|
||||
# "MsgSignature": message["sig"],
|
||||
"RawTypedData": message["data"]
|
||||
}
|
||||
)
|
||||
|
||||
self.assert401(response)
|
||||
|
||||
def test_required_sm_aborts_without_existing_user(self):
|
||||
# We don't create the user here to test a failure case
|
||||
# self.app.post(
|
||||
# "/api/v1/users/",
|
||||
# data=json.dumps(user),
|
||||
# content_type='application/json'
|
||||
# )
|
||||
|
||||
response = self.app.get(
|
||||
"/api/v1/users/me",
|
||||
headers={
|
||||
"MsgSignature": message["sig"],
|
||||
"RawTypedData": message["data"]
|
||||
}
|
||||
)
|
||||
|
||||
self.assert401(response)
|
||||
|
||||
def test_required_sm_decorator_authorizes_when_recovered_address_matches_existing_user(self):
|
||||
self.app.post(
|
||||
"/api/v1/users/",
|
||||
data=json.dumps(user),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
response = self.app.get(
|
||||
"/api/v1/users/me",
|
||||
headers={
|
||||
"MsgSignature": message["sig"],
|
||||
"RawTypedData": message["data"]
|
||||
}
|
||||
)
|
||||
|
||||
response_json = response.json
|
||||
|
||||
self.assert200(response)
|
||||
self.assertEqual(response_json["displayName"], user["displayName"])
|
|
@ -6,6 +6,7 @@ from grant.proposal.models import CATEGORIES
|
|||
from grant.proposal.models import Proposal
|
||||
from grant.user.models import User
|
||||
from ..config import BaseTestConfig
|
||||
from mock import patch
|
||||
|
||||
milestones = [
|
||||
{
|
||||
|
@ -181,3 +182,88 @@ class TestAPI(BaseTestConfig):
|
|||
self.assertEqual(users_json["avatar"]["imageUrl"], team[0]["avatar"]["link"])
|
||||
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"])
|
||||
self.assertEqual(users_json["displayName"], team[0]["displayName"])
|
||||
|
||||
@patch('grant.email.send.send_email')
|
||||
def test_create_user(self, mock_send_email):
|
||||
mock_send_email.return_value.ok = True
|
||||
|
||||
self.app.post(
|
||||
"/api/v1/users/",
|
||||
data=json.dumps(team[0]),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
# User
|
||||
user_db = User.get_by_email_or_account_address(account_address=team[0]["accountAddress"])
|
||||
self.assertEqual(user_db.display_name, team[0]["displayName"])
|
||||
self.assertEqual(user_db.title, team[0]["title"])
|
||||
self.assertEqual(user_db.account_address, team[0]["accountAddress"])
|
||||
|
||||
@patch('grant.email.send.send_email')
|
||||
def test_create_user_duplicate_400(self, mock_send_email):
|
||||
mock_send_email.return_value.ok = True
|
||||
self.test_create_user()
|
||||
|
||||
response = self.app.post(
|
||||
"/api/v1/users/",
|
||||
data=json.dumps(team[0]),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
|
||||
def test_update_user_remove_social_and_avatar(self):
|
||||
self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
data=json.dumps(proposal),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
updated_user = copy.deepcopy(team[0])
|
||||
updated_user['displayName'] = 'Billy'
|
||||
updated_user['title'] = 'Commander'
|
||||
updated_user['socialMedias'] = []
|
||||
updated_user['avatar'] = {}
|
||||
|
||||
user_update_resp = self.app.put(
|
||||
"/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]),
|
||||
data=json.dumps(updated_user),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
users_json = user_update_resp.json
|
||||
self.assertFalse(users_json["avatar"])
|
||||
self.assertFalse(len(users_json["socialMedias"]))
|
||||
self.assertEqual(users_json["displayName"], updated_user["displayName"])
|
||||
self.assertEqual(users_json["title"], updated_user["title"])
|
||||
|
||||
def test_update_user(self):
|
||||
self.app.post(
|
||||
"/api/v1/proposals/",
|
||||
data=json.dumps(proposal),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
updated_user = copy.deepcopy(team[0])
|
||||
updated_user['displayName'] = 'Billy'
|
||||
updated_user['title'] = 'Commander'
|
||||
updated_user['socialMedias'] = [
|
||||
{
|
||||
"link": "https://github.com/billyman"
|
||||
}
|
||||
]
|
||||
updated_user['avatar'] = {
|
||||
"link": "https://x.io/avatar.png"
|
||||
}
|
||||
|
||||
user_update_resp = self.app.put(
|
||||
"/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]),
|
||||
data=json.dumps(updated_user),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
users_json = user_update_resp.json
|
||||
self.assertEqual(users_json["avatar"]["imageUrl"], updated_user["avatar"]["link"])
|
||||
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], updated_user["socialMedias"][0]["link"])
|
||||
self.assertEqual(users_json["displayName"], updated_user["displayName"])
|
||||
self.assertEqual(users_json["title"], updated_user["title"])
|
||||
|
|
|
@ -2,4 +2,7 @@
|
|||
FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068DEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068D
|
||||
|
||||
# Disable typescript checking for dev building (reduce build time & resource usage)
|
||||
NO_DEV_TS_CHECK=true
|
||||
NO_DEV_TS_CHECK=true
|
||||
|
||||
# Set the public host url (no trailing slash)
|
||||
PUBLIC_HOST_URL=https://demo.grant.io
|
|
@ -22,6 +22,10 @@ const SignOut = loadable(() => import('pages/sign-out'));
|
|||
const Profile = loadable(() => import('pages/profile'));
|
||||
const Settings = loadable(() => import('pages/settings'));
|
||||
const Exception = loadable(() => import('pages/exception'));
|
||||
const Tos = loadable(() => import('pages/tos'));
|
||||
const About = loadable(() => import('pages/about'));
|
||||
const Privacy = loadable(() => import('pages/privacy'));
|
||||
const Contact = loadable(() => import('pages/contact'));
|
||||
|
||||
import 'styles/style.less';
|
||||
|
||||
|
@ -107,6 +111,54 @@ const routeConfigs: RouteConfig[] = [
|
|||
},
|
||||
onlyLoggedIn: true,
|
||||
},
|
||||
{
|
||||
// Terms of Service page
|
||||
route: {
|
||||
path: '/tos',
|
||||
component: Tos,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Terms of Service',
|
||||
},
|
||||
onlyLoggedIn: false,
|
||||
},
|
||||
{
|
||||
// About page
|
||||
route: {
|
||||
path: '/about',
|
||||
component: About,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'About',
|
||||
},
|
||||
onlyLoggedIn: false,
|
||||
},
|
||||
{
|
||||
// Privacy page
|
||||
route: {
|
||||
path: '/privacy',
|
||||
component: Privacy,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Privacy Policy',
|
||||
},
|
||||
onlyLoggedIn: false,
|
||||
},
|
||||
{
|
||||
// Contact page
|
||||
route: {
|
||||
path: '/contact',
|
||||
component: Contact,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Contact',
|
||||
},
|
||||
onlyLoggedIn: false,
|
||||
},
|
||||
{
|
||||
// User profile
|
||||
route: {
|
||||
|
@ -144,7 +196,7 @@ const routeConfigs: RouteConfig[] = [
|
|||
// 404
|
||||
route: {
|
||||
path: '/*',
|
||||
render: () => <Exception type="404" />,
|
||||
render: () => <Exception code="404" />,
|
||||
},
|
||||
template: {
|
||||
title: 'Page not found',
|
||||
|
@ -157,7 +209,9 @@ type Props = RouteComponentProps<any>;
|
|||
class Routes extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { pathname } = this.props.location;
|
||||
const currentRoute = routeConfigs.find(config => !!matchPath(pathname, config.route));
|
||||
const currentRoute =
|
||||
routeConfigs.find(config => !!matchPath(pathname, config.route)) ||
|
||||
routeConfigs[routeConfigs.length - 1];
|
||||
const routeComponents = routeConfigs.map(config => {
|
||||
const { route, onlyLoggedIn, onlyLoggedOut } = config;
|
||||
if (onlyLoggedIn || onlyLoggedOut) {
|
||||
|
|
|
@ -64,3 +64,12 @@ export function createUser(payload: {
|
|||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
|
||||
return axios
|
||||
.put(`/api/v1/users/${user.ethAddress}`, formatTeamMemberForPost(user))
|
||||
.then(res => {
|
||||
res.data = formatTeamMemberFromGet(res.data);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import './style.less';
|
||||
|
||||
export default class About extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="About">
|
||||
<h1 className="About-title">About Grant.io</h1>
|
||||
<section>
|
||||
<p>
|
||||
Grant.io organizes creators and community members to incentivize ecosystem
|
||||
improvements.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
.About {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
|
||||
&-title {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
padding-bottom: 3rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
margin-bottom: 2.5rem;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 3rem;
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
.AddressInput {
|
||||
&-input {
|
||||
&-identicon {
|
||||
position: relative;
|
||||
border-radius: 100%;
|
||||
left: -2px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
.ant-input-affix-wrapper-lg & {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper-sm & {
|
||||
left: -4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Ant overrides
|
||||
&.ant-input-affix-wrapper .ant-input:not(:first-child) {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
&.ant-input-affix-wrapper-lg .ant-input:not(:first-child) {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
&.ant-input-affix-wrapper-sm .ant-input:not(:first-child) {
|
||||
padding-left: 28px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Form, Input } from 'antd';
|
||||
import { InputProps } from 'antd/lib/input';
|
||||
import { FormItemProps } from 'antd/lib/form';
|
||||
import { isValidEthAddress } from 'utils/validators';
|
||||
import Identicon from 'components/Identicon';
|
||||
import { DONATION } from 'utils/constants';
|
||||
import './AddressInput.less';
|
||||
|
||||
export interface Props {
|
||||
value: string | undefined;
|
||||
className?: string;
|
||||
showIdenticon?: boolean;
|
||||
inputProps?: InputProps;
|
||||
formItemProps?: FormItemProps;
|
||||
onChange(ev: React.ChangeEvent<HTMLInputElement>): void;
|
||||
}
|
||||
|
||||
export default class AddressInput extends React.Component<Props> {
|
||||
render() {
|
||||
const { value, onChange, className, showIdenticon } = this.props;
|
||||
const passedFormItemProps = this.props.formItemProps || {};
|
||||
const passedInputProps = this.props.inputProps || {};
|
||||
const isInvalid = value && !isValidEthAddress(value);
|
||||
|
||||
const formItemProps = {
|
||||
validateStatus: (isInvalid
|
||||
? 'error'
|
||||
: undefined) as FormItemProps['validateStatus'],
|
||||
help: isInvalid ? 'Address is invalid' : undefined,
|
||||
...passedFormItemProps,
|
||||
className: classnames('AddressInput', className, passedFormItemProps.className),
|
||||
};
|
||||
|
||||
const inputProps = {
|
||||
placeholder: DONATION.ETH,
|
||||
prefix: value &&
|
||||
showIdenticon && (
|
||||
<Identicon className="AddressInput-input-identicon" address={value} />
|
||||
),
|
||||
...passedInputProps,
|
||||
value,
|
||||
onChange,
|
||||
className: classnames(
|
||||
'AddressInput-input',
|
||||
className && `${className}-input`,
|
||||
passedInputProps.className,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item {...formItemProps}>
|
||||
<Input {...inputProps} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -40,24 +40,26 @@ class AuthFlow extends React.Component<Props> {
|
|||
title: () => 'Prove your Identity',
|
||||
subtitle: () => 'Log into your Grant.io account by proving your identity',
|
||||
render: () => {
|
||||
const user = this.props.checkedUsers[this.state.address];
|
||||
const { address, provider } = this.state;
|
||||
const user = address && this.props.checkedUsers[address];
|
||||
return (
|
||||
user && (
|
||||
<SignIn provider={this.state.provider} user={user} reset={this.resetState} />
|
||||
)
|
||||
user &&
|
||||
provider && <SignIn provider={provider} user={user} reset={this.resetState} />
|
||||
);
|
||||
},
|
||||
},
|
||||
SIGN_UP: {
|
||||
title: () => 'Claim your Identity',
|
||||
subtitle: () => 'Create a Grant.io account by claiming your identity',
|
||||
render: () => (
|
||||
<SignUp
|
||||
address={this.state.address}
|
||||
provider={this.state.provider}
|
||||
reset={this.resetState}
|
||||
/>
|
||||
),
|
||||
render: () => {
|
||||
const { address, provider } = this.state;
|
||||
return (
|
||||
address &&
|
||||
provider && (
|
||||
<SignUp address={address} provider={provider} reset={this.resetState} />
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
SELECT_PROVIDER: {
|
||||
title: () => 'Provide an Identity',
|
||||
|
@ -80,13 +82,17 @@ class AuthFlow extends React.Component<Props> {
|
|||
return 'Connect with MetaMask';
|
||||
}
|
||||
},
|
||||
render: () => (
|
||||
<ProvideIdentity
|
||||
provider={this.state.provider}
|
||||
onSelectAddress={this.setAddress}
|
||||
reset={this.resetState}
|
||||
/>
|
||||
),
|
||||
render: () => {
|
||||
return (
|
||||
this.state.provider && (
|
||||
<ProvideIdentity
|
||||
provider={this.state.provider}
|
||||
onSelectAddress={this.setAddress}
|
||||
reset={this.resetState}
|
||||
/>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -105,7 +111,7 @@ class AuthFlow extends React.Component<Props> {
|
|||
render() {
|
||||
const { checkedUsers, isCheckingUser } = this.props;
|
||||
const { provider, address } = this.state;
|
||||
const checkedUser = checkedUsers[address];
|
||||
const checkedUser = address && checkedUsers[address];
|
||||
let page;
|
||||
|
||||
if (provider) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Form, Input, Button } from 'antd';
|
||||
import { Form, Button } from 'antd';
|
||||
import { isValidEthAddress } from 'utils/validators';
|
||||
import AddressInput from 'components/AddressInput';
|
||||
import './Address.less';
|
||||
|
||||
interface Props {
|
||||
|
@ -20,14 +21,12 @@ export default class AddressProvider extends React.Component<Props, State> {
|
|||
const { address } = this.state;
|
||||
return (
|
||||
<Form className="AddressProvider" onSubmit={this.handleSubmit}>
|
||||
<Form.Item className="AddressProvider-address">
|
||||
<Input
|
||||
size="large"
|
||||
value={address}
|
||||
onChange={this.handleChange}
|
||||
placeholder="0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520"
|
||||
/>
|
||||
</Form.Item>
|
||||
<AddressInput
|
||||
className="AddressProvider-address"
|
||||
value={address}
|
||||
onChange={this.handleChange}
|
||||
inputProps={{ size: 'large' }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
|
|
|
@ -146,7 +146,7 @@ interface AddressChoiceProps {
|
|||
const AddressChoice: React.SFC<AddressChoiceProps> = props => (
|
||||
<button
|
||||
className={classnames('AddressChoice', props.isFake && 'is-fake')}
|
||||
onClick={props.onClick ? () => props.onClick(props.address) : undefined}
|
||||
onClick={() => props.onClick && props.onClick(props.address)}
|
||||
>
|
||||
{/* TODO: Use user avatar + name if they have an account */}
|
||||
{props.isFake ? (
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { RouteComponentProps, withRouter } from 'react-router';
|
||||
import ogpLogo from 'static/images/ogp-logo.png';
|
||||
import { urlToPublic } from 'utils/helpers';
|
||||
|
||||
interface Props {
|
||||
interface OwnProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default class BasicHead extends React.Component<Props> {
|
||||
type Props = OwnProps & RouteComponentProps<any>;
|
||||
|
||||
class BasicHead extends React.Component<Props> {
|
||||
render() {
|
||||
const { children, title } = this.props;
|
||||
const defaultOgpUrl = process.env.PUBLIC_HOST_URL + this.props.location.pathname;
|
||||
const defaultOgpImage = urlToPublic(ogpLogo);
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
|
@ -19,9 +26,29 @@ export default class BasicHead extends React.Component<Props> {
|
|||
integrity="sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
|
||||
{/* open graph protocol defaults, can be overridden in children <HeaderDetails ...> */}
|
||||
<meta property="og:site_name" content="Grant.io" />
|
||||
<meta property="og:title" content={`Grant.io - ${title}`} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={defaultOgpUrl} />
|
||||
<meta property="og:image" content={defaultOgpImage} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
{/* TODO: i18n */}
|
||||
{/* <meta property="og:locale:alternate" content="en_US" /> */}
|
||||
{/* <meta property="og:locale:alternate" content="de_DE" /> */}
|
||||
|
||||
{/* twitter defaults, can be overridden in children <HeaderDetails ...> */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:site" content="@io_grant" />
|
||||
<meta property="twitter:title" content={`Grant.io - ${title}`} />
|
||||
<meta property="twitter:image" content={defaultOgpImage} />
|
||||
<meta property="twitter:url" content={defaultOgpUrl} />
|
||||
</Helmet>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(BasicHead);
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import './style.less';
|
||||
|
||||
export default class Contact extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="Contact">
|
||||
<h1 className="Contact-title">Contact Us</h1>
|
||||
<section>
|
||||
<p>
|
||||
You may contact the Grant.io project by emailing{' '}
|
||||
<a href="mailto:daniel@grant.io">daniel@grant.io</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
.Contact {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
|
||||
&-title {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
padding-bottom: 3rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
margin-bottom: 2.5rem;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 3rem;
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Input, Form, Icon, Select } from 'antd';
|
||||
import { SelectValue } from 'antd/lib/select';
|
||||
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
|
||||
import { CreateFormState } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
import { typedKeys } from 'utils/ts';
|
||||
|
||||
interface State {
|
||||
title: string;
|
||||
|
@ -37,8 +39,8 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
|
|||
});
|
||||
};
|
||||
|
||||
handleCategoryChange = (value: PROPOSAL_CATEGORY) => {
|
||||
this.setState({ category: value }, () => {
|
||||
handleCategoryChange = (value: SelectValue) => {
|
||||
this.setState({ category: value as PROPOSAL_CATEGORY }, () => {
|
||||
this.props.updateForm(this.state);
|
||||
});
|
||||
};
|
||||
|
@ -85,7 +87,7 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
|
|||
value={category || undefined}
|
||||
onChange={this.handleCategoryChange}
|
||||
>
|
||||
{Object.keys(PROPOSAL_CATEGORY).map((c: PROPOSAL_CATEGORY) => (
|
||||
{typedKeys(PROPOSAL_CATEGORY).map(c => (
|
||||
<Select.Option value={c} key={c}>
|
||||
<Icon
|
||||
type={CATEGORY_UI[c].icon}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { RadioChangeEvent } from 'antd/lib/radio';
|
|||
import { CreateFormState } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
import { ONE_DAY } from 'utils/time';
|
||||
import { DONATION } from 'utils/constants';
|
||||
|
||||
interface State {
|
||||
payOutAddress: string;
|
||||
|
@ -43,7 +44,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
|||
<Input
|
||||
size="large"
|
||||
name="payOutAddress"
|
||||
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
|
||||
placeholder={DONATION.ETH}
|
||||
type="text"
|
||||
value={payOutAddress}
|
||||
onChange={this.handleInputChange}
|
||||
|
@ -143,7 +144,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
|||
|
||||
private handleRadioChange = (event: RadioChangeEvent) => {
|
||||
const { value, name } = event.target;
|
||||
this.setState({ [name]: value } as any, () => {
|
||||
this.setState({ [name as string]: value } as any, () => {
|
||||
this.props.updateForm(this.state);
|
||||
});
|
||||
};
|
||||
|
@ -172,7 +173,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
|||
interface TrusteeFieldsProps {
|
||||
index: number;
|
||||
value: string;
|
||||
error: null | false | string;
|
||||
error: string | Falsy;
|
||||
onChange(index: number, value: string): void;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
@ -192,7 +193,7 @@ const TrusteeFields = ({
|
|||
<div style={{ display: 'flex' }}>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
|
||||
placeholder={DONATION.ETH}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={ev => onChange(index, ev.currentTarget.value)}
|
||||
|
|
|
@ -108,7 +108,7 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
|
|||
interface MilestoneFieldsProps {
|
||||
index: number;
|
||||
milestone: CreateMilestone;
|
||||
error: null | false | string;
|
||||
error: Falsy | string;
|
||||
onChange(index: number, milestone: CreateMilestone): void;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ class CreateFlowPreview extends React.Component<Props> {
|
|||
<ProposalDetail
|
||||
account="0x0"
|
||||
proposalId="preview"
|
||||
fetchProposal={() => null}
|
||||
fetchProposal={(() => null) as any}
|
||||
proposal={proposal}
|
||||
isPreview
|
||||
/>
|
||||
|
|
|
@ -6,7 +6,7 @@ import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils
|
|||
import Markdown from 'components/Markdown';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { CREATE_STEP } from './index';
|
||||
import { CATEGORY_UI } from 'api/constants';
|
||||
import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import './Review.less';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
|
||||
|
@ -23,7 +23,7 @@ type Props = OwnProps & StateProps;
|
|||
interface Field {
|
||||
key: KeyOfForm;
|
||||
content: React.ReactNode;
|
||||
error: string | undefined | false;
|
||||
error: string | Falsy;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
|
@ -36,7 +36,7 @@ class CreateReview extends React.Component<Props> {
|
|||
render() {
|
||||
const { form } = this.props;
|
||||
const errors = getCreateErrors(this.props.form);
|
||||
const catUI = CATEGORY_UI[form.category] || ({} as any);
|
||||
const catUI = CATEGORY_UI[form.category as PROPOSAL_CATEGORY] || ({} as any);
|
||||
const sections: Section[] = [
|
||||
{
|
||||
step: CREATE_STEP.BASICS,
|
||||
|
@ -121,13 +121,15 @@ class CreateReview extends React.Component<Props> {
|
|||
},
|
||||
{
|
||||
key: 'deadline',
|
||||
content: `${Math.floor(moment.duration(form.deadline * 1000).asDays())} days`,
|
||||
content: `${Math.floor(
|
||||
moment.duration((form.deadline || 0) * 1000).asDays(),
|
||||
)} days`,
|
||||
error: errors.deadline,
|
||||
},
|
||||
{
|
||||
key: 'milestoneDeadline',
|
||||
content: `${Math.floor(
|
||||
moment.duration(form.milestoneDeadline * 1000).asDays(),
|
||||
moment.duration((form.milestoneDeadline || 0) * 1000).asDays(),
|
||||
)} days`,
|
||||
error: errors.milestoneDeadline,
|
||||
},
|
||||
|
|
|
@ -113,7 +113,7 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
};
|
||||
}
|
||||
|
||||
const withConnect = connect<StateProps>((state: AppState) => ({
|
||||
const withConnect = connect<StateProps, {}, {}, AppState>(state => ({
|
||||
authUser: state.auth.user,
|
||||
}));
|
||||
|
||||
|
|
|
@ -1,6 +1,24 @@
|
|||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import { SOCIAL_TYPE, CreateFormState } from 'types';
|
||||
|
||||
function generateRandomAddress() {
|
||||
return (
|
||||
'0x' +
|
||||
Math.random()
|
||||
.toString(16)
|
||||
.substring(2, 12) +
|
||||
Math.random()
|
||||
.toString(16)
|
||||
.substring(2, 12) +
|
||||
Math.random()
|
||||
.toString(16)
|
||||
.substring(2, 12) +
|
||||
Math.random()
|
||||
.toString(16)
|
||||
.substring(2, 12)
|
||||
);
|
||||
}
|
||||
|
||||
const createExampleProposal = (
|
||||
payOutAddress: string,
|
||||
trustees: string[],
|
||||
|
@ -29,7 +47,7 @@ const createExampleProposal = (
|
|||
avatarUrl: `https://randomuser.me/api/portraits/women/${Math.floor(
|
||||
Math.random() * 80,
|
||||
)}.jpg`,
|
||||
ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
|
||||
ethAddress: generateRandomAddress(),
|
||||
emailAddress: 'designer@tshirt.com',
|
||||
socialAccounts: {
|
||||
[SOCIAL_TYPE.KEYBASE]: 'willo',
|
||||
|
|
|
@ -4,6 +4,7 @@ import { compose } from 'recompose';
|
|||
import { Steps, Icon, Spin, Alert } from 'antd';
|
||||
import qs from 'query-string';
|
||||
import { withRouter, RouteComponentProps } from 'react-router';
|
||||
import { History } from 'history';
|
||||
import { debounce } from 'underscore';
|
||||
import Basics from './Basics';
|
||||
import Team from './Team';
|
||||
|
@ -46,7 +47,7 @@ interface StepInfo {
|
|||
title: React.ReactNode;
|
||||
subtitle: React.ReactNode;
|
||||
help: React.ReactNode;
|
||||
component: React.ComponentClass<any>;
|
||||
component: any;
|
||||
}
|
||||
const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
|
||||
[CREATE_STEP.BASICS]: {
|
||||
|
@ -145,22 +146,12 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
isExample: false,
|
||||
};
|
||||
this.debouncedUpdateForm = debounce(this.updateForm, 800);
|
||||
this.historyUnlisten = this.props.history.listen(this.handlePop);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resetCreateCrowdFund();
|
||||
this.props.fetchDraft();
|
||||
this.historyUnlisten = this.props.history.listen((location, action) => {
|
||||
if (action === 'POP') {
|
||||
const searchValues = qs.parse(location.search);
|
||||
const urlStep = searchValues.step && searchValues.step.toUpperCase();
|
||||
if (urlStep && CREATE_STEP[urlStep]) {
|
||||
this.setStep(urlStep as CREATE_STEP, true);
|
||||
} else {
|
||||
this.setStep(CREATE_STEP.BASICS, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -315,6 +306,18 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
return !!Object.keys(errors).length;
|
||||
};
|
||||
|
||||
private handlePop: History.LocationListener = (location, action) => {
|
||||
if (action === 'POP') {
|
||||
const searchValues = qs.parse(location.search);
|
||||
const urlStep = searchValues.step && searchValues.step.toUpperCase();
|
||||
if (urlStep && CREATE_STEP[urlStep]) {
|
||||
this.setStep(urlStep as CREATE_STEP, true);
|
||||
} else {
|
||||
this.setStep(CREATE_STEP.BASICS, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private fillInExample = () => {
|
||||
const { accounts } = this.props;
|
||||
const [payoutAddress, ...trustees] = accounts;
|
||||
|
|
|
@ -1,18 +1,26 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Logo from 'static/images/logo-name.svg';
|
||||
import './style.less';
|
||||
|
||||
export default () => (
|
||||
<footer className="Footer">
|
||||
<Link className="Footer-title" to="/">
|
||||
Grant.io
|
||||
<Logo className="Footer-title-logo" />
|
||||
</Link>
|
||||
{/*
|
||||
<div className="Footer-links">
|
||||
<a className="Footer-links-link">about</a>
|
||||
<a className="Footer-links-link">legal</a>
|
||||
<a className="Footer-links-link">privacy policy</a>
|
||||
</div>
|
||||
*/}
|
||||
<div className="Footer-links">
|
||||
<Link to="/about" className="Footer-links-link">
|
||||
about
|
||||
</Link>
|
||||
<Link to="/contact" className="Footer-links-link">
|
||||
contact
|
||||
</Link>
|
||||
<Link to="/tos" className="Footer-links-link">
|
||||
terms of service
|
||||
</Link>
|
||||
<Link to="/privacy" className="Footer-links-link">
|
||||
privacy policy
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
|
|
@ -8,18 +8,22 @@
|
|||
height: 140px;
|
||||
|
||||
&-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
transition: transform 100ms ease;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.95;
|
||||
transition: transform 100ms ease, opacity 100ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
height: 2.6rem;
|
||||
fill: #FFF;
|
||||
}
|
||||
}
|
||||
|
||||
&-links {
|
||||
|
|
|
@ -107,9 +107,11 @@ class HeaderAuth extends React.Component<Props> {
|
|||
|
||||
private closeMenu = () => this.setState({ isMenuOpen: false });
|
||||
|
||||
private handleVisibilityChange = (visibility: boolean) => {
|
||||
private handleVisibilityChange = (visibility?: boolean) => {
|
||||
// Handle the dropdown component's built in close events
|
||||
this.setState({ isMenuOpen: visibility });
|
||||
if (visibility) {
|
||||
this.setState({ isMenuOpen: visibility });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ class HeaderDrawer extends React.Component<Props> {
|
|||
onClose={onClose}
|
||||
placement="left"
|
||||
>
|
||||
<div className="HeaderDrawer-title">Grant.io</div>
|
||||
<div className="HeaderDrawer-title">Navigation</div>
|
||||
<Menu mode="inline" style={{ borderRight: 0 }}>
|
||||
<Menu.ItemGroup className="HeaderDrawer-user" title={userTitle}>
|
||||
{user ? (
|
||||
|
|
|
@ -4,6 +4,7 @@ import classnames from 'classnames';
|
|||
import HeaderAuth from './Auth';
|
||||
import HeaderDrawer from './Drawer';
|
||||
import MenuIcon from 'static/images/menu.svg';
|
||||
import Logo from 'static/images/logo-name.svg';
|
||||
import './style.less';
|
||||
|
||||
interface Props {
|
||||
|
@ -46,7 +47,7 @@ export default class Header extends React.Component<Props, State> {
|
|||
</div>
|
||||
|
||||
<Link className="Header-title" to="/">
|
||||
Grant.io
|
||||
<Logo className="Header-title-logo" />
|
||||
</Link>
|
||||
|
||||
<div className="Header-links is-right">
|
||||
|
|
|
@ -18,6 +18,10 @@
|
|||
text-shadow: none;
|
||||
box-shadow: 0 1px rgba(0, 0, 0, 0.1);
|
||||
|
||||
svg {
|
||||
fill: #333;
|
||||
}
|
||||
|
||||
&.is-transparent {
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
|
@ -28,31 +32,32 @@
|
|||
svg {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.Header-title {
|
||||
transform: translateY(0px) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateY(-2px) translate(-50%, -50%);
|
||||
font-size: 1.8rem;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
letter-spacing: 0.08rem;
|
||||
font-weight: 500;
|
||||
transition: transform 100ms ease;
|
||||
transition: transform 100ms ease, opacity 100ms ease;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
opacity: 0.92;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
&:focus {
|
||||
color: inherit;
|
||||
transform: translateY(-4px) translate(-50%, -50%);
|
||||
transform: translateY(-2px) translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
transform: translateY(10%);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { urlToPublic } from 'utils/helpers';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default class HeaderDetails extends React.Component<Props> {
|
||||
render() {
|
||||
const { title, image, url, type, description } = this.props;
|
||||
return (
|
||||
<Helmet>
|
||||
<title>{`Grant.io - ${title}`}</title>
|
||||
{/* open graph protocol */}
|
||||
{type && <meta property="og:type" content="website" />}
|
||||
<meta property="og:title" content={title} />
|
||||
{description && <meta property="og:description" content={description} />}
|
||||
{url && <meta property="og:url" content={urlToPublic(url)} />}
|
||||
{image && <meta property="og:image" content={urlToPublic(image)} />}
|
||||
{/* twitter card */}
|
||||
<meta property="twitter:title" content={title} />
|
||||
{description && <meta property="twitter:description" content={description} />}
|
||||
{url && <meta property="twitter:url" content={urlToPublic(url)} />}
|
||||
{image && <meta property="twitter:image" content={urlToPublic(image)} />}
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from 'antd';
|
||||
import HeaderDetails from 'components/HeaderDetails';
|
||||
import TeamsSvg from 'static/images/intro-teams.svg';
|
||||
import FundingSvg from 'static/images/intro-funding.svg';
|
||||
import CommunitySvg from 'static/images/intro-community.svg';
|
||||
|
@ -25,6 +26,11 @@ export default class Home extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<div className="Home">
|
||||
<HeaderDetails
|
||||
title="Home"
|
||||
description="Grant.io organizes creators and community members to incentivize ecosystem
|
||||
improvements"
|
||||
/>
|
||||
<div className="Home-hero">
|
||||
<h1 className="Home-hero-title">
|
||||
Decentralized funding for <br /> Blockchain ecosystem improvements
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { convert, MARKDOWN_TYPE } from 'utils/markdown';
|
||||
import './Markdown.less';
|
||||
|
||||
interface Props extends React.HTMLAttributes<any> {
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
source: string;
|
||||
type?: MARKDOWN_TYPE;
|
||||
}
|
||||
|
@ -11,8 +11,14 @@ export default class Markdown extends React.PureComponent<Props> {
|
|||
render() {
|
||||
const { source, type, ...rest } = this.props;
|
||||
const html = convert(source, type);
|
||||
// TS types seem to be fighting over react prop defs for div
|
||||
const divProps = rest as any;
|
||||
return (
|
||||
<div className="Markdown" {...rest} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<div
|
||||
className="Markdown"
|
||||
{...divProps}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ interface Props {
|
|||
}
|
||||
|
||||
interface State {
|
||||
mdeState: ReactMdeTypes.MdeState;
|
||||
mdeState: ReactMdeTypes.MdeState | null;
|
||||
}
|
||||
|
||||
export default class MarkdownEditor extends React.PureComponent<Props, State> {
|
||||
|
@ -55,7 +55,7 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
|
|||
|
||||
handleChange = (mdeState: ReactMdeTypes.MdeState) => {
|
||||
this.setState({ mdeState });
|
||||
this.props.onChange(mdeState.markdown);
|
||||
this.props.onChange(mdeState.markdown || '');
|
||||
};
|
||||
|
||||
generatePreview = (md: string) => {
|
||||
|
@ -73,7 +73,7 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
|
|||
>
|
||||
<ReactMde
|
||||
onChange={this.handleChange}
|
||||
editorState={this.state.mdeState}
|
||||
editorState={this.state.mdeState as ReactMdeTypes.MdeState}
|
||||
generateMarkdownPreview={this.generatePreview}
|
||||
commands={commands[type]}
|
||||
layout="tabbed"
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import './style.less';
|
||||
|
||||
export default class Privacy extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="Privacy">
|
||||
<h1 className="Privacy-title">Privacy Policy</h1>
|
||||
<section>
|
||||
<h2>1. Lorem Ipsum</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
|
||||
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
|
||||
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
|
||||
in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
|
||||
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
|
||||
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
|
||||
in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>2. Duis Aute Irure</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
|
||||
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
|
||||
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
|
||||
in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>3. Ullamco Laboris Reprehenderit</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
|
||||
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>4. Fugiat</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
|
||||
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
|
||||
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
|
||||
in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<b>Effective:</b> Oct 25, 2018
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
@import '~styles/legal-document-mixin.less';
|
||||
|
||||
.Privacy {
|
||||
.legal-document-mixin();
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
@small-query: ~'(max-width: 500px)';
|
||||
|
||||
.ProfileEditShade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ProfileEdit {
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
margin: -1rem;
|
||||
background: #fff;
|
||||
border-radius: 0.2rem;
|
||||
|
||||
@media @small-query {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.is-editing {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
position: relative;
|
||||
height: 10.5rem;
|
||||
width: 10.5rem;
|
||||
margin-right: 1.25rem;
|
||||
align-self: start;
|
||||
|
||||
@media @small-query {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&-img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&-change {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 1rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:hover:focus {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-delete {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 0.2rem;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
&:hover,
|
||||
&:hover:focus {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-alert {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
import React from 'react';
|
||||
import lodash from 'lodash';
|
||||
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
|
||||
import { SOCIAL_INFO } from 'utils/social';
|
||||
import { SOCIAL_TYPE, TeamMember } from 'types';
|
||||
import { UserState } from 'modules/users/reducers';
|
||||
import { getCreateTeamMemberError } from 'modules/create/utils';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import './ProfileEdit.less';
|
||||
|
||||
interface Props {
|
||||
user: UserState;
|
||||
onDone(): void;
|
||||
onEdit(user: TeamMember): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
fields: TeamMember;
|
||||
isChanged: boolean;
|
||||
showError: boolean;
|
||||
}
|
||||
|
||||
export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
fields: { ...this.props.user } as TeamMember,
|
||||
isChanged: false,
|
||||
showError: false,
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: Props, _: State) {
|
||||
if (
|
||||
prevProps.user.isUpdating &&
|
||||
!this.props.user.isUpdating &&
|
||||
!this.state.showError
|
||||
) {
|
||||
this.setState({ showError: true });
|
||||
}
|
||||
if (
|
||||
prevProps.user.isUpdating &&
|
||||
!this.props.user.isUpdating &&
|
||||
!this.props.user.updateError
|
||||
) {
|
||||
this.props.onDone();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { fields } = this.state;
|
||||
const error = getCreateTeamMemberError(fields);
|
||||
const isMissingField =
|
||||
!fields.name || !fields.title || !fields.emailAddress || !fields.ethAddress;
|
||||
const isDisabled = !!error || isMissingField || !this.state.isChanged;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ProfileEdit">
|
||||
<div className="ProfileEdit-avatar">
|
||||
<UserAvatar className="ProfileEdit-avatar-img" user={fields} />
|
||||
<Button
|
||||
className="ProfileEdit-avatar-change"
|
||||
onClick={this.handleChangePhoto}
|
||||
>
|
||||
<Icon
|
||||
className="ProfileEdit-avatar-change-icon"
|
||||
type={fields.avatarUrl ? 'picture' : 'plus-circle'}
|
||||
/>
|
||||
<div>{fields.avatarUrl ? 'Change photo' : 'Add photo'}</div>
|
||||
</Button>
|
||||
{fields.avatarUrl && (
|
||||
<Button
|
||||
className="ProfileEdit-avatar-delete"
|
||||
icon="delete"
|
||||
shape="circle"
|
||||
onClick={this.handleDeletePhoto}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ProfileEdit-info">
|
||||
<Form
|
||||
className="ProfileEdit-info-form"
|
||||
layout="vertical"
|
||||
onSubmit={this.handleSave}
|
||||
>
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="name"
|
||||
autoComplete="off"
|
||||
placeholder="Display name (Required)"
|
||||
value={fields.name}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="title"
|
||||
autoComplete="off"
|
||||
placeholder="Title (Required)"
|
||||
value={fields.title}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="emailAddress"
|
||||
disabled={true}
|
||||
placeholder="Email address (Required)"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={fields.emailAddress}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Input
|
||||
name="ethAddress"
|
||||
disabled={true}
|
||||
autoComplete="ethAddress"
|
||||
placeholder="Ethereum address (Required)"
|
||||
value={fields.ethAddress}
|
||||
onChange={this.handleChangeField}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={12}>
|
||||
{Object.values(SOCIAL_INFO).map(s => (
|
||||
<Col xs={24} sm={12} key={s.type}>
|
||||
<Form.Item>
|
||||
<Input
|
||||
placeholder={`${s.name} account`}
|
||||
autoComplete="off"
|
||||
value={fields.socialAccounts[s.type]}
|
||||
onChange={ev => this.handleSocialChange(ev, s.type)}
|
||||
addonBefore={s.icon}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{!isMissingField &&
|
||||
error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={error}
|
||||
showIcon
|
||||
style={{ marginBottom: '0.75rem' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={isDisabled}
|
||||
loading={this.props.user.isUpdating}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button type="ghost" htmlType="button" onClick={this.handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
{this.state.showError &&
|
||||
this.props.user.updateError && (
|
||||
<Alert
|
||||
className="ProfileEdit-alert"
|
||||
message={`There was an error attempting to update your profile. (code ${
|
||||
this.props.user.updateError
|
||||
})`}
|
||||
type="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ProfileEditShade" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private handleSave = (evt: React.SyntheticEvent<any>) => {
|
||||
evt.preventDefault();
|
||||
this.props.onEdit(this.state.fields);
|
||||
};
|
||||
|
||||
private handleCancel = () => {
|
||||
this.props.onDone();
|
||||
};
|
||||
|
||||
private handleChangeField = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = ev.currentTarget;
|
||||
const fields = {
|
||||
...this.state.fields,
|
||||
[name as any]: value,
|
||||
};
|
||||
const isChanged = this.isChangedCheck(fields);
|
||||
this.setState({
|
||||
isChanged,
|
||||
fields,
|
||||
});
|
||||
};
|
||||
|
||||
private handleSocialChange = (
|
||||
ev: React.ChangeEvent<HTMLInputElement>,
|
||||
type: SOCIAL_TYPE,
|
||||
) => {
|
||||
const { value } = ev.currentTarget;
|
||||
const fields = {
|
||||
...this.state.fields,
|
||||
socialAccounts: {
|
||||
...this.state.fields.socialAccounts,
|
||||
[type]: value,
|
||||
},
|
||||
};
|
||||
// delete key for empty string
|
||||
if (!value) {
|
||||
delete fields.socialAccounts[type];
|
||||
}
|
||||
const isChanged = this.isChangedCheck(fields);
|
||||
this.setState({
|
||||
isChanged,
|
||||
fields,
|
||||
});
|
||||
};
|
||||
|
||||
private handleChangePhoto = () => {
|
||||
// TODO: Actual file uploading
|
||||
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
|
||||
const num = Math.floor(Math.random() * 80);
|
||||
const fields = {
|
||||
...this.state.fields,
|
||||
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
|
||||
};
|
||||
const isChanged = this.isChangedCheck(fields);
|
||||
this.setState({
|
||||
isChanged,
|
||||
fields,
|
||||
});
|
||||
};
|
||||
|
||||
private handleDeletePhoto = () => {
|
||||
const fields = lodash.clone(this.state.fields);
|
||||
delete fields.avatarUrl;
|
||||
const isChanged = this.isChangedCheck(fields);
|
||||
this.setState({ isChanged, fields });
|
||||
};
|
||||
|
||||
private isChangedCheck = (a: TeamMember) => {
|
||||
return !lodash.isEqual(a, this.props.user);
|
||||
};
|
||||
}
|
|
@ -51,6 +51,7 @@
|
|||
|
||||
&-social {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
& a {
|
||||
display: block;
|
||||
|
|
|
@ -1,20 +1,59 @@
|
|||
import React from 'react';
|
||||
import { SocialInfo, TeamMember } from 'types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button } from 'antd';
|
||||
import { SocialInfo } from 'types';
|
||||
import { usersActions } from 'modules/users';
|
||||
import { UserState } from 'modules/users/reducers';
|
||||
import { typedKeys } from 'utils/ts';
|
||||
import ProfileEdit from './ProfileEdit';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import './ProfileUser.less';
|
||||
import { SOCIAL_INFO, socialAccountToUrl } from 'utils/social';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
import './ProfileUser.less';
|
||||
import { AppState } from 'store/reducers';
|
||||
|
||||
interface OwnProps {
|
||||
user: TeamMember;
|
||||
user: UserState;
|
||||
}
|
||||
|
||||
export default class Profile extends React.Component<OwnProps> {
|
||||
interface StateProps {
|
||||
authUser: AppState['auth']['user'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
updateUser: typeof usersActions['updateUser'];
|
||||
}
|
||||
|
||||
interface State {
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & DispatchProps;
|
||||
|
||||
class ProfileUser extends React.Component<Props> {
|
||||
state: State = {
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
authUser,
|
||||
user,
|
||||
user: { socialAccounts },
|
||||
} = this.props;
|
||||
|
||||
const isSelf = !!authUser && authUser.ethAddress === user.ethAddress;
|
||||
|
||||
if (this.state.isEditing) {
|
||||
return (
|
||||
<ProfileEdit
|
||||
user={user}
|
||||
onDone={() => this.setState({ isEditing: false })}
|
||||
onEdit={this.props.updateUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ProfileUser">
|
||||
<div className="ProfileUser-avatar">
|
||||
|
@ -37,15 +76,28 @@ export default class Profile extends React.Component<OwnProps> {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ProfileUser-info-social">
|
||||
{Object.values(SOCIAL_INFO).map(
|
||||
s =>
|
||||
(socialAccounts[s.type] && (
|
||||
<Social key={s.type} account={socialAccounts[s.type]} info={s} />
|
||||
)) ||
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
{Object.keys(socialAccounts).length > 0 && (
|
||||
<div className="ProfileUser-info-social">
|
||||
{typedKeys(SOCIAL_INFO).map(
|
||||
s =>
|
||||
(socialAccounts[s] && (
|
||||
<Social
|
||||
key={s}
|
||||
account={socialAccounts[s] as string}
|
||||
info={SOCIAL_INFO[s]}
|
||||
/>
|
||||
)) ||
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isSelf && (
|
||||
<div>
|
||||
<Button onClick={() => this.setState({ isEditing: true })}>
|
||||
Edit profile
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -59,3 +111,14 @@ const Social = ({ account, info }: { account: string; info: SocialInfo }) => {
|
|||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const connectedProfileUser = connect<StateProps, DispatchProps, {}, AppState>(
|
||||
state => ({
|
||||
authUser: state.auth.user,
|
||||
}),
|
||||
{
|
||||
updateUser: usersActions.updateUser,
|
||||
},
|
||||
)(ProfileUser);
|
||||
|
||||
export default connectedProfileUser;
|
||||
|
|
|
@ -6,6 +6,7 @@ import { AppState } from 'store/reducers';
|
|||
import { connect } from 'react-redux';
|
||||
import { compose } from 'recompose';
|
||||
import { Spin, Tabs, Badge } from 'antd';
|
||||
import HeaderDetails from 'components/HeaderDetails';
|
||||
import ProfileUser from './ProfileUser';
|
||||
import ProfileProposal from './ProfileProposal';
|
||||
import ProfileComment from './ProfileComment';
|
||||
|
@ -43,7 +44,7 @@ class Profile extends React.Component<Props> {
|
|||
const userLookupParam = this.props.match.params.id;
|
||||
const { authUser } = this.props;
|
||||
if (!userLookupParam) {
|
||||
if (authUser.ethAddress) {
|
||||
if (authUser && authUser.ethAddress) {
|
||||
return <Redirect to={`/profile/${authUser.ethAddress}`} />;
|
||||
} else {
|
||||
return <Redirect to="auth" />;
|
||||
|
@ -58,7 +59,7 @@ class Profile extends React.Component<Props> {
|
|||
}
|
||||
|
||||
if (user.fetchError) {
|
||||
return <Exception type="404" />;
|
||||
return <Exception code="404" />;
|
||||
}
|
||||
|
||||
const { createdProposals, fundedProposals, comments } = user;
|
||||
|
@ -68,6 +69,13 @@ class Profile extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<div className="Profile">
|
||||
{/* TODO: SSR fetch user details */}
|
||||
{/* TODO: customize details for funders/creators */}
|
||||
<HeaderDetails
|
||||
title={`${user.name} is funding projects on Grant.io`}
|
||||
description={`Join ${user.name} in funding the future!`}
|
||||
image={user.avatarUrl}
|
||||
/>
|
||||
<ProfileUser user={user} />
|
||||
<Tabs>
|
||||
<Tabs.TabPane
|
||||
|
@ -134,8 +142,8 @@ const TabTitle = (disp: string, count: number) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const withConnect = connect<StateProps, DispatchProps>(
|
||||
(state: AppState) => ({
|
||||
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
|
||||
state => ({
|
||||
usersMap: state.users.map,
|
||||
authUser: state.auth.user,
|
||||
}),
|
||||
|
@ -147,7 +155,7 @@ const withConnect = connect<StateProps, DispatchProps>(
|
|||
},
|
||||
);
|
||||
|
||||
export default compose<Props, any>(
|
||||
export default compose<Props, {}>(
|
||||
withRouter,
|
||||
withConnect,
|
||||
)(Profile);
|
||||
|
|
|
@ -194,17 +194,18 @@ export class Milestones extends React.Component<Props> {
|
|||
return (
|
||||
<div className="MilestonAction">
|
||||
<div className="MilestoneAction-top">
|
||||
{showVoteProgress && (
|
||||
<div className="MilestoneAction-progress">
|
||||
<Progress
|
||||
type="dashboard"
|
||||
percent={activeVoteMilestone.percentAgainstPayout}
|
||||
format={p => `${p}%`}
|
||||
status="exception"
|
||||
/>
|
||||
<div className="MilestoneAction-progress-text">voted against payout</div>
|
||||
</div>
|
||||
)}
|
||||
{showVoteProgress &&
|
||||
activeVoteMilestone && (
|
||||
<div className="MilestoneAction-progress">
|
||||
<Progress
|
||||
type="dashboard"
|
||||
percent={activeVoteMilestone.percentAgainstPayout}
|
||||
format={p => `${p}%`}
|
||||
status="exception"
|
||||
/>
|
||||
<div className="MilestoneAction-progress-text">voted against payout</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{content}
|
||||
{button && (
|
||||
|
|
|
@ -43,7 +43,7 @@ interface State {
|
|||
}
|
||||
|
||||
class ProposalMilestones extends React.Component<Props, State> {
|
||||
stepTitleRefs: Array<React.RefObject<HTMLDivElement>>;
|
||||
stepTitleRefs: Array<React.RefObject<HTMLDivElement>> = [];
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
throttledUpdateDoTitlesOverflow: () => void;
|
||||
constructor(props: Props) {
|
||||
|
@ -261,23 +261,34 @@ class ProposalMilestones extends React.Component<Props, State> {
|
|||
|
||||
private updateDoTitlesOverflow = () => {
|
||||
// hmr can sometimes muck up refs, let's make sure they all exist
|
||||
if (!this.stepTitleRefs.reduce((a, r) => !!r.current && a)) return;
|
||||
if (!this.ref || !this.ref.current || !this.stepTitleRefs) {
|
||||
return;
|
||||
}
|
||||
if (!this.stepTitleRefs.reduce((a, r) => !!r.current && a, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let doTitlesOverflow = false;
|
||||
const stepCount = this.stepTitleRefs.length;
|
||||
if (stepCount > 1) {
|
||||
// avoiding style calculation here by hardcoding antd icon width + padding + margin
|
||||
const iconWidths = stepCount * 56;
|
||||
const totalWidth = this.ref.current.clientWidth;
|
||||
const last = this.stepTitleRefs.slice(stepCount - 1).pop().current;
|
||||
// last title gets full space
|
||||
const lastWidth = last.clientWidth;
|
||||
const remainingWidth = totalWidth - (lastWidth + iconWidths);
|
||||
const remainingWidthSingle = remainingWidth / (stepCount - 1);
|
||||
// first titles have to share remaining space
|
||||
this.stepTitleRefs.slice(0, stepCount - 1).forEach(r => {
|
||||
doTitlesOverflow =
|
||||
doTitlesOverflow || r.current.clientWidth > remainingWidthSingle;
|
||||
});
|
||||
const last = this.stepTitleRefs[stepCount - 1].current;
|
||||
if (last) {
|
||||
// last title gets full space
|
||||
const lastWidth = last.clientWidth;
|
||||
const remainingWidth = totalWidth - (lastWidth + iconWidths);
|
||||
const remainingWidthSingle = remainingWidth / (stepCount - 1);
|
||||
// first titles have to share remaining space
|
||||
doTitlesOverflow = this.stepTitleRefs
|
||||
.slice(0, stepCount - 1)
|
||||
.reduce(
|
||||
(prev, r) =>
|
||||
prev || (r.current ? r.current.clientWidth : 0) > remainingWidthSingle,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.setState({ doTitlesOverflow });
|
||||
};
|
||||
|
|
|
@ -30,7 +30,7 @@ interface OwnProps {
|
|||
}
|
||||
|
||||
interface StateProps {
|
||||
proposal: ProposalWithCrowdFund;
|
||||
proposal: ProposalWithCrowdFund | null;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue