Compare commits

..

3 Commits

Author SHA1 Message Date
basti 970b390509 Show contribution history from events 2017-08-14 17:33:38 +02:00
basti b8f4040def Get contributions from minted events (WIP) 2017-08-14 17:15:11 +02:00
basti 8fbecd928b Update deps 2017-08-14 17:14:17 +02:00
258 changed files with 2322 additions and 68785 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"directory": "bower_components",
"analytics": false
}
+1
View File
@@ -4,6 +4,7 @@
root = true
[*]
end_of_line = lf
charset = utf-8
+1 -1
View File
@@ -5,5 +5,5 @@
Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": true
"disableAnalytics": false
}
-21
View File
@@ -1,21 +0,0 @@
# unconventional js
/blueprints/*/files/
/vendor/
# compiled output
/dist/
/tmp/
/release/
# dependencies
/bower_components/
/node_modules/
# misc
/coverage/
!.*
# ember-try
/.node_modules.ember-try/
/bower.json.ember-try
/package.json.ember-try
-77
View File
@@ -1,77 +0,0 @@
'use strict';
module.exports = {
root: true,
parser: '@babel/eslint-parser',
parserOptions: {
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
legacyDecorators: true
},
babelOptions: {
babelrc: false,
configFile: false,
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
],
},
},
globals: {
console: true
},
plugins: [
'ember'
],
extends: [
'eslint:recommended',
'plugin:ember/recommended'
],
env: {
browser: true
},
rules: {
'ember/avoid-leaking-state-in-ember-objects': 'warn',
'no-console': 'off',
'ember/no-jquery': 'error',
'ember/require-computed-property-dependencies': 'warn',
'ember/no-computed-properties-in-native-classes': 'warn',
'ember/no-observers': 'warn',
'ember/no-classic-classes': 'warn',
'ember/no-classic-components': 'warn',
'ember/no-controller-access-in-routes': 'warn',
'ember/no-actions-hash': 'warn',
'ember/require-tagless-components': 'warn'
},
overrides: [
// node files
{
files: [
'.eslintrc.js',
'.template-lintrc.js',
'ember-cli-build.js',
'testem.js',
'blueprints/*/index.js',
'config/**/*.js',
'lib/*/index.js',
'server/**/*.js'
],
parserOptions: {
sourceType: 'script'
},
env: {
browser: false,
node: true
},
plugins: ['node'],
rules: Object.assign({}, require('eslint-plugin-node').configs.recommended.rules, {
// add your custom rules and overrides for node files here
// this can be removed once the following is fixed
// https://github.com/mysticatea/eslint-plugin-node/issues/77
'node/no-unpublished-require': 'off'
})
}
]
};
-4
View File
@@ -1,4 +0,0 @@
template: |
## Changes
$CHANGES
-30
View File
@@ -1,30 +0,0 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm install
- run: npm test
+7 -16
View File
@@ -1,26 +1,17 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist/
/tmp/
/dist
/tmp
# dependencies
/bower_components/
/node_modules/
/node_modules
/bower_components
# misc
/.env*
/.pnp*
/.sass-cache
/connect.lock
/coverage/
/coverage/*
/libpeerconnection.log
/npm-debug.log*
/testem.log
/yarn-error.log
.tm_properties
# ember-try
/.node_modules.ember-try/
/bower.json.ember-try
/package.json.ember-try
npm-debug.log*
testem.log
+32
View File
@@ -0,0 +1,32 @@
{
"predef": [
"document",
"window",
"-Promise"
],
"browser": true,
"boss": true,
"curly": true,
"debug": false,
"devel": true,
"eqeqeq": true,
"evil": true,
"forin": false,
"immed": false,
"laxbreak": false,
"newcap": true,
"noarg": true,
"noempty": false,
"nonew": false,
"nomen": false,
"onevar": false,
"plusplus": false,
"regexp": false,
"undef": true,
"sub": true,
"strict": false,
"white": false,
"eqnull": true,
"esversion": 6,
"unused": true
}
-100
View File
@@ -1,100 +0,0 @@
'use strict';
module.exports = {
extends: 'octane',
rules: {
'simple-unless': false,
'no-nested-interactive': false,
'no-html-comments': false
},
ignore: [
'kredits-web/templates/components/**',
'app/templates/components/**'
],
pending: [
{
"moduleId": "app/templates/dashboard",
"only": [
"no-action"
]
},
{
"moduleId": "app/components/add-contribution/template",
"only": [
"no-action",
"no-curly-component-invocation"
]
},
{
"moduleId": "app/components/add-contributor/template",
"only": [
"no-action",
"no-curly-component-invocation"
]
},
{
"moduleId": "app/components/contribution-list/template",
"only": [
"no-action",
"no-curly-component-invocation"
]
},
{
"moduleId": "app/components/contributor-list/template",
"only": [
"no-action"
]
},
{
"moduleId": "app/components/expense-list/template",
"only": [
"no-invalid-role"
]
},
{
"moduleId": "app/components/topbar-account-panel/template",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributions/new",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributions/resubmit",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributors/edit",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributors/new",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/signup/eth-account",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/signup/index",
"only": [
"no-action"
]
}
]
};
+25
View File
@@ -0,0 +1,25 @@
---
language: node_js
node_js:
- "6"
sudo: false
cache:
directories:
- $HOME/.npm
- $HOME/.cache # includes bowers cache
before_install:
- npm config set spin false
- npm install -g bower
- bower --version
- npm install phantomjs-prebuilt
- node_modules/phantomjs-prebuilt/bin/phantomjs --version
install:
- npm install
- bower install
script:
- npm test
+33 -79
View File
@@ -1,123 +1,77 @@
[![Build Status](https://github.com/67P/kredits-web/actions/workflows/ci.yml/badge.svg)](https://github.com/67P/kredits-web/actions/workflows/ci.yml?query=branch%3Amaster)
[![Build Status](https://travis-ci.org/67P/kredits-web.svg?branch=master)](https://travis-ci.org/67P/kredits-web)
# Kredits Web
# kredits-web
This is the main Web UI for Kosmos [Kredits](https://wiki.kosmos.org/Kredits).
It provides an overview of contributors and contributions, as well as the
community's budget, expenses, and reimbursements.
This README outlines the details of collaborating on this Ember application.
A short introduction of this app could easily go here.
It is an unhosted Web app, fetching all data from
[Rootstock](https://rootstock.io/) and [IPFS](https://ipfs.tech/), and caching
it in the browser's local database. As such, you can run the app directly from
the `/release` directory on any Web server that can serve static assets.
## Development
### Prerequisites
## Prerequisites
You will need the following things properly installed on your computer.
* [Git](https://git-scm.com/)
* [Node.js](https://nodejs.org/) (with npm)
* [Node.js](https://nodejs.org/) (with NPM)
* [Bower](https://bower.io/)
* [Ember CLI](https://ember-cli.com/)
* [Google Chrome](https://google.com/chrome/) (only for running tests)
* [PhantomJS](http://phantomjs.org/)
### Installation
## Installation
* `git clone git@github.com:67P/kredits-web.git` this repository
* `git clone <repository-url>` this repository
* `cd kredits-web`
* `npm install`
* `bower install`
### Building/running for development
## Running / Development
* `npm start` - by default Kredits Web connects to the Rootstock testnet network
* Visit the app at [http://localhost:4200](http://localhost:4200).
* Visit the tests at [http://localhost:4200/tests](http://localhost:4200/tests).
* `ember serve`
* Visit your app at [http://localhost:4200](http://localhost:4200).
See [working with locally deployed contracts](https://github.com/67P/kredits-web#working-with-locally-deployed-contracts) for details on how to develop with locally deployed contracts.
### Code generators
### Code Generators
Make use of the many generators for code, try `ember help generate` for more details
### Running tests
### Running Tests
* `ember test`
* `ember test --server`
### Linting
* `npm run lint:hbs`
* `npm run lint:js`
* `npm run lint:js -- --fix`
### Building
* `ember build` (development)
* `npm run build` (production)
* `ember build --environment production` (production)
### Deploying
_(You need collaborator permissions on the 5apps Deploy project.)_
Specify what it takes to deploy your app.
`npm run deploy`
### Working with locally deployed contracts
## Working with locally deployed contracts
The smart contracts and their JavaScript wrapper library are developed in the
[kredits-contracts](https://github.com/67P/kredits-contracts) repo/package.
For development you might want to run your own development chain with your own development contracts.
The kredits-contracts helper scripts are available in kredits-web. See [kredits-contracts](https://github.com/67P/kredits-contracts) for more information.
You can run `kredits-web` on your machine, against a local, simulated
blockchain. [kredits-contracts](https://github.com/67P/kredits-contracts)
contains all the tools to start and set up such a simulated network, as well as
to deploy the Kredits smart contracts to it.
Start your parity node with a KreditsChain:
npm run kredits-parity
These are the basic steps to get up and running:
Deploy the contracts to your local chain:
#### 1. IPFS
npm run kredits-deploy
Run a local IPFS deamon.
Run kredits-web with your localy deployed contracts:
* Make sure CORS headers are configured. See [IPFS](#ipfs) for more info.
* `ipfs daemon`
KREDITS_CONTRACT_ADDR=`kredits-inspect -c Kredits -r -w address` \
TOKEN_CONTRACT_ADDR=`kredits-inspect -c Token -r -w address` \
npm start
#### 2. kredits-contracts
You need to have `./node_modules/.bin` in your path for this to work. Otherwise simply copy the contract addresses and set the environment variables manually.
Run a local devchain with test data. (See [kredits-contracts
README](https://github.com/67P/kredits-contracts) for details.
* Clone [kredits-contracts](https://github.com/67P/kredits-contracts)
* `npm install`
* `npm run devchain` - runs a local development chain
* `npm run bootstrap` - deploys all contracts and seeds test data
* `npm link` - makes the `kredits-contracts` module linkable as `kredits-contracts` on your machine
#### 3. kredits-web
With IPFS and the local devchain running, you can now link the contracts and
start the Ember app:
* `npm link kredits-contracts` - links the local `kredits-contracts` package (has to be done again after every `npm install`)
* `npm run start:local` - runs the Ember app with WEB3_PROVIDER_URL=http://localhost:8545 set
#### 4. Metamask network
If you want to interact with the local contracts via a Web3 wallet, switch the
network to a "Custom RPC" one, with the RPC URL `http://localhost:8545`.
#### IPFS
If you haven't configured your IPFS node for CORS yet, you can do so by running
the following commands:
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["localhost:4200"]'
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "GET", "POST"]'
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Credentials '["true"]'
ipfs config Addresses.Gateway /ip4/127.0.0.1/tcp/8080
## Further Reading / Useful Links
* [ember.js](https://emberjs.com/)
* [ember.js](http://emberjs.com/)
* [ember-cli](https://ember-cli.com/)
* Development Browser Extensions
* [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
+13 -7
View File
@@ -1,12 +1,18 @@
import Application from '@ember/application';
import Resolver from 'ember-resolver';
import Ember from 'ember';
import Resolver from './resolver';
import loadInitializers from 'ember-load-initializers';
import config from './config/environment';
export default class App extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
}
let App;
Ember.MODEL_FACTORY_INJECTIONS = true;
App = Ember.Application.extend({
modulePrefix: config.modulePrefix,
podModulePrefix: config.podModulePrefix,
Resolver
});
loadInitializers(App, config.modulePrefix);
export default App;
@@ -1,88 +0,0 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { and, notEmpty } from '@ember/object/computed';
import { assign } from '@ember/polyfills';
import moment from 'moment';
import { inject as service } from '@ember/service';
export default Component.extend({
kredits: service(),
attributes: null,
contributors: computed('kredits.contributorsSorted.[]', function() {
return this.kredits.contributorsSorted.map(c => {
return {
id: c.id.toString(),
name: c.name
}
})
}),
isValidContributor: notEmpty('contributorId'),
isValidKind: notEmpty('kind'),
isValidAmount: computed('amount', function() {
return parseInt(this.amount, 10) > 0;
}),
isValidDescription: notEmpty('description'),
isValidUrl: notEmpty('url'),
isValid: and('isValidContributor',
'isValidKind',
'isValidAmount',
'isValidDescription'),
init () {
this._super(...arguments);
this.set('defaultDate', moment().startOf('hour').toDate());
this.set('defaultAttr', {
contributorId: null,
kind: null,
date: this.defaultDate,
amount: null,
description: null,
url: null,
details: null
});
this.set('attributes', assign({}, this.defaultAttr, this.attributes));
this.reset();
},
reset () {
this.setProperties(this.attributes);
},
actions: {
submit (evt) {
evt.preventDefault();
if (!this.isValid) {
alert('Invalid data. Please review and try again.');
return;
}
const attributes = this.getProperties(Object.keys(this.attributes));
attributes.contributorId = parseInt(this.contributorId);
let dateInput = (attributes.date instanceof Array) ?
attributes.date[0] : attributes.date;
const [ date, time ] = dateInput.toISOString().split('T');
[ attributes.date, attributes.time ] = [ date, time ];
this.set('inProgress', true);
this.save(attributes)
.then((/*contribution*/) => {
this.reset();
}, err => {
console.warn(err);
window.alert('Something went wrong. Check the browser console for details.');
})
.finally(() => this.set('inProgress', false));
}
}
});
@@ -1,86 +0,0 @@
<form onsubmit={{action "submit"}}>
<label>
<p class="label">Contributor:</p>
<p>
<select required onchange={{action (mut this.contributorId) value="target.value"}}>
<option value="" selected disabled hidden></option>
{{#each this.contributors as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</p>
</label>
<label>
<p class="label">Kind:</p>
<p>
<select required onchange={{action (mut this.kind) value="target.value"}}>
<option value="" selected disabled hidden></option>
<option value="bureaucracy" selected={{eq this.kind "bureaucracy"}}>Bureaucracy</option>
<option value="community" selected={{eq this.kind "community"}}>Community</option>
<option value="design" selected={{eq this.kind "design"}}>Design</option>
<option value="dev" selected={{eq this.kind "dev"}}>Development</option>
<option value="docs" selected={{eq this.kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq this.kind "ops"}}>IT Operations</option>
<option value="outreach" selected={{eq this.kind "outreach"}}>Outreach</option>
<option value="qa" selected={{eq this.kind "qa"}}>Quality Assurance</option>
<option value="special" selected={{eq this.kind "special"}}>Special</option>
</select>
</p>
</label>
<label>
<p class="label">Date:</p>
<p>
{{ember-flatpickr
date=this.date
defaultDate=this.defaultDate
maxDate=this.defaultDate
enableTime=true
time_24hr=true
onChange=(action (mut this.date))
}}
</p>
</label>
<label>
<p class="label">Amount:</p>
<p>
{{input type="text"
placeholder="500"
value=this.amount
class=(if this.isValidAmount "valid" "")}}
</p>
</label>
<label>
<p class="label">Description:</p>
<p>
{{input type="text"
value=this.description
class=(if this.isValidDescription "valid" "")}}
</p>
</label>
<label>
<p class="label">URL (optional):</p>
<p>
{{input type="text"
value=this.url
class=(if this.isValidUrl "valid" "")}}
</p>
</label>
{{#if this.details}}
<label>
<p class="label">Details:</p>
<p>
<pre>
{{this.details}}
</pre>
</p>
</label>
{{/if}}
<p class="actions">
{{input type="submit"
disabled=this.inProgress
value=(if this.inProgress "Processing" "Save")}}
</p>
</form>
+55 -67
View File
@@ -1,91 +1,79 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { and, notEmpty } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { isPresent } from '@ember/utils';
import { isAddress } from 'web3-utils';
import Ember from 'ember';
import Contributor from 'kredits-web/models/contributor';
const {
Component,
isPresent,
inject: {
service
},
computed
} = Ember;
export default Component.extend({
router: service(),
kredits: service(),
attributes: null,
newContributor: null,
inProgress: false,
isValidAccount: computed('account', function() {
return isAddress(this.account);
}),
isValidAddress: function() {
return this.get('kredits.web3')
.isAddress(this.get('newContributor.address'));
}.property('kredits.web3', 'newContributor.address'),
isValidName: notEmpty('name'),
isValidURL: notEmpty('url'),
isValidGithubUID: notEmpty('github_uid'),
isValidGithubUsername: notEmpty('github_username'),
isValidGiteaUsername: notEmpty('gitea_username'),
isValidWikiUsername: notEmpty('wiki_username'),
isValidZoomDisplayName: notEmpty('zoom_display_name'),
isValidName: function() {
return isPresent(this.get('newContributor.name'));
}.property('newContributor.name'),
isValid: and(
'isValidAccount',
isValidURL: function() {
return isPresent(this.get('newContributor.url'));
}.property('newContributor.url'),
isValidGithubUID: function() {
return isPresent(this.get('newContributor.github_uid'));
}.property('newContributor.github_uid'),
isValidGithubUsername: function() {
return isPresent(this.get('newContributor.github_username'));
}.property('newContributor.github_username'),
isValidWikiUsername: function() {
return isPresent(this.get('newContributor.wiki_username'));
}.property('newContributor.wiki_username'),
isValid: computed.and(
'isValidAddress',
'isValidName',
'isValidGithubUID'
),
inProgress: false,
init () {
this._super(...arguments);
this.setDefaultAttributes();
this.reset();
},
setDefaultAttributes () {
if (isPresent(this.attributes)) { return; }
this.set('attributes', {
account: null,
name: null,
kind: 'person',
url: null,
github_username: null,
github_uid: null,
gitea_username: null,
wiki_username: null,
zoom_display_name: null
});
},
reset: function() {
this.setProperties(this.attributes);
this.setProperties({
newContributor: Contributor.create({ kind: 'person' }),
inProgress: false
});
},
actions: {
submit (evt) {
evt.preventDefault();
if (!this.kredits.currentUserIsCore) {
window.alert('Only core team members can edit profiles. Please ask someone to set you up.');
return;
}
if (!this.isValid) {
window.alert('Invalid data. Please review and try again.');
save() {
if (!this.get('contractInteractionEnabled')) {
alert('Only core team members can add new contributors. Please ask someone to set you up.');
return;
}
const attributes = Object.keys(this.attributes);
const contributor = this.getProperties(attributes);
if (this.get('isValid')) {
this.set('inProgress', true);
this.set('inProgress', true);
this.save(contributor).then(() => {
this.reset();
}).catch(err => {
console.warn(err);
window.alert('Something went wrong. Please check the browser console.');
}).finally(() => {
this.set('inProgress', false);
this.router.transitionTo('dashboard');
});
this.get('kredits').addContributor(this.get('newContributor')).then(contributor => {
this.reset();
this.get('contributors').pushObject(contributor);
window.scroll(0,0);
});
} else {
alert('Invalid data. Please review and try again.');
}
}
}
+41 -38
View File
@@ -1,56 +1,59 @@
<form onsubmit={{action "submit"}}>
<form {{action "save" on="submit"}}>
<p>
<label for="c-account">Rootstock account</label>
<Input @type="text"
@value={{this.account}}
name="account" id="c-account"
placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4"
class={{if this.isValidAccount "valid" ""}} />
{{input type="checkbox" name="is-core" id="is-core" checked=newContributor.isCore}}
<label for="is-core" class="checkbox">
Core team member (can add contributors)
</label>
</p>
<p>
<label for="c-kind">Kind</label>
<select required onchange={{action (mut this.kind) value="target.value"}} id="c-kind">
<option value="person" selected={{eq this.kind "person"}}>Person</option>
<option value="organization" selected={{eq this.kind "organization"}}>Organization</option>
{{input name="address"
type="text"
placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4"
value=newContributor.address
class=(if isValidAddress 'valid' '')}}
</p>
<p>
<select required onchange={{action (mut newContributor.kind) value="target.value"}}>
<option value="person" selected={{eq newContributor.kind "person"}}>Person</option>
<option value="organization" selected={{eq newContributor.kind "organization"}}>Organization</option>
</select>
</p>
<p>
<label for="c-name">Name</label>
{{input name="name" type="text" value=this.name placeholder="Zero Cool"
class=(if this.isValidName "valid" "") id="c-name"}}
{{input name="name"
type="text"
placeholder="Name"
value=newContributor.name
class=(if isValidName 'valid' '')}}
</p>
<p>
<label for="c-url">URL</label>
{{input name="url" type="text" value=this.url placeholder="http://zerocool.bit"
class=(if this.isValidURL "valid" "") id="c-url"}}
{{input name="url"
type="text"
placeholder="URL"
value=newContributor.url
class=(if isValidURL 'valid' '')}}
</p>
<p>
<label for="c-github-uid">GitHub UID</label>
{{input name="github_uid" type="text" value=this.github_uid placeholder="2342"
class=(if this.isValidGithubUID "valid" "") id="c-github-uid"}}
{{input name="github_uid"
type="text"
placeholder="GitHub UID (123)"
value=newContributor.github_uid
class=(if isValidGithubUID 'valid' '')}}
</p>
<p>
<label for="c-github-username">GitHub username</label>
{{input name="github_username" type="text" value=this.github_username placeholder="zerocool"
class=(if this.isValidGithubUsername "valid" "") id="c-github-username"}}
{{input name="github_username"
type="text"
placeholder="GitHub username"
value=newContributor.github_username
class=(if isValidGithubUsername 'valid' '')}}
</p>
<p>
<label for="c-gitea-username">Gitea username</label>
{{input name="gitea_username" type="text" value=this.gitea_username placeholder="zerocool"
class=(if this.isValidGiteaUsername "valid" "") id="c-gitea-username"}}
</p>
<p>
<label for="c-wiki-username">Wiki username</label>
{{input name="wiki_username" type="text" value=this.wiki_username placeholder="ZeroCool"
class=(if this.isValidWikiUsername "valid" "") id="c-wiki-username"}}
</p>
<p>
<label for="c-zoom-display-name">Zoom display name</label>
{{input name="zoom_display_name" type="text" value=this.zoom_display_name placeholder="Zero Cool"
class=(if this.isValidZoomDisplayName "valid" "") id="c-zoom-display-name"}}
{{input name="wiki_username"
type="text"
placeholder="Wiki Username"
value=newContributor.wiki_username
class=(if isValidWikiUsername 'valid' '')}}
</p>
<p class="actions">
{{input type="submit" disabled=this.inProgress
value=(if this.inProgress "Processing" "Save")}}
{{input type="submit" value=(if inProgress 'Processing' 'Save') disabled=inProgress}}
</p>
</form>
@@ -1,91 +0,0 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import moment from 'moment';
import isValidAmount from 'kredits-web/utils/is-valid-amount';
import { isPresent } from '@ember/utils';
export default class AddExpenseItemComponent extends Component {
@tracked amount = '0';
@tracked currency = 'EUR';
@tracked date = moment().startOf('hour').toDate();
@tracked title = '';
@tracked description = '';
@tracked url = '';
@tracked tags = '';
defaultDate = moment().startOf('hour').toDate();
currencies = [
{ code: 'EUR' },
{ code: 'USD' }
];
get isValidAmount () {
return isValidAmount(this.amount);
}
get amountInputClass () {
return this.isValidTotal ? 'valid' : '';
}
validateForm () {
const formEl = document.querySelector('form#add-expense-item');
const inputFields = formEl.querySelectorAll('input');
inputFields.forEach(i => i.classList.remove('invalid'));
let validity = true;
if (!this.isValidAmount) {
document.querySelector('input[name=expense-amount]').classList.add('invalid');
validity = false;
}
if (!formEl.checkValidity()) {
inputFields.forEach(i => {
if (!i.validity.valid) {
i.classList.add('invalid');
validity = false;
}
})
}
return validity;
}
@action
updateCurrency(event) {
this.currency = event.target.value;
}
@action
submit (e) {
e.preventDefault();
let dateInput = (this.date instanceof Array) ?
this.date[0] : this.date;
const [ date ] = moment(dateInput).utcOffset(0, true)
.toISOString()
.split('T');
const isValid = this.validateForm();
if (!isValid) return false;
const expense = {
amount: parseFloat(this.amount),
currency: this.currency,
date: date,
title: this.title,
description: isPresent(this.description) ? this.description : undefined,
url: isPresent(this.url) ? this.url : undefined,
}
if (isPresent(this.tags)) {
expense.tags = this.tags.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
}
this.args.addExpenseItem(expense);
}
}
@@ -1,69 +0,0 @@
<form id="add-expense-item" {{on "submit" this.submit}} novalidate>
<fieldset class="horizontal">
<label>
<p class="label">Amount:</p>
<p>
<Input @name="expense-amount"
@type="text"
@placeholder="10"
@value={{this.amount}}
@required={{true}}
@pattern="([0-9]*[.])?[0-9]+"
@class={{this.amountInputClass}} />
</p>
</label>
<label>
<p class="label">Currency:</p>
<p>
<select required name="expense-currency" {{on "change" this.updateCurrency}}>
<option value="" selected disabled hidden></option>
{{#each this.currencies as |currency|}}
<option value={{currency.code}} selected={{eq this.currency currency.code}}>{{currency.code}}</option>
{{/each}}
</select>
</p>
</label>
</fieldset>
<label>
<p class="label">Date:</p>
<p>
<EmberFlatpickr @date={{this.date}}
@defaultDate={{this.defaultDate}}
@maxDate={{this.defaultDate}}
@enableTime={{false}}
@onChange={{fn (mut this.date)}} />
</p>
</label>
<label>
<p class="label">Title:</p>
<p>
<Input @name="expense-title"
@type="text"
@value={{this.title}}
@required={{true}} />
</p>
</label>
<label>
<p class="label">Description (optional):</p>
<p>
<Input @name="expense-description" @type="text" @value={{this.description}} />
</p>
</label>
<label>
<p class="label">URL (optional):</p>
<p>
<Input @name="expense-url" @type="url" @value={{this.url}} />
</p>
</label>
<label>
<p class="label">Tags (comma-separated, optional):</p>
<p>
<Input @name="expense-tags" @type="text" @value={{this.tags}} />
</p>
</label>
<p class="actions">
<Input @type="submit" @value="Add" @class="green"
@title="Add item to reimbursement" />
</p>
</form>
+66
View File
@@ -0,0 +1,66 @@
import Ember from 'ember';
const {
Component,
isPresent,
inject: {
service
},
computed
} = Ember;
export default Component.extend({
kredits: service(),
proposal: null,
contributors: null,
inProgress: false,
isValidRecipient: computed('proposal.recipientAddress', function() {
return this.get('kredits.web3').isAddress(this.get('proposal.recipientAddress'));
}),
isValidAmount: computed('proposal.amount', function() {
return parseInt(this.get('proposal.amount'), 10) > 0;
}),
isValidUrl: computed('proposal.url', function() {
return isPresent(this.get('proposal.url'));
}),
isValidDescription: computed('proposal.description', function() {
return isPresent(this.get('proposal.description'));
}),
isValid: computed.and('isValidRecipient',
'isValidAmount',
'isValidDescription'),
actions: {
save() {
if (! this.get('isValid')) {
alert('Invalid data. Please review and try again.');
return false;
}
this.set('inProgress', true);
let proposal = this.get('proposal');
// Set the recipient's IPFS profile hash so it can be used in the
// contribution object (which is to be stored in IPFS as well)
let contributor = this.get('contributors').findBy('address', proposal.get('recipientAddress'));
proposal.set('recipientProfile', contributor.get('ipfsHash'));
this.get('kredits').addProposal(proposal)
.then(() => {
this.attrs.onSave();
}).catch((error) => {
Ember.Logger.error('[add-proposal] error creating the proposal', error);
alert('Something went wrong.');
}).finally(() => {
this.set('inProgress', false);
});
}
}
});
+42
View File
@@ -0,0 +1,42 @@
<form {{action "save" on="submit"}}>
<p>
<select required onchange={{action (mut proposal.recipientAddress) value="target.value"}}>
<option value="" selected disabled hidden>Contributor</option>
{{#each contributors as |contributor|}}
<option value={{contributor.address}} selected={{eq proposal.recipientAddress contributor.address}}>{{contributor.github_username}}</option>
{{/each}}
</select>
</p>
<p>
<select required onchange={{action (mut proposal.kind) value="target.value"}}>
<option value="community" selected={{eq proposal.kind "community"}}>Community</option>
<option value="design" selected={{eq proposal.kind "design"}}>Design</option>
<option value="dev" selected={{eq proposal.kind "dev"}}>Development</option>
<option value="docs" selected={{eq proposal.kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq proposal.kind "ops"}}>IT Operations</option>
</select>
</p>
<p>
{{input type="text"
placeholder="100"
value=proposal.amount
class=(if isValidAmount 'valid' '')}}
</p>
<p>
{{input type="text"
placeholder="Description"
value=proposal.description
class=(if isValidDescription 'valid' '')}}
</p>
<p>
{{input type="text"
placeholder="URL (optional)"
value=proposal.url
class=(if isValidUrl 'valid' '')}}
</p>
<p class="actions">
{{input type="submit" value=(if inProgress 'Processing' 'Save') disabled=inProgress}}
{{#link-to 'index'}}Back{{/link-to}}
</p>
</form>
@@ -1,165 +0,0 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { A } from '@ember/array';
import { scheduleOnce } from '@ember/runloop';
import isValidAmount from 'kredits-web/utils/is-valid-amount';
import readFileContent from 'kredits-web/utils/read-file-content';
import config from 'kredits-web/config/environment';
export default class AddReimbursementComponent extends Component {
@service router;
@service kredits;
@service exchangeRates;
@alias('kredits.contributorsSorted') contributors;
@tracked recipientId = null;
@tracked title = '';
@tracked total = '0';
@tracked expenses = A([]);
@tracked expenseFormVisible = true;
constructor() {
super(...arguments);
this.exchangeRates.fetchRates();
}
get isValidTotal () {
return isValidAmount(this.total);
}
get totalInputClass () {
return this.isValidTotal ? 'valid' : '';
}
get totalEUR () {
const expenses = this.expenses.filterBy('currency', 'EUR');
if (expenses.length > 0) {
return expenses.mapBy('amount')
.reduce((summation, current) => summation + current);
} else {
return 0;
}
}
get totalUSD () {
const expenses = this.expenses.filterBy('currency', 'USD');
if (expenses.length > 0) {
return expenses.mapBy('amount')
.reduce((summation, current) => summation + current);
} else {
return 0;
}
}
get submitButtonEnabled () {
return this.isValidTotal &&
(this.expenses.length > 0);
}
get submitButtonDisabled () {
return !this.submitButtonEnabled;
}
scrollToExpenseItemForm () {
const anchor = document.getElementById('new-expense-item');
anchor.scrollIntoView();
}
updateTotalAmountFromFiat() {
let btcAmount = parseFloat(this.total);
if (this.exchangeRates.btceur > 0 && this.totalEUR > 0) {
btcAmount += (this.totalEUR / this.exchangeRates.btceur);
}
if (this.exchangeRates.btcusd > 0 && this.totalUSD > 0) {
btcAmount += (this.totalUSD / this.exchangeRates.btcusd);
}
if (this.totalUSD === 0 && this.totalEUR === 0) {
btcAmount = 0;
}
this.total = btcAmount.toFixed(8);
}
// TODO use ember-concurrency here
// https://github.com/67P/kredits-web/pull/209#discussion_r1064234421
@action
async addExpensesFromFile (evt) {
const content = await readFileContent(evt.target.files[0]);
const expenses = JSON.parse(content);
if (expenses instanceof Array) {
for (const item of expenses) {
this.addExpenseItem(item);
}
} else {
console.warn("Expenses in file must be a list of items:");
console.debug(content);
}
}
@action
updateContributor (event) {
this.recipientId = event.target.value;
}
@action
showExpenseForm () {
this.expenseFormVisible = true;
scheduleOnce('afterRender', this, this.scrollToExpenseItemForm);
}
@action
addExpenseItem (expenseItem) {
this.expenses.pushObject(expenseItem);
this.updateTotalAmountFromFiat();
this.expenseFormVisible = false;
}
@action
removeExpenseItem (expenseItem) {
this.expenses.removeObject(expenseItem);
this.updateTotalAmountFromFiat();
if (this.expenses.length === 0) {
this.expenseFormVisible = true;
}
}
@action
submit (e) {
e.preventDefault();
if (!this.kredits.currentUser) { window.alert('You need to connect your RSK account first.'); return false }
if (!this.kredits.currentUserIsCore) { window.alert('Only core contributors can submit reimbursements.'); return false }
const contributor = this.contributors.findBy('id', parseInt(this.recipientId));
const attributes = {
amount: parseInt(parseFloat(this.total) * 100000000), // convert to sats
token: config.tokens['BTC'],
recipientId: parseInt(this.recipientId),
title: `Expenses covered by ${contributor.name}`,
description: this.description,
url: this.url,
expenses: JSON.parse(JSON.stringify((this.expenses)))
}
this.inProgress = true;
this.kredits.addReimbursement(attributes)
.then((/* reimbursement */) => {
this.router.transitionTo('budget');
})
.catch(e => {
console.error('Could not add reimbursement:', e);
window.alert('Something went wrong. Please check the browser console.')
})
.finally(() => {
this.inProgress = false;
});
}
}
@@ -1,79 +0,0 @@
<form {{on "submit" this.submit}} novalidate>
<label>
<p class="label">Contributor:</p>
<p>
<select required {{on "change" this.updateContributor}}>
<option value="" selected disabled hidden></option>
{{#each this.contributors as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</p>
</label>
<fieldset class="horizontal thirds total-amounts">
<label>
<p class="label">Total amount (BTC):</p>
<p>
<Input @type="text"
@placeholder="0.0015"
@value={{this.total}}
@required={{true}}
@pattern="([0-9]*[.])?[0-9]+"
@class={{this.totalInputClass}} />
</p>
</label>
<label>
<p class="label">EUR total</p>
<p>
<Input @type="text"
@name="total-eur"
@value={{this.totalEUR}}
@disabled={{true}} />
</p>
</label>
<label>
<p class="label">USD total</p>
<p>
<Input @type="text"
@name="total-usd"
@value={{this.totalUSD}}
@disabled={{true}} />
</p>
</label>
</fieldset>
<h3>Expense items</h3>
{{#if this.expenses}}
<ExpenseList @expenses={{this.expenses}}
@removeExpenseItem={{this.removeExpenseItem}}
@deletable={{true}} />
<p class="actions">
<button {{on "click" this.showExpenseForm}}
class="green small" type="button">+ Add another item</button>
</p>
{{else}}
<p>No line items yet.</p>
{{/if}}
<p class="actions">
{{#if this.inProgress}}
<Input @type="submit" @value="Submitting..." @disabled={{true}}
@title="Submit/propose this reimbursement" />
{{else}}
<Input @type="submit" @value="Submit" @disabled={{this.submitButtonDisabled}}
@title="Submit/propose this reimbursement" />
{{/if}}
</p>
{{#if this.expenseFormVisible}}
<h3 id="new-expense-item">New expense item</h3>
<AddExpenseItem @addExpenseItem={{fn this.addExpenseItem}} />
{{/if}}
</form>
<form id="add-expenses-from-file">
<h3>Add expense items from file</h3>
<input type="file" multiple="false"
onchange={{fn this.addExpensesFromFile}}
accept="application/json" />
</form>
@@ -1,15 +0,0 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
export default class BudgetBalancesComponent extends Component {
@service communityFunds;
get balancesSorted () {
return this.communityFunds.balances
.sortBy('confirmed_balance').reverse();
}
get loading () {
return !this.communityFunds.balancesLoaded;
}
}
@@ -1,26 +0,0 @@
<table class="token-balances {{if this.loading 'loading'}}">
<thead>
<tr>
<th>Token</th>
<th>Amount</th>
<th>Fiat value</th>
</tr>
</thead>
<tbody>
{{#each this.balancesSorted as |balance|}}
<tr>
<th>
<img src={{balance.token.icon}}
alt={{balance.token.description}}
title={{balance.token.description}} />
</th>
<td class="amount">
{{fmt-number balance.confirmed_balance}} <span class="unit">sats</span>
</td>
<td class="fiat-amount">
~{{balance.balanceUSD}} USD
</td>
</tr>
{{/each}}
</tbody>
</table>
@@ -1,64 +0,0 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
let categoryColors = {
community: "#fb6868",
design: "#fbe468",
dev: "#e068fb",
docs: "#97fb68",
ops: "#8f68fb",
}
export default Component.extend({
contributions: null,
chartOptions: Object.freeze({
legend: {
display: false
}
}),
chartData: computed('contributions', function() {
let kredits = this.contributions
.filterBy('vetoed', false)
.map(c => {
return { kind: c.kind, amount: c.amount }
})
.reduce(function (kinds, c) {
if (c.kind in kinds) {
kinds[c.kind] = kinds[c.kind] + c.amount
} else {
kinds[c.kind] = c.amount;
}
return kinds;
}, {});
return {
datasets: [{
data: [
kredits['community'],
kredits['design'],
kredits['dev'],
kredits['ops'],
kredits['docs'],
],
borderColor: [
categoryColors.community,
categoryColors.design,
categoryColors.dev,
categoryColors.ops,
categoryColors.docs,
],
borderWidth: 1
}],
labels: [
'Community',
'Design',
'Development',
'Operations & Infrastructure',
'Documentation'
],
}
})
});
@@ -1,6 +0,0 @@
<div class="chart">
<EmberChart @type="doughnut"
@data={{this.chartData}}
@options={{this.chartOptions}}
@width="200" @height="200" />
</div>
-24
View File
@@ -1,24 +0,0 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import moment from 'moment';
export default class ConfirmedInComponent extends Component {
@service kredits;
get confirmedInBlocks () {
return this.args.confirmedAtBlock - this.kredits.currentBlock;
}
get confirmedInSeconds () {
// A new block is mined every 30 seconds on average
return this.confirmedInBlocks * 30;
}
get confirmedInHumanTime () {
return moment.duration(this.confirmedInSeconds, "seconds").humanize();
}
get isConfirmed () {
return this.confirmedInBlocks <= 0;
}
}
-5
View File
@@ -1,5 +0,0 @@
{{#if this.isConfirmed}}
Confirmed at block <strong>{{@confirmedAtBlock}}</strong> (~ {{this.confirmedInHumanTime}} ago)
{{else}}
Confirming in <strong>{{this.confirmedInBlocks}}</strong> blocks (~ {{this.confirmedInHumanTime}})
{{/if}}
+10 -63
View File
@@ -1,69 +1,16 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { sort } from '@ember/object/computed';
import { isPresent } from '@ember/utils';
import { inject as service } from '@ember/service';
import Ember from 'ember';
export default Component.extend({
export default Ember.Component.extend({
router: service(),
tagName: 'ul',
classNames: ['contribution-list'],
tagName: 'div',
classNames: ['contributions'],
selectedContribution: null,
showQuickFilter: false,
hideSmallContributions: false,
contributorId: null,
contributionKind: null,
kredits: service(),
contributorsSorting: Object.freeze(['name:asc']),
contributors: sort('kredits.contributors', 'contributorsSorting'),
contributorsActive: computed('contributors.[]', 'contributions', function() {
const activeIds = new Set(this.contributions.mapBy('contributorId'));
return this.contributors.filter(c => activeIds.has(c.id));
}),
contributionKinds: computed('contributions.[]', function() {
return this.contributions.mapBy('kind').uniq();
}),
contributionsFiltered: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() {
return this.contributions.filter(c => {
let included = true;
if (this.hideSmallContributions &&
c.amount <= 500) { included = false; }
if (isPresent(this.contributorId) &&
c.contributorId !== parseInt(this.contributorId)) { included = false; }
if (isPresent(this.contributionKind) &&
c.kind !== this.contributionKind) { included = false; }
return included;
contributionsWithContributors: function() {
return this.get('contributions').map((c) => {
c.set('contributor', this.get('contributors')
.findBy('address', c.get('recipientAddress')));
return c;
});
}),
actions: {
veto (contributionId) {
if (this.contractInteractionEnabled) {
this.vetoContribution(contributionId);
} else {
window.alert('Only members can veto contributions. Please ask someone to set you up.');
}
},
openContributionDetails(contribution) {
this.router.transitionTo('dashboard.contributions.show', contribution);
}
}
}.property('contributions.@each')
});
+7 -56
View File
@@ -1,56 +1,7 @@
{{#if @showQuickFilter}}
<div class="quick-filter">
<p>
<label class="filter-contributor">
Contributor:
<select onchange={{action (mut this.contributorId) value="target.value"}}>
<option value="" selected>all</option>
{{#each this.contributorsActive as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</label>
<label class="filter-contribution-kind">
Kind:
<select onchange={{action (mut this.contributionKind) value="target.value"}}>
<option value="" selected>all</option>
{{#each this.contributionKinds as |kind|}}
<option value={{kind}} selected={{eq this.contributionKind kind}}>{{capitalize-string kind}}</option>
{{/each}}
</select>
</label>
<label class="filter-contribution-size">
<Input @type="checkbox" @checked={{this.hideSmallContributions}} />
Hide small contributions
</label>
</p>
</div>
{{/if}}
<ul class="item-list contribution-list {{if @loading 'loading'}}">
{{#each this.contributionsFiltered as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}}
class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
<span class="title">{{contribution.description}}</span>
</p>
<p class="kredits-amount">
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
</p>
{{#unless contribution.vetoed}}
{{#unless (is-confirmed-contribution contribution)}}
<p class="voting">
{{input type="button" class="button small danger" value="veto"
click=(action "veto" contribution.id)
disabled=contribution.hasPendingChanges}}
</p>
{{/unless}}
{{/unless}}
</li>
{{/each}}
</ul>
{{#each contributionsWithContributors as |contribution|}}
<li title="({{contribution.kind}}) {{contribution.description}}">
<span class="category {{contribution.kind}}">♥</span>
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
for <span class="recipient">{{contribution.contributor.name}}</span>
</li>
{{/each}}
+13 -9
View File
@@ -1,18 +1,22 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import Ember from 'ember';
export default Component.extend({
tagName: '',
export default Ember.Component.extend({
router: service(),
selectedContributorId: null,
tagName: 'table',
classNames: 'contributor-list',
selectedContributor: null,
actions: {
openContributorDetails(contributor) {
this.router.transitionTo('dashboard.contributors.show', contributor);
toggleContributorInfo(contributor) {
if (contributor.get('showMetadata')) {
contributor.set('showMetadata', false);
} else {
this.get('contributors').setEach('showMetadata', false);
contributor.set('showMetadata', true);
}
}
}
});
+24 -25
View File
@@ -1,25 +1,24 @@
<table class="contributor-list {{if @loading 'loading'}}">
<thead>
</thead>
<tbody>
{{#each @contributorList as |c|}}
<tr role="button"
onclick={{action "openContributorDetails" c.contributor}}
class="{{if (is-current-user c.contributor) "current-user"}} {{if (eq c.contributor.id @selectedContributorId) "selected"}}">
<td class="person">
<UserAvatar @contributor={{c.contributor}} /> {{c.contributor.name}}
</td>
<td class="kredits">
<span class="amount">
{{#if @showUnconfirmedKredits}}
{{c.amountTotal}}
{{else}}
{{c.amountConfirmed}}
{{/if}}
</span>
<span class="symbol">₭S</span>
</td>
</tr>
{{/each}}
</tbody>
</table>
<tbody>
{{#each contributors as |contributor|}}
<tr class="{{if contributor.isCurrentUser 'current-user'}}" {{action "toggleContributorInfo" contributor}}>
<td class="person">
<img class="avatar" src={{contributor.avatarURL}}>
{{contributor.name}}
</td>
<td class="kredits">
<span class="amount">{{contributor.kredits}}</span>
<span class="symbol">₭S</span>
</td>
</tr>
<tr class="metadata {{if contributor.isCurrentUser 'current-user'}} {{if contributor.showMetadata 'visible'}}">
<td colspan="2">
<ul>
<li><a href="https://testnet.etherscan.io/address/{{contributor.address}}">Inspect Ethereum transactions</a></li>
{{#if contributor.ipfsHash}}
<li><a href="https://ipfs.io/ipfs/{{contributor.ipfsHash}}">Inspect IPFS profile</a></li>
{{/if}}
</ul>
</td>
</tr>
{{/each}}
</tbody>
-9
View File
@@ -1,9 +0,0 @@
import Component from '@glimmer/component';
export default class ExpenseListComponent extends Component {
get showDeleteButton () {
return !!this.args.deletable;
}
}
-29
View File
@@ -1,29 +0,0 @@
<ul class="expense-list">
{{#each @expenses as |expense|}}
<li class="expense-item">
<h4>
<span class="date">{{fmt-date-localized expense.date}}:</span>
<span class="title">{{expense.title}}</span>
</h4>
<div class="amount">
{{fmt-fiat-currency expense.amount expense.currency}}
</div>
<p class="description">
{{expense.description}}
</p>
<p class="tags">
{{#each expense.tags as |tag|}}
<button type="button" class="small yellow" role="none">
<IconTag />{{tag}}
</button>
{{/each}}
</p>
{{#if this.showDeleteButton}}
<div class="actions">
<button {{on "click" (fn @removeExpenseItem expense)}}
class="danger small" type="button">delete</button>
</div>
{{/if}}
</li>
{{/each}}
</ul>
@@ -1,15 +0,0 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
tagName: "",
account: null,
iconComponentName: computed('account.site', function() {
if (this.account.site.match(/github|gitea|wiki|zoom/)) {
return 'icon-account-' + this.account.site.replace(/\./g, '-');
} else {
return 'icon-web-globe';
}
})
});
@@ -1,5 +0,0 @@
<a href={{this.account.url}} target="_blank" rel="noopener noreferrer"
title="{{this.account.username}} on {{this.account.site}}">
<span class="site">{{this.account.site}}</span>
{{component this.iconComponentName}}
</a>
+2 -2
View File
@@ -1,6 +1,6 @@
import Component from '@ember/component';
import Ember from 'ember';
export default Component.extend({
export default Ember.Component.extend({
classNames: ['loading-spinner']
+2 -2
View File
@@ -2,7 +2,7 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1" x="0px" y="0px" viewBox="0 0 132 100"
enable-background="new 0 0 100 100" xml:space="preserve">
<path id="path-comet"
<path id="path-comet"
d="M79.062,24.173L79.062,24.173l-0.021-0.011c-0.068-0.03-0.137-0.062-0.204-0.093L42.392,7.337
c0,0,3.065,14.104,4.458,18.019l0,0c0,0-41.754-12.481-46.85-13.67c1.67,6.52,30.607,62.492,30.607,62.492
c5.848,11.873,19.394,18.485,33.522,18.485c19.811,0,35.87-16.059,35.87-35.869C100,42.313,91.418,29.837,79.062,24.173z">
@@ -10,5 +10,5 @@
</svg>
</p>
<p>
Loading data...
Loading data from Ethereum...
</p>
@@ -1,7 +0,0 @@
<nav id="main-menu">
<ul>
<li><LinkTo @route="dashboard">Dashboard</LinkTo></li>
<li><LinkTo @route="budget">Budget</LinkTo></li>
<li><LinkTo @route="about">About</LinkTo></li>
</ul>
</nav>
+20
View File
@@ -0,0 +1,20 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'ul',
classNames: ['proposal-list'],
actions: {
confirm(proposalId) {
if (this.get('contractInteractionEnabled')) {
this.sendAction('confirmAction', proposalId);
} else {
window.alert('Only members can vote on proposals. Please ask someone to set you up.');
}
}
}
});
+10
View File
@@ -0,0 +1,10 @@
{{#each proposals as |proposal|}}
<li data-proposal-id={{proposal.id}} title="({{proposal.kind}}) {{proposal.description}}">
<span class="category {{proposal.kind}}">♥</span>
<span class="amount">{{proposal.amount}}</span><span class="symbol">₭S</span>
for <span class="recipient">{{proposal.recipientName}}</span>
<span class="votes">({{proposal.votesCount}}/{{proposal.votesNeeded}} votes)</span>
{{#unless proposal.executed}}<button {{action "confirm" proposal.id}}>+1</button>{{/unless}}
</li>
{{/each}}
@@ -1,68 +0,0 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import config from 'kredits-web/config/environment';
import fmtDateLocalized from 'kredits-web/helpers/fmt-date-localized';
export default class ReimbursementItemComponent extends Component {
@service kredits;
@tracked showExpenseDetails = false;
constructor(owner, args) {
super(owner, args);
if (this.isUnconfirmed && !this.isVetoed) {
this.showExpenseDetails = true;
}
}
get ipfsGatewayUrl () {
return config.ipfs.gatewayUrl;
}
get isConfirmed () {
return (this.args.reimbursement.confirmedAt - this.kredits.currentBlock) <= 0;
}
get isUnconfirmed () {
return !this.isConfirmed;
}
get isVetoed () {
return this.args.reimbursement.vetoed;
}
get showVetoButton () {
return this.isUnconfirmed && this.kredits.currentUserIsCore;
}
get showConfirmedIn () {
return !this.isVetoed &&
(this.showExpenseDetails || this.isUnconfirmed);
}
get expenses () {
return this.args.reimbursement.expenses;
}
get expensesDateRange () {
const dates = this.expenses.map(e => e.date).uniq().sort();
let out = fmtDateLocalized.compute(dates.firstObject)
if (dates.length > 1) {
out += ' - ' + fmtDateLocalized.compute(dates.lastObject)
}
return out;
}
@action
toggleExpenseDetails () {
this.showExpenseDetails = !this.showExpenseDetails;
}
@action
veto (id) {
this.kredits.vetoReimbursement(id).then(transaction => {
console.debug('[controllers:budget] Veto submitted to chain: '+transaction.hash);
});
}
}
@@ -1,41 +0,0 @@
<li data-reimbursement-id={{@reimbursement.id}}
class="{{item-status @reimbursement}}"
role="button" {{on "click" this.toggleExpenseDetails}}>
<p class="meta">
<span class="recipient">
<UserAvatar @contributor={{@reimbursement.contributor}} />
</span>
<span class="title">
Expenses covered by {{@reimbursement.contributor.name}}
</span>
</p>
<p class="token-amount">
<span class="amount">
{{sats-to-btc @reimbursement.amount}}</span>&#8239;<span class="symbol">BTC</span>
</p>
{{#if this.showExpenseDetails}}
<ExpenseList @expenses={{@reimbursement.expenses}} />
{{/if}}
<div class="meta">
<p>
{{#if this.showConfirmedIn}}
<ConfirmedIn @confirmedAtBlock={{@reimbursement.confirmedAt}} />
{{else}}
{{#unless this.isVetoed}}{{this.expensesDateRange}}{{/unless}}
{{/if}}
</p>
<p class="actions">
<a href="{{this.ipfsGatewayUrl}}/{{@reimbursement.ipfsHash}}"
class="button small" target="_blank" rel="noopener noreferrer">
Inspect IPFS data
</a>
{{#if this.showVetoButton}}
<button {{on "click" (fn this.veto @reimbursement.id)}}
disabled={{@reimbursement.vetoed}}
class="button small danger" type="button">veto</button>
{{/if}}
</p>
</div>
</li>
@@ -1,10 +0,0 @@
import Component from '@glimmer/component';
import { sort } from '@ember/object/computed';
import { inject as service } from '@ember/service';
export default class ReimbursementListComponent extends Component {
@service kredits;
itemSorting = Object.freeze(['pendingStatus:asc', 'id:desc']);
@sort('args.items', 'itemSorting') itemsSorted;
}
@@ -1,5 +0,0 @@
<ul class="item-list collapsible spaced reimbursement-list {{if @loading 'loading'}}">
{{#each this.itemsSorted as |item|}}
<ReimbursementItem @reimbursement={{item}} />
{{/each}}
</ul>
@@ -1,37 +0,0 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { isPresent } from '@ember/utils';
export default class TopbarAccountPanelComponent extends Component {
@service router;
@service kredits;
@tracked setupInProgress = false;
get userHasWallet () {
return isPresent(window.ethereum);
}
get walletConnected () {
return this.userHasWallet && this.kredits.hasAccounts;
}
get walletDisconnected () {
return this.userHasWallet && !this.kredits.hasAccounts;
}
@action
signup () {
this.router.transitionTo('signup');
}
@action
async connectWallet () {
this.setupInProgress = true;
await this.kredits.connectWallet();
this.setupInProgress = false;
}
}
@@ -1,17 +0,0 @@
<section id="user-account">
{{#if this.setupInProgress}}
Connecting account...
{{else}}
{{#if (and this.walletConnected this.kredits.currentUser)}}
{{this.kredits.currentUser.name}}
{{else}}
<span class="anonymous">Anonymous</span>
<button onclick={{action "signup"}} id="signup"
class="small" type="button">Sign up</button>
{{#if this.walletDisconnected}}
<button onclick={{action "connectWallet"}} id="connect"
class="small green" type="button">Connect wallet</button>
{{/if}}
{{/if}}
{{/if}}
</section>
-33
View File
@@ -1,33 +0,0 @@
import Component from '@ember/component';
import { alias } from '@ember/object/computed';
import { computed } from '@ember/object';
const SIZES = {
'small': '128', // pixels
'medium': '256',
'large': '512'
}
export default Component.extend({
contributor: null,
tagName: 'img',
classNames: ['avatar'],
classNameBindings: ['size'],
attributeBindings: ['src', 'title'],
size: 'small',
src: alias('avatarURL'),
title: alias('contributor.name'),
avatarURL: computed('contributor.github_uid', 'size', function() {
const github_uid = this.contributor.github_uid;
if (github_uid) {
return `https://avatars2.githubusercontent.com/u/${github_uid}?v=3&s=${SIZES[this.size]}`;
} else {
// TODO use custom avatar
return '';
}
})
});
+3 -5
View File
@@ -1,6 +1,4 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import Ember from 'ember';
export default class ApplicationController extends Controller {
@service kredits;
}
export default Ember.Controller.extend({
});
-10
View File
@@ -1,10 +0,0 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
export default class BudgetController extends Controller {
@service kredits;
@alias('kredits.reimbursementsUnconfirmed') reimbursementsUnconfirmed;
@alias('kredits.reimbursementsConfirmed') reimbursementsConfirmed;
}
-32
View File
@@ -1,32 +0,0 @@
import Controller from '@ember/controller';
import { alias, filterBy, sort } from '@ember/object/computed';
import { inject as service } from '@ember/service';
export default Controller.extend({
kredits: service(),
queryParams: ['contributorId', 'kind', 'amount'],
contributors: alias('kredits.contributors'),
minedContributors: filterBy('contributors', 'id'),
contributorsSorting: Object.freeze(['name:asc']),
sortedContributors: sort('minedContributors', 'contributorsSorting'),
actions: {
save (contribution) {
const contributor = this.contributors.findBy('id', contribution.contributorId);
contribution.contributorIpfsHash = contributor.ipfsHash;
return this.kredits.addContribution(contribution)
.then(contribution => {
this.transitionToRoute('index');
return contribution;
});
}
}
});
@@ -1,7 +0,0 @@
import ContributionsNewController from 'kredits-web/controllers/contributions/new';
export default ContributionsNewController.extend({
attributes: null,
});
-16
View File
@@ -1,16 +0,0 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
export default Controller.extend({
kredits: service(),
actions: {
save (attributes) {
return this.kredits.updateContributor(this.model.id, attributes);
}
}
});
-16
View File
@@ -1,16 +0,0 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
export default Controller.extend({
kredits: service(),
actions: {
save (contributor) {
return this.kredits.addContributor(contributor);
}
}
});
-55
View File
@@ -1,55 +0,0 @@
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import { alias, gt, not, sort } from '@ember/object/computed';
import { inject as service } from '@ember/service';
export default Controller.extend({
kredits: service(),
showDetailsPane: false,
selectedContributorId: null,
selectedContributionId: null,
currentBlock: alias('kredits.currentBlock'),
contributions: alias('kredits.contributions'),
contributionsConfirmed: alias('kredits.contributionsConfirmed'),
contributionsUnconfirmed: alias('kredits.contributionsUnconfirmed'),
contributionsSorting: Object.freeze(['date:desc', 'time:desc', 'id:desc']),
contributionsUnconfirmedSorted: sort('contributionsUnconfirmed', 'contributionsSorting'),
contributionsConfirmedSorted: sort('contributionsConfirmed', 'contributionsSorting'),
kreditsByContributor: alias('kredits.kreditsByContributor'),
kreditsToplistSorting: computed('showUnconfirmedKredits', function(){
return this.showUnconfirmedKredits ? ['amountTotal:desc'] : ['amountConfirmed:desc'];
}),
kreditsToplist: sort('kreditsByContributor', 'kreditsToplistSorting'),
showUnconfirmedKredits: true,
hideUnconfirmedKredits: not('showUnconfirmedKredits'),
showQuickFilterUnconfirmed: false,
showQuickFilterConfirmed: false,
showFullContributionSync: gt('kredits.missingHistoricContributionsCount', 0),
actions: {
vetoContribution (contributionId) {
this.kredits.veto(contributionId).then(transaction => {
console.debug('[controllers:index] Veto submitted to chain: '+transaction.hash);
});
},
toggleQuickFilterUnconfirmed () {
this.toggleProperty('showQuickFilterUnconfirmed');
},
toggleQuickFilterConfirmed () {
this.toggleProperty('showQuickFilterConfirmed');
}
}
});
@@ -1,12 +0,0 @@
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import config from 'kredits-web/config/environment';
export default Controller.extend({
ipfsGatewayUrl: computed(function() {
return config.ipfs.gatewayUrl;
})
});
@@ -1,17 +0,0 @@
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import config from 'kredits-web/config/environment';
export default Controller.extend({
roleName: computed('model.isCore', 'totalKreditsEarned', function() {
if (this.model.isCore) return 'Core Contributor';
if (this.model.totalKreditsEarned <= 5000) return 'Newcomer';
return 'Contributor';
}),
ipfsGatewayUrl: computed(function() {
return config.ipfs.gatewayUrl;
})
});
+148
View File
@@ -0,0 +1,148 @@
import Ember from 'ember';
import Proposal from 'kredits-web/models/proposal';
const {
computed,
inject: {
service
}
} = Ember;
export default Ember.Controller.extend({
kredits: service(),
contractInteractionEnabled: computed.alias('kredits.web3Provided'),
findContributorByAddress(address) {
return this.get('model.contributors')
.findBy('address', address);
},
proposalsOpen: function() {
let proposals = this.get('model.proposals')
.filterBy('executed', false)
.map(p => {
p.set('recipientName', this.findContributorByAddress(p.get('recipientAddress')).name);
return p;
});
return proposals;
}.property('model.proposals.[]', 'model.proposals.@each.executed', 'model.contributors.[]'),
proposalsClosed: function() {
let proposals = this.get('model.proposals')
.filterBy('executed', true)
.map(p => {
p.set('recipientName', this.findContributorByAddress(p.get('recipientAddress')).name);
return p;
});
return proposals;
}.property('model.proposals.[]', 'model.proposals.@each.executed', 'model.contributors.[]'),
proposalsSorting: ['id:desc'],
proposalsClosedSorted: Ember.computed.sort('proposalsClosed', 'proposalsSorting'),
proposalsOpenSorted: Ember.computed.sort('proposalsOpen', 'proposalsSorting'),
contributorsWithKredits: function() {
return this.get('model.contributors').filter(c => {
return c.get('kredits') !== 0;
});
}.property('model.contributors.@each.kredits'),
contributorsSorting: ['kredits:desc'],
contributorsSorted: Ember.computed.sort('contributorsWithKredits', 'contributorsSorting'),
watchContractEvents: function() {
let events = this.get('kredits.kreditsContract')
.allEvents(/* [additionalFilterObject], */);
events.watch((error, data) => {
Ember.Logger.debug('[index] Received contract event', data);
switch (data.event) {
case 'ProposalCreated':
this._handleProposalCreated(data);
break;
case 'ProposalExecuted':
this._handleProposalExecuted(data);
break;
case 'ProposalVoted':
this._handleProposalVoted(data);
break;
case 'Transfer':
this._handleTransfer(data);
break;
}
});
}.on('init'),
_handleProposalCreated(data) {
if (Ember.isPresent(this.get('model.proposals')
.findBy('id', data.args.id.toNumber()))) {
Ember.Logger.debug('[index] proposal exists, not adding from event');
return false;
}
let proposal = Proposal.create({
id: data.args.id.toNumber(),
creatorAddress: data.args.creator,
recipientAddress: data.args.recipient,
recipientName: null,
votesCount: 0,
votesNeeded: 2,
amount: data.args.amount.toNumber(),
executed: false,
url: data.args.url,
ipfsHash: data.args.ipfsHash
});
this.get('model.proposals').pushObject(proposal);
},
_handleProposalExecuted(data) {
if (this.get('model.proposals')
.findBy('id', data.args.id.toNumber())
.get('executed')) {
Ember.Logger.debug('[index] proposal already executed, not adding from event');
return false;
}
this.get('model.proposals')
.findBy('id', data.args.id.toNumber())
.setProperties({
'executed': true,
'votesCount': 2 // TODO use real count
});
this.get('model.contributors')
.findBy('address', data.args.recipient)
.incrementProperty('kredits', data.args.amount.toNumber());
},
_handleProposalVoted(data) {
this.get('model.proposals')
.findBy('id', data.args.id.toNumber())
.incrementProperty('votesCount', 1);
},
_handleTransfer(data) {
this.get('model.contributors')
.findBy('address', data.args.from)
.incrementProperty('kredits', - data.args.value.toNumber());
this.get('model.contributors')
.findBy('address', data.args.to)
.incrementProperty('kredits', data.args.value.toNumber());
},
actions: {
confirmProposal(proposalId) {
this.get('kredits').vote(proposalId).then(transactionId => {
window.confirm('Vote submitted to Ethereum blockhain: '+transactionId);
});
}
}
});
+29
View File
@@ -0,0 +1,29 @@
import Ember from 'ember';
import QueryParams from 'ember-parachute';
export const queryParams = new QueryParams({
recipient: {
defaultValue: ''
},
amount: {
defaultValue: ''
},
url: {
defaultValue: ''
},
ipfsHash: {
defaultValue: ''
}
});
export default Ember.Controller.extend(queryParams.Mixin, {
contributors: null,
actions: {
onSave() {
this.transitionToRoute('index');
}
}
});
-50
View File
@@ -1,50 +0,0 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import config from 'kredits-web/config/environment';
import { isAddress } from 'web3-utils';
export default class AccountController extends Controller {
@service kredits;
@tracked accountAddress = null;
get isValidEthAccount () {
return isAddress(this.accountAddress);
}
get signupButtonDisabled () {
return !this.isValidEthAccount;
}
@action
completeSignup () {
const payload = {
accessToken: this.kredits.githubAccessToken,
account: this.accountAddress
}
fetch(config.githubSignupUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(res => res.json())
.then(data => {
if (data.error) {
alert('Creating profile failed. We have been notified about this error and will take a look soon. Sorry!');
console.warn('Creating contributor profile failed:',
JSON.parse(data.error.body).error.message)
return false;
} else {
console.log('[signup/account] Created contributor:', data);
this.kredits.githubAccessToken = null;
this.accountAddress = null;
this.transitionToRoute('signup.complete');
}
})
}
}
-12
View File
@@ -1,12 +0,0 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import config from 'kredits-web/config/environment';
export default class IndexController extends Controller {
@action
connectGithub () {
window.location = config.githubConnectUrl;
}
}
-7
View File
@@ -1,7 +0,0 @@
import Helper from '@ember/component/helper';
export default Helper.extend({
compute([string]) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
});
-18
View File
@@ -1,18 +0,0 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtCryptoCurrency(params/*, hash*/) {
let fmtAmount;
const amount = params[0];
const code = params[1];
switch(code) {
case 'RBTC':
fmtAmount = amount / 1000000000000000000;
break;
case 'BTC':
fmtAmount = amount / 100000000;
break;
}
return fmtAmount;
});
-8
View File
@@ -1,8 +0,0 @@
import { helper } from '@ember/component/helper';
import getLocale from 'kredits-web/utils/get-locale';
export default helper(function fmtDateLocalized(dateStr) {
const date = new Date(dateStr);
const locale = getLocale();
return new Intl.DateTimeFormat(locale).format(date);
});
-11
View File
@@ -1,11 +0,0 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtFiatCurrency(params) {
const lang = navigator.language || navigator.userLanguage;
const formatter = new Intl.NumberFormat(lang, {
style: 'currency',
currency: params[1] || 'EUR',
currencyDisplay: 'code'
})
return formatter.format(params[0]);
});
-6
View File
@@ -1,6 +0,0 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtNumber(number) {
const lang = navigator.language || navigator.userLanguage;
return number.toLocaleString(lang);
});
-15
View File
@@ -1,15 +0,0 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([contribution]) {
return !contribution.vetoed &&
(contribution.confirmedAt <= this.currentBlock);
}
});
-16
View File
@@ -1,16 +0,0 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { isPresent } from '@ember/utils';
export default Helper.extend({
kredits: service(),
currentUser: alias('kredits.currentUser'),
compute([contributor]) {
return isPresent(contributor) && isPresent(this.currentUser) &&
contributor.account === this.currentUser.account;
}
});
-56
View File
@@ -1,56 +0,0 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { once } from '@ember/runloop';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([item]) {
this.setupRecompute(item);
let status = [];
if (item.vetoed) {
status.push('vetoed');
} else if (item.confirmedAt > this.currentBlock) {
status.push('unconfirmed');
} else {
status.push('confirmed');
}
if (item.hasPendingChanges) {
status.push('pending');
}
return status.join(' ');
},
destroy () {
if (this.teardown) this.teardown();
this._super(...arguments);
},
setupRecompute (item) {
if (this.teardown) this.teardown();
item.addObserver('vetoed' , this, this.triggerRecompute);
item.addObserver('confirmedAt' , this, this.triggerRecompute);
item.addObserver('currentBlock' , this, this.triggerRecompute);
item.addObserver('hasPendingChanges' , this, this.triggerRecompute);
this.teardown = () => {
item.removeObserver('vetoed', this, this.triggerRecompute);
item.removeObserver('confirmedAt', this, this.triggerRecompute);
item.removeObserver('currentBlock', this, this.triggerRecompute);
item.removeObserver('hasPendingChanges', this, this.triggerRecompute);
};
},
triggerRecompute () {
once(this, this.recompute);
}
});
-5
View File
@@ -1,5 +0,0 @@
import { helper } from '@ember/component/helper';
export default helper(function satsToBtc(amount/*, hash*/) {
return amount / 100000000;
});
+2 -2
View File
@@ -9,8 +9,8 @@
{{content-for "head"}}
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/vendor.css">
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/kredits-web.css">
<link rel="stylesheet" href="{{rootURL}}assets/vendor.css">
<link rel="stylesheet" href="{{rootURL}}assets/kredits-web.css">
{{content-for "head-footer"}}
+20
View File
@@ -0,0 +1,20 @@
/**
* This pauses the app's boot process until the window load event is fired.
* It allows the user provided Web3 instance (e.g. Metamask, Mist Browser)
* to be inserted before we try to use it.
*/
export default {
name: 'defer-loading',
initialize: function(application) {
// Load event already fired, so web3 should be loaded if available
if (window.windowLoadComplete) { return true; }
// Pause app loading
application.deferReadiness();
window.addEventListener('load', function() {
// Continue app loading
application.advanceReadiness();
});
}
};
+40 -37
View File
@@ -1,50 +1,53 @@
import EmberObject, { computed } from '@ember/object';
import { isEmpty, isPresent } from '@ember/utils';
import moment from 'moment';
import Ember from 'ember';
export default EmberObject.extend({
// Contract
id: null,
export default Ember.Object.extend({
// blockNumber: null,
// blockHash: null,
// transactionHash: null,
recipientAddress: null,
contributorId: null,
amount: null,
confirmedAt: null,
vetoed: null,
ipfsHash: null,
amount: null,
// contributor model instance
contributor: null,
// TODO contributor who submitted the contribution
// submittedBy: null,
// IPFS
contribution: null,
kind: null,
description: null,
details: null,
url: null,
date: null,
time: null,
details: null,
pendingTx: null,
/**
* Loads the contribution details from IPFS and sets local instance
* properties from it
*
* @method
* @public
*/
loadDetails(ipfs) {
let promise = new Ember.RSVP.Promise((resolve, reject) => {
ipfs.getFile(this.get('ipfsHash')).then(content => {
let contributionJSON = JSON.parse(content);
let contribution = Ember.Object.create(contributionJSON);
init () {
this._super(...arguments);
if (isEmpty(this.details)) this.set('details', {});
this.setProperties({
kind: contribution.get('kind'),
description: contribution.get('description'),
url: contribution.get('url')
});
// TODO load details
// let details = profile.get('accounts');
Ember.Logger.debug('[proposal] loaded contribution details', contributionJSON);
resolve();
}).catch((err) => {
Ember.Logger.error('[proposal] error trying to load contribution details', this.get('ipfsHash'), err);
reject(err);
});
});
return promise;
},
iso8601Date: computed('date', 'time', function() {
return this.time ? `${this.date}T${this.time}` : this.date;
}),
jsDate: computed('iso8601Date', function() {
return moment(this.iso8601Date).toDate();
}),
hasPendingChanges: computed('pendingTx', function() {
return isPresent(this.pendingTx);
}),
serialize () {
return JSON.stringify(this);
}
});
+109 -15
View File
@@ -1,25 +1,119 @@
import EmberObject from '@ember/object';
import Ember from 'ember';
export default EmberObject.extend({
// Contract
id: null,
account: null,
balance: 0,
totalKreditsEarned: 0,
contributionsCount: 0,
isCore: false,
ipfsHash: null,
const {
isPresent,
} = Ember;
// IPFS
kind: null,
export default Ember.Object.extend({
address: null,
name: null,
kind: null,
url: null,
github_username: null,
github_uid: null,
wiki_username: null,
zoom_display_name: null,
ipfsHash: null,
kredits: null,
isCore: false,
isCurrentUser: false,
serialize () {
return JSON.stringify(this);
avatarURL: function() {
return `https\:\/\/avatars2.githubusercontent.com/u/${this.get('github_uid')}?v=3&s=128`;
}.property('github_uid'),
/**
* Loads the contributor's profile data from IPFS and sets local instance
* properties from it
*
* @method
* @public
*/
loadProfile(ipfs) {
let promise = new Ember.RSVP.Promise((resolve, reject) => {
ipfs.getFile(this.get('ipfsHash')).then(content => {
let profileJSON = JSON.parse(content);
let profile = Ember.Object.create(profileJSON);
this.setProperties({
name: profile.get('name'),
kind: profile.get('kind')
});
let accounts = profile.get('accounts');
let github = accounts.findBy('site', 'github.com');
let wiki = accounts.findBy('site', 'wiki.kosmos.org');
if (isPresent(github)) {
this.setProperties({
github_username: github.username,
github_uid: github.uid,
});
}
if (isPresent(wiki)) {
this.setProperties({
wiki_username: wiki.username
});
}
Ember.Logger.debug('[contributor] loaded contributor profile', profile);
resolve();
}).catch((err) => {
Ember.Logger.error('[contributor] error trying to load contributor profile', this.get('ipfsHash'), err);
reject(err);
});
});
return promise;
},
/**
* Creates a JSON-LD object of the contributor, according to
* https://github.com/67P/kosmos-schemas/blob/master/schemas/contributor.json
*
* @method
* @public
*/
toJSON() {
let contributor = {
"@context": "https://schema.kosmos.org",
"@type": "Contributor",
"kind": this.get('kind'),
"name": this.get('name'),
"accounts": []
};
if (Ember.isPresent(this.get('url'))) {
contributor["url"] = this.get('url');
}
if (Ember.isPresent(this.get('github_uid'))) {
contributor.accounts.push({
"site": "github.com",
"uid": this.get('github_uid'),
"username": this.get('github_username'),
"url": `https://github.com/${this.get('github_username')}`
});
}
if (Ember.isPresent(this.get('wiki_username'))) {
contributor.accounts.push({
"site": "wiki.kosmos.org",
"username": this.get('wiki_username'),
"url": `https://wiki.kosmos.org/User:${this.get('wiki_username')}`
});
}
return contributor;
},
/**
* Returns the JSON-LD representation of the model as a string
*
* @method
* @public
*/
serialize() {
return JSON.stringify(this.toJSON());
}
});
+102
View File
@@ -0,0 +1,102 @@
import Ember from 'ember';
const {
isPresent,
isEmpty,
} = Ember;
export default Ember.Object.extend({
id: null,
creatorAddress: null,
recipientAddress: null,
recipientName: null,
recipientProfile: null,
votesCount: null,
votesNeeded: null,
amount: null,
executed: null,
contribution: null,
kind: null,
description: null,
url: null,
details: null,
ipfsHash: null,
/**
* Loads the contribution details from IPFS and sets local instance
* properties from it
*
* @method
* @public
*/
loadContribution(ipfs) {
let promise = new Ember.RSVP.Promise((resolve, reject) => {
ipfs.getFile(this.get('ipfsHash')).then(content => {
let contributionJSON = JSON.parse(content);
let contribution = Ember.Object.create(contributionJSON);
this.setProperties({
kind: contribution.get('kind'),
description: contribution.get('description'),
url: contribution.get('url')
});
// TODO load details
// let details = profile.get('accounts');
Ember.Logger.debug('[proposal] loaded contribution details', contributionJSON);
resolve();
}).catch((err) => {
Ember.Logger.error('[proposal] error trying to load contribution details', this.get('ipfsHash'), err);
reject(err);
});
});
return promise;
},
/**
* Creates a JSON-LD object of the contribution, according to
* https://github.com/67P/kosmos-schemas/blob/master/schemas/contribution.json
*
* @method
* @public
*/
contributionToJSON() {
if (isEmpty(this.get('recipientProfile'))) {
throw new Error('IPFS hash for recipient profile missing from proposal object');
}
if (isEmpty(this.get('kind')) || isEmpty(this.get('description'))) {
throw new Error('Missing one or more required properties: kind, description');
}
let contribution = {
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
"contributor": {
"ipfs": this.get('recipientProfile')
},
"kind": this.get('kind'),
"description": this.get('description'),
"details": {}
};
if (isPresent(this.get('url'))) {
contribution["url"] = this.get('url');
}
return contribution;
},
/**
* Returns the JSON-LD representation of the contribution as a string
*
* @method
* @public
*/
serializeContribution() {
return JSON.stringify(this.contributionToJSON());
}
});
-47
View File
@@ -1,47 +0,0 @@
import EmberObject, { computed } from '@ember/object';
import moment from 'moment';
import { isPresent } from '@ember/utils';
export default EmberObject.extend({
// Contract
id: null,
recipientId: null,
token: null,
amount: null,
confirmedAt: null,
vetoed: null,
ipfsHash: null,
// contributor model instance
recipient: null,
// TODO contributor who submitted the reimbursement
// recordedBy: null,
// IPFS
expenses: null, // Array of expense objects
pendingTx: null,
iso8601Date: computed('date', 'time', function() {
return this.time ? `${this.date}T${this.time}` : this.date;
}),
jsDate: computed('iso8601Date', function() {
return moment(this.iso8601Date).toDate();
}),
hasPendingChanges: computed('pendingTx', function() {
return isPresent(this.pendingTx);
}),
pendingStatus: computed('pendingTx', function() {
return isPresent(this.pendingTx) ? 'isPending' : 'notPending';
}),
serialize () {
return JSON.stringify(this);
}
});
+3
View File
@@ -0,0 +1,3 @@
import Resolver from 'ember-resolver';
export default Resolver;
+9 -32
View File
@@ -1,39 +1,16 @@
import EmberRouter from '@ember/routing/router';
import Ember from 'ember';
import config from './config/environment';
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
const Router = Ember.Router.extend({
location: config.locationType,
rootURL: config.rootURL
});
Router.map(function() {
this.route('dashboard', function() {
this.route('contributors', function() {
this.route('show', { path: ':id' });
});
this.route('contributions', function() {
this.route('show', { path: ':id' });
});
});
this.route('contributions', function() {
this.route('new', { queryParams: ['contributorId', 'kind', 'amount'] });
this.route('resubmit', { path: ':id/resubmit' });
});
this.route('contributors', function() {
this.route('new');
this.route('edit', { path: ':id/edit' });
});
this.route('signup', function() {
this.route('github');
this.route('account');
this.route('complete');
});
this.route('budget', function() {
this.route('expenses');
this.route('reimbursements', function() {});
});
this.route('reimbursements', function() {
this.route('spinner');
this.route('proposals', function() {
this.route('new');
});
this.route('about');
});
export default Router;
-4
View File
@@ -1,4 +0,0 @@
import Route from '@ember/routing/route';
export default class AboutRoute extends Route {
}
+3 -34
View File
@@ -1,35 +1,4 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { schedule } from '@ember/runloop';
import Ember from 'ember';
export default class ApplicationRoute extends Route {
@service kredits;
@service communityFunds;
beforeModel(/* transition */) {
return this.kredits.setup().then(() => {
this.kredits.kredits.preflightChecks().catch(error => {
console.error('Kredits preflight check failed!');
console.error(error);
});
}).catch(error => {
console.log('Error initializing Kredits', error);
});
}
model() {
return this.kredits.loadInitialData().then(() => {
this.kredits.addContractEventHandlers()
});
}
afterModel() {
if (this.kredits.contributorsNeedSync) {
schedule('afterRender', this.kredits.syncContributors,
this.kredits.syncContributors.perform);
}
schedule('afterRender', this.communityFunds.fetchBalances,
this.communityFunds.fetchBalances.perform);
}
}
export default Ember.Route.extend({
});
-34
View File
@@ -1,34 +0,0 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { isEmpty, isPresent } from '@ember/utils';
import { schedule } from '@ember/runloop';
export default class BudgetRoute extends Route {
@service browserCache;
@service kredits;
async model () {
if (isPresent(this.kredits.reimbursements) &&
isEmpty(this.kredits.reimbursementsPending)) {
console.debug('[route:budget] Reimbursements loaded before, no need to sync or load');
return;
} else {
const numCachedReimbursements = await this.browserCache.reimbursements.length();
if (numCachedReimbursements > 0) {
await this.kredits.loadObjectsFromCache('Reimbursement');
this.kredits.set('reimbursementsNeedSync', true);
} else {
await this.kredits.fetchObjects('Reimbursement', { page: { size: 10 } });
}
}
}
afterModel() {
if (this.kredits.reimbursementsNeedSync) {
schedule('afterRender', this.kredits.syncReimbursements,
this.kredits.syncReimbursements.perform);
}
schedule('afterRender', this.kredits.fetchMissingReimbursements,
this.kredits.fetchMissingReimbursements.perform);
}
}
-4
View File
@@ -1,4 +0,0 @@
import Route from '@ember/routing/route';
export default class BudgetExpensesRoute extends Route {
}
-9
View File
@@ -1,9 +0,0 @@
import Route from '@ember/routing/route';
export default class ContributionsNewRoute extends Route {
model (params) {
return { params };
}
}
-21
View File
@@ -1,21 +0,0 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default Route.extend({
kredits: service(),
model(params) {
return this.kredits.contributions.findBy('id', parseInt(params.id));
},
setupController (controller, model) {
this._super(controller, model);
controller.set('attributes', model.getProperties([
'contributorId', 'kind', 'amount', 'description', 'url', 'details'
]));
controller.set('attributes.date', model.jsDate);
}
});
-30
View File
@@ -1,30 +0,0 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { alias } from '@ember/object/computed';
export default Route.extend({
kredits: service(),
contributors: alias('kredits.contributors'),
model(params) {
return this.kredits.contributors.findBy('id', params.id);
},
setupController (controller, model) {
this._super(controller, model);
controller.set('attributes', {
account: model.account,
name: model.name,
kind: model.kind,
url: model.url,
github_username: model.github_username,
github_uid: model.github_uid,
gitea_username: model.gitea_username,
wiki_username: model.wiki_username,
zoom_display_name: model.zoom_display_name
});
}
});
-21
View File
@@ -1,21 +0,0 @@
import Route from '@ember/routing/route';
import { schedule } from '@ember/runloop';
import { inject as service } from '@ember/service';
export default class DashboardRoute extends Route {
@service kredits;
afterModel() {
if (this.kredits.contributionsNeedSync) {
schedule('afterRender', this.kredits.syncContributions,
this.kredits.syncContributions.perform);
}
// TODO fetch automatically under a certain threshold
// The browser might delete cached data and we don't need manual re-syncs
// depending on how little is missing
// schedule('afterRender', this.kredits.fetchMissingContributions,
// this.kredits.fetchMissingContributions.perform);
}
}
@@ -1,32 +0,0 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { alias } from '@ember/object/computed';
export default Route.extend({
kredits: service(),
contributions: alias('kredits.contributions'),
model (params) {
return this.contributions.findBy('id', parseInt(params.id));
},
setupController (controller, model) {
this._super(controller, model);
this.controllerFor('dashboard')
.setProperties({
showDetailsPane: true,
selectedContributionId: model.id
});
},
deactivate () {
this.controllerFor('dashboard')
.setProperties({
showDetailsPane: false,
selectedContributionId: null
});
}
});
-38
View File
@@ -1,38 +0,0 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { alias } from '@ember/object/computed';
export default Route.extend({
kredits: service(),
contributors: alias('kredits.contributors'),
model (params) {
const contributor = this.contributors.findBy('id', params.id);
if (contributor) {
return contributor;
} else {
return this.kredits.fetchContributor(params.id);
}
},
setupController (controller, model) {
this._super(controller, model);
this.controllerFor('dashboard')
.setProperties({
showDetailsPane: true,
selectedContributorId: model.id
});
},
deactivate () {
this.controllerFor('dashboard')
.setProperties({
showDetailsPane: false,
selectedContributorId: null
});
}
});
+29 -4
View File
@@ -1,9 +1,34 @@
import Route from '@ember/routing/route';
import Ember from 'ember';
import Contributor from 'kredits-web/models/contributor';
export default Route.extend({
export default Ember.Route.extend({
redirect () {
this.transitionTo('dashboard');
kredits: Ember.inject.service(),
beforeModel(transition) {
const kredits = this.get('kredits');
if (kredits.get('web3') && kredits.get('web3Provided')) {
kredits.get('web3').eth.getAccounts((error, accounts) => {
if (error || accounts.length === 0) {
if (confirm('It looks like you have an Ethereum wallet available. Please unlock your account.')) {
transition.retry();
}
}
});
}
},
model() {
let kredits = this.get('kredits');
return Ember.RSVP.hash({
contributors: kredits.getContributors(),
totalSupply: kredits.getValueFromContract('tokenContract', 'totalSupply'),
proposals: kredits.getProposals(),
contributions: kredits.getContributions(),
newContributor: Contributor.create({ kind: 'person' })
});
}
});
+37
View File
@@ -0,0 +1,37 @@
import Ember from 'ember';
import Proposal from 'kredits-web/models/proposal';
const {
Route,
RSVP,
inject: {
service
}
} = Ember;
export default Route.extend({
kredits: service(),
model(params) {
const proposal = Proposal.create({
recipientAddress: params.recipient,
amount: params.amount,
url: params.url,
kind: params.kind || 'dev',
ipfsHash: params.ipfsHash
});
return RSVP.hash({
proposal,
contributors: this.get('kredits').getContributors()
});
},
setupController(controller, model) {
this._super(controller, model.proposal);
controller.set('contributors', model.contributors);
}
});
-9
View File
@@ -1,9 +0,0 @@
import Route from '@ember/routing/route';
export default class ReimbursementsNewRoute extends Route {
model (params) {
return { params };
}
}
-4
View File
@@ -1,4 +0,0 @@
import Route from '@ember/routing/route';
export default Route.extend({
});
-23
View File
@@ -1,23 +0,0 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { isEmpty } from '@ember/utils';
export default class SignupAccountRoute extends Route {
@service kredits;
async setupController (controller) {
if (!window.ethereum) return;
if (this.kredits.hasAccounts) {
controller.accountAddress = this.kredits.currentUserAccounts.firstObject;
} else {
return this.kredits.connectWallet();
}
}
redirect () {
if (isEmpty(this.kredits.githubAccessToken)) {
this.transitionTo('signup.index');
}
}
}
-4
View File
@@ -1,4 +0,0 @@
import Route from '@ember/routing/route';
export default Route.extend({
});
-29
View File
@@ -1,29 +0,0 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { isEmpty } from '@ember/utils';
export default Route.extend({
kredits: service(),
redirect () {
this._super(...arguments);
let accessToken;
try {
accessToken = window.location.hash.match(/access_token=(.+)/)[1];
} catch (error) { /* ignore */ }
if (isEmpty(accessToken) || accessToken === 'undefined') {
console.error('No GitHub access token found.');
this.transitionTo('signup');
return;
}
this.kredits.set('githubAccessToken', accessToken);
this.transitionTo('signup.account');
}
});
+4
View File
@@ -0,0 +1,4 @@
import Ember from 'ember';
export default Ember.Route.extend({
});
-37
View File
@@ -1,37 +0,0 @@
import Service from '@ember/service';
import * as localforage from 'localforage';
import config from 'kredits-web/config/environment';
function createStore(name) {
let networkName;
if (config.web3NetworkName) {
networkName = config.web3NetworkName.toLocaleLowerCase().replace(' ', '-');
} else {
networkName = 'custom';
}
return localforage.createInstance({ name: `kredits:${networkName}:${name}` });
}
export default class BrowserCacheService extends Service {
constructor() {
super(...arguments);
this.stores = {
contributors: createStore('contributors'),
contributions: createStore('contributions'),
reimbursements: createStore('reimbursements')
}
}
get contributors() {
return this.stores.contributors;
}
get contributions() {
return this.stores.contributions;
}
get reimbursements() {
return this.stores.reimbursements;
}
}
-53
View File
@@ -1,53 +0,0 @@
import { A } from '@ember/array';
import { task } from 'ember-concurrency-decorators';
import { tracked } from '@glimmer/tracking';
import Service, { inject as service } from '@ember/service';
import config from 'kredits-web/config/environment';
export default class CommunityFundsService extends Service {
@service exchangeRates;
@tracked balancesLoaded = false;
@tracked balances = A([]);
@task
*fetchBalances () {
const promises = [];
const balances = config.communityFundsAPI.balances;
for (const item of Object.keys(balances)) {
const c = balances[item];
promises.push(
this.fetchBalance(c.url)
.then(res => { return this.processBalance(res, c) })
)
}
yield Promise.all(promises)
.then(() => {
this.balancesLoaded = true;
})
.catch(err => {
console.log(`[community-funds] Fetching balances failed:`);
console.error(err);
});
}
async fetchBalance(url) {
return fetch(url).then(res => res.json());
}
async processBalance (res, config) {
await this.exchangeRates.fetchRates();
// Format and round the approximate USD value
const lang = navigator.language || navigator.userLanguage;
const balanceUSD = (res.confirmed_balance / 100000000) * this.exchangeRates.btcusd;
res.balanceUSD = Math.round(balanceUSD).toLocaleString(lang);
this.balances.pushObject({
...res,
...{ token: { icon: `/img/${config.icon}`, symbol: config.symbol, description: config.description } }
});
}
}

Some files were not shown because too many files have changed in this diff Show More