Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d35cab0a67 |
@@ -4,6 +4,7 @@
|
||||
|
||||
root = true
|
||||
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
|
||||
@@ -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
|
||||
+7
-41
@@ -1,22 +1,8 @@
|
||||
'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 }],
|
||||
],
|
||||
},
|
||||
ecmaVersion: 2017,
|
||||
sourceType: 'module'
|
||||
},
|
||||
globals: {
|
||||
console: true
|
||||
@@ -33,45 +19,25 @@ module.exports = {
|
||||
},
|
||||
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'
|
||||
'no-console': 'off'
|
||||
},
|
||||
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'
|
||||
'lib/*/index.js'
|
||||
],
|
||||
parserOptions: {
|
||||
sourceType: 'script'
|
||||
sourceType: 'script',
|
||||
ecmaVersion: 2015
|
||||
},
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
template: |
|
||||
## Changes
|
||||
|
||||
$CHANGES
|
||||
@@ -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
|
||||
+11
-13
@@ -1,26 +1,24 @@
|
||||
# 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
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
testem.log
|
||||
.tm_properties
|
||||
|
||||
# ember-try
|
||||
/.node_modules.ember-try/
|
||||
/bower.json.ember-try
|
||||
/package.json.ember-try
|
||||
.node_modules.ember-try/
|
||||
bower.json.ember-try
|
||||
package.json.ember-try
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
language: node_js
|
||||
node_js:
|
||||
- "6"
|
||||
|
||||
sudo: false
|
||||
dist: trusty
|
||||
|
||||
addons:
|
||||
chrome: stable
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.npm
|
||||
|
||||
env:
|
||||
global:
|
||||
# See https://git.io/vdao3 for details.
|
||||
- JOBS=1
|
||||
|
||||
before_install:
|
||||
- npm config set spin false
|
||||
|
||||
script:
|
||||
- npm run lint:js
|
||||
- npm test
|
||||
@@ -1,60 +1,52 @@
|
||||
[](https://github.com/67P/kredits-web/actions/workflows/ci.yml?query=branch%3Amaster)
|
||||
[](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)
|
||||
* [Ember CLI](https://ember-cli.com/)
|
||||
* [Google Chrome](https://google.com/chrome/) (only for running tests)
|
||||
* [Google Chrome](https://google.com/chrome/)
|
||||
|
||||
### Installation
|
||||
## Installation
|
||||
|
||||
* `git clone git@github.com:67P/kredits-web.git` this repository
|
||||
* `cd kredits-web`
|
||||
* `npm install`
|
||||
|
||||
### Building/running for development
|
||||
## Running / Development
|
||||
|
||||
* `npm start` - by default Kredits Web connects to the Rootstock testnet network
|
||||
|
||||
* `ember serve` - by default kredits-web connects to the Ethreum Kovan network
|
||||
* Visit your app at [http://localhost:4200](http://localhost:4200).
|
||||
* Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests).
|
||||
|
||||
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
|
||||
|
||||
@@ -62,58 +54,53 @@ _(You need collaborator permissions on the 5apps Deploy project.)_
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
You can run `kredits-web` on your machine, against a local, simulated Ethereum
|
||||
network, provided e.g. by [ganache](http://truffleframework.com/ganache/) or
|
||||
[ganache-cli](https://github.com/trufflesuite/ganache-cli).
|
||||
|
||||
[kredits-contracts](https://github.com/67P/kredits-contracts) holds all the tools
|
||||
to start and set up such a simulated network, as well as to deploy smart
|
||||
contracts to it.
|
||||
|
||||
These are the basic steps to get up and running:
|
||||
|
||||
#### 1. IPFS
|
||||
|
||||
Run a local IPFS deamon.
|
||||
Run a local IPFS deamon in offline mode.
|
||||
|
||||
* Make sure CORS headers are configured. See [IPFS](#ipfs) for more info.
|
||||
* `ipfs daemon`
|
||||
* `ipfs daemon --offline`
|
||||
|
||||
#### 2. kredits-contracts
|
||||
|
||||
Run a local devchain with test data. (See [kredits-contracts
|
||||
README](https://github.com/67P/kredits-contracts) for details.
|
||||
Get your local Ethereum development node running.
|
||||
|
||||
* 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
|
||||
* `npm run ganache` - which is basically: `ganache-cli -p 7545 -i 100` (we use the non-default port for local networks and a fixed network id)
|
||||
* `npm run bootstrap` - bootstrap runs fresh migrations, adds some seed data and writes the address/abi information to JSON that will be used by kredits-web
|
||||
* `npm link` - make 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:
|
||||
With IPFS and Ethereum/ganache running, you can now start this 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`.
|
||||
* `npm link kredits-contracts` - link the local `kredits-contracts` package
|
||||
* `npm run start:local` - NETWORK_ID=100 and WEB3_PROVIDER_URL=http://localhost:7545 must be set for local settings
|
||||
|
||||
#### IPFS
|
||||
|
||||
If you haven't configured your IPFS node for CORS yet, you can do so by running
|
||||
the following commands:
|
||||
Install IPFS with your favorite package manager and run
|
||||
|
||||
ipfs init (on initial installation)
|
||||
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
|
||||
|
||||
|
||||
+8
-6
@@ -1,12 +1,14 @@
|
||||
import Application from '@ember/application';
|
||||
import Resolver from 'ember-resolver';
|
||||
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;
|
||||
}
|
||||
const App = 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>
|
||||
|
||||
@@ -1,93 +1,64 @@
|
||||
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 { inject as injectService } from '@ember/service';
|
||||
|
||||
|
||||
export default Component.extend({
|
||||
kredits: injectService(),
|
||||
|
||||
router: service(),
|
||||
kredits: service(),
|
||||
// Default attributes used by reset
|
||||
attributes: {
|
||||
account: null,
|
||||
name: null,
|
||||
kind: 'person',
|
||||
url: null,
|
||||
github_username: null,
|
||||
github_uid: null,
|
||||
wiki_username: null,
|
||||
isCore: false,
|
||||
},
|
||||
|
||||
attributes: null,
|
||||
|
||||
isValidAccount: computed('account', function() {
|
||||
return isAddress(this.account);
|
||||
}),
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
this.reset();
|
||||
},
|
||||
|
||||
// TODO: add proper address validation
|
||||
isValidAccount: notEmpty('account'),
|
||||
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'),
|
||||
|
||||
isValid: and(
|
||||
'isValidAccount',
|
||||
'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);
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
submit() {
|
||||
if (!this.isValid) {
|
||||
window.alert('Invalid data. Please review and try again.');
|
||||
alert('Invalid data. Please review and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const attributes = Object.keys(this.attributes);
|
||||
const contributor = this.getProperties(attributes);
|
||||
let attributes = Object.keys(this.attributes);
|
||||
let contributor = this.getProperties(attributes);
|
||||
let saved = this.save(contributor);
|
||||
|
||||
this.set('inProgress', true);
|
||||
// The promise handles inProgress
|
||||
this.set('inProgress', saved);
|
||||
|
||||
this.save(contributor).then(() => {
|
||||
saved.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');
|
||||
window.scroll(0,0);
|
||||
window.alert('Contributor added.');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -1,56 +1,64 @@
|
||||
<form onsubmit={{action "submit"}}>
|
||||
<form {{action "submit" 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 name="is-core"
|
||||
type="checkbox"
|
||||
id="is-core"
|
||||
checked=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="account"
|
||||
type="text"
|
||||
placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4"
|
||||
value=account
|
||||
class=(if isValidAccount 'valid' '')}}
|
||||
</p>
|
||||
<p>
|
||||
<select required onchange={{action (mut kind) value="target.value"}}>
|
||||
<option value="person" selected={{eq kind "person"}}>Person</option>
|
||||
<option value="organization" selected={{eq 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=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=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=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=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=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"
|
||||
disabled=(is-pending inProgress)
|
||||
value=(if (is-pending inProgress) 'Processing' 'Save')}}
|
||||
</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>
|
||||
@@ -0,0 +1,57 @@
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
import { and, notEmpty } from '@ember/object/computed';
|
||||
|
||||
export default Component.extend({
|
||||
// Default attributes used by reset
|
||||
attributes: {
|
||||
contributorId: null,
|
||||
kind: 'community',
|
||||
amount: null,
|
||||
description: null,
|
||||
url: null,
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
this.reset();
|
||||
},
|
||||
|
||||
contributors: [],
|
||||
|
||||
isValidContributor: notEmpty('contributorId'),
|
||||
isValidAmount: computed('amount', function() {
|
||||
return parseInt(this.amount, 10) > 0;
|
||||
}),
|
||||
isValidDescription: notEmpty('description'),
|
||||
isValidUrl: notEmpty('url'),
|
||||
isValid: and('isValidContributor',
|
||||
'isValidAmount',
|
||||
'isValidDescription'),
|
||||
|
||||
reset: function() {
|
||||
this.setProperties(this.attributes);
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit() {
|
||||
if (!this.isValid) {
|
||||
alert('Invalid data. Please review and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
let attributes = Object.keys(this.attributes);
|
||||
let proposal = this.getProperties(attributes);
|
||||
let saved = this.save(proposal);
|
||||
|
||||
// The promise handles inProgress
|
||||
this.set('inProgress', saved);
|
||||
|
||||
saved.then(() => {
|
||||
this.reset();
|
||||
window.scroll(0,0);
|
||||
window.alert('Proposal added.');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<form {{action "submit" on="submit"}}>
|
||||
<p>
|
||||
<select required onchange={{action (mut contributorId) value="target.value"}}>
|
||||
<option value="" selected disabled hidden>Contributor</option>
|
||||
{{#each contributors as |contributor|}}
|
||||
<option value={{contributor.id}} selected={{eq contributorId contributor.id}}>{{contributor.github_username}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<select required onchange={{action (mut kind) value="target.value"}}>
|
||||
<option value="community" selected={{eq kind "community"}}>Community</option>
|
||||
<option value="design" selected={{eq kind "design"}}>Design</option>
|
||||
<option value="dev" selected={{eq kind "dev"}}>Development</option>
|
||||
<option value="docs" selected={{eq kind "docs"}}>Documentation</option>
|
||||
<option value="ops" selected={{eq kind "ops"}}>IT Operations</option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
{{input type="text"
|
||||
placeholder="100"
|
||||
value=amount
|
||||
class=(if isValidAmount 'valid' '')}}
|
||||
</p>
|
||||
<p>
|
||||
{{input type="text"
|
||||
placeholder="Description"
|
||||
value=description
|
||||
class=(if isValidDescription 'valid' '')}}
|
||||
</p>
|
||||
<p>
|
||||
{{input type="text"
|
||||
placeholder="URL (optional)"
|
||||
value=url
|
||||
class=(if isValidUrl 'valid' '')}}
|
||||
</p>
|
||||
<p class="actions">
|
||||
{{input type="submit"
|
||||
disabled=(is-pending inProgress)
|
||||
value=(if (is-pending inProgress) 'Processing' 'Save')}}
|
||||
{{#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,12 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { alias } from '@ember/object/computed';
|
||||
|
||||
export default class BudgetBalancesComponent extends Component {
|
||||
@service communityFunds;
|
||||
@alias('communityFunds.balances') balances;
|
||||
|
||||
get loading () {
|
||||
return !this.communityFunds.balancesLoaded;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +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.balances as |balance|}}
|
||||
<tr>
|
||||
<th>{{balance.token.symbol}}</th>
|
||||
<td class="amount">{{balance.confirmed_balance}}</td>
|
||||
<td class="fiat-amount">~{{balance.balanceUSD}} USD</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -12,24 +12,13 @@ let categoryColors = {
|
||||
export default Component.extend({
|
||||
|
||||
contributions: null,
|
||||
chartOptions: Object.freeze({
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}),
|
||||
|
||||
chartData: computed('contributions', function() {
|
||||
let kredits = this.contributions
|
||||
.filterBy('vetoed', false)
|
||||
let kredits = this.get('contributions')
|
||||
.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;
|
||||
}
|
||||
}).reduce(function (kinds, c) {
|
||||
kinds[c.kind] = (kinds[c.kind] || 0) + c.amount;
|
||||
return kinds;
|
||||
}, {});
|
||||
|
||||
@@ -59,6 +48,12 @@ export default Component.extend({
|
||||
'Documentation'
|
||||
],
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
chartOptions: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<div class="chart">
|
||||
<EmberChart @type="doughnut"
|
||||
@data={{this.chartData}}
|
||||
@options={{this.chartOptions}}
|
||||
@width="200" @height="200" />
|
||||
{{ember-chart type='doughnut'
|
||||
data=chartData
|
||||
options=chartOptions
|
||||
width=200
|
||||
height=200}}
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{{#unless this.isConfirmed}}
|
||||
Confirming in <strong>{{this.confirmedInBlocks}}</strong> blocks (~ {{this.confirmedInHumanTime}})
|
||||
{{/unless}}
|
||||
@@ -1,69 +0,0 @@
|
||||
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';
|
||||
|
||||
export default Component.extend({
|
||||
|
||||
router: service(),
|
||||
|
||||
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;
|
||||
});
|
||||
}),
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
{{#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>
|
||||
@@ -1,18 +1,22 @@
|
||||
import Component from '@ember/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default Component.extend({
|
||||
tagName: '',
|
||||
|
||||
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.contributors.setEach('showMetadata', false);
|
||||
contributor.set('showMetadata', true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<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.balance}}</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.account}}">Inspect Ethereum transactions</a></li>
|
||||
{{#if contributor.ipfsHash}}
|
||||
<li><a href="https://ipfs.io/ipfs/{{contributor.ipfsHash}}">Inspect IPFS profile</a></li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
{{#if contributor.showMetadata}}
|
||||
<pre>{{contributor.ipfsData}}</pre>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export default class ExpenseListComponent extends Component {
|
||||
|
||||
get showDeleteButton () {
|
||||
return !!this.args.deletable;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Component from '@ember/component';
|
||||
|
||||
export default Component.extend({
|
||||
|
||||
tagName: 'ul',
|
||||
classNames: ['proposal-list'],
|
||||
|
||||
actions: {
|
||||
|
||||
confirm(proposalId) {
|
||||
if (this.contractInteractionEnabled) {
|
||||
this.confirmProposal(proposalId);
|
||||
} else {
|
||||
window.alert('Only members can vote on proposals. Please ask someone to set you up.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{{#each proposals as |proposal|}}
|
||||
<li data-proposal-id={{proposal.id}} class={{if proposal.isExecuted "confirmed" "unconfirmed"}}>
|
||||
<p class="meta">
|
||||
<span class="category {{proposal.kind}}">♥ ({{proposal.kind}})</span>
|
||||
<span class="recipient">{{proposal.contributor.name}}:</span>
|
||||
</p>
|
||||
<p class="kredits-amount">
|
||||
<span class="amount">{{proposal.amount}}</span><span class="symbol">₭S</span>
|
||||
</p>
|
||||
<p class="description">
|
||||
<span class="description">{{proposal.description}}</span>
|
||||
</p>
|
||||
<p class="voting">
|
||||
{{#unless proposal.isExecuted}}
|
||||
<span class="votes">({{proposal.votesCount}}/{{proposal.votesNeeded}} votes)</span>
|
||||
<button {{action "confirm" proposal.id}}>+1</button>
|
||||
{{/unless}}
|
||||
</p>
|
||||
</li>
|
||||
{{/each}}
|
||||
@@ -1,23 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { sort } from '@ember/object/computed';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import config from 'kredits-web/config/environment';
|
||||
|
||||
export default class ReimbursementListComponent extends Component {
|
||||
@service kredits;
|
||||
|
||||
itemSorting = Object.freeze(['pendingStatus:asc', 'id:desc']);
|
||||
@sort('args.items', 'itemSorting') itemsSorted;
|
||||
|
||||
get ipfsGatewayUrl () {
|
||||
return config.ipfs.gatewayUrl;
|
||||
}
|
||||
|
||||
@action
|
||||
veto (id) {
|
||||
this.kredits.vetoReimbursement(id).then(transaction => {
|
||||
console.debug('[controllers:budget] Veto submitted to chain: '+transaction.hash);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<ul class="item-list spaced reimbursement-list {{if @loading 'loading'}}">
|
||||
{{#each this.itemsSorted as |reimbursement|}}
|
||||
<li data-reimbursement-id={{reimbursement.id}}
|
||||
class="{{item-status reimbursement}}">
|
||||
<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> <span class="symbol">BTC</span>
|
||||
</p>
|
||||
|
||||
<ExpenseList @expenses={{reimbursement.expenses}} />
|
||||
|
||||
<div class="meta">
|
||||
<p class="confirmation-eta">
|
||||
<ConfirmedIn @confirmedAtBlock={{reimbursement.confirmedAt}} />
|
||||
</p>
|
||||
<p class="actions">
|
||||
<a href="{{this.ipfsGatewayUrl}}/{{reimbursement.ipfsHash}}"
|
||||
class="button small" target="_blank" rel="noopener noreferrer">
|
||||
Inspect IPFS data
|
||||
</a>
|
||||
{{#if this.kredits.currentUserIsCore}}
|
||||
<button {{on "click" (fn this.veto reimbursement.id)}}
|
||||
disabled={{reimbursement.vetoed}}
|
||||
class="button small danger" type="button">veto</button>
|
||||
{{/if}}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
{{/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>
|
||||
@@ -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 '';
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { inject as injectService } from '@ember/service';
|
||||
|
||||
export default Controller.extend({
|
||||
kredits: service(),
|
||||
kredits: injectService(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,59 +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),
|
||||
|
||||
showIntroText: computed('kredits.{hasAccounts,currentUser}', function(){
|
||||
return (!this.kredits.hasAccounts || !this.kredits.currentUser);
|
||||
}),
|
||||
|
||||
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;
|
||||
})
|
||||
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { alias, filter, filterBy, sort } from '@ember/object/computed';
|
||||
import { inject as injectService } from '@ember/service';
|
||||
|
||||
export default Controller.extend({
|
||||
kredits: injectService(),
|
||||
|
||||
contributors: alias('kredits.contributors'),
|
||||
contributorsWithKredits: filter('contributors', function(contributor) {
|
||||
return contributor.get('balance') !== 0;
|
||||
}),
|
||||
contributorsSorting: ['balance:desc'],
|
||||
contributorsSorted: sort('contributorsWithKredits', 'contributorsSorting'),
|
||||
|
||||
proposals: alias('kredits.proposals'),
|
||||
proposalsOpen: filterBy('proposals', 'isExecuted', false),
|
||||
proposalsClosed: filterBy('proposals', 'isExecuted', true),
|
||||
proposalsSorting: ['id:desc'],
|
||||
proposalsClosedSorted: sort('proposalsClosed', 'proposalsSorting'),
|
||||
proposalsOpenSorted: sort('proposalsOpen', 'proposalsSorting'),
|
||||
|
||||
actions: {
|
||||
confirmProposal(proposalId) {
|
||||
this.kredits.vote(proposalId).then(transaction => {
|
||||
window.confirm('Vote submitted to Ethereum blockhain: '+transaction.hash);
|
||||
});
|
||||
},
|
||||
|
||||
save(contributor) {
|
||||
return this.kredits.addContributor(contributor)
|
||||
.then((contributor) => {
|
||||
this.contributors.pushObject(contributor);
|
||||
return contributor;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import Controller from '@ember/controller';
|
||||
import { alias, filterBy } from '@ember/object/computed';
|
||||
import { inject as injectService } from '@ember/service';
|
||||
|
||||
export default Controller.extend({
|
||||
kredits: injectService(),
|
||||
|
||||
contributors: alias('kredits.contributors'),
|
||||
minedContributors: filterBy('contributors', 'id'),
|
||||
|
||||
actions: {
|
||||
save(proposal) {
|
||||
// contributorIpfsHash is needed for the proposal ipfs data. I'm not happy to do this here but I think to load all the contributors in addProposal again is a bit too much. I hope we can refactor it later.
|
||||
let contributor = this.contributors.findBy('id', proposal.contributorId);
|
||||
proposal.contributorIpfsHash = contributor.get('ipfsHash');
|
||||
|
||||
return this.kredits.addProposal(proposal)
|
||||
.then((proposal) => {
|
||||
this.transitionToRoute('index');
|
||||
return proposal;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Helper from '@ember/component/helper';
|
||||
|
||||
export default Helper.extend({
|
||||
compute([string]) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
import getLocale from 'kredits-web/utils/get-locale';
|
||||
|
||||
export default helper(function(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const locale = getLocale();
|
||||
return new Intl.DateTimeFormat(locale).format(date);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
export default helper(function satsToBtc(amount/*, hash*/) {
|
||||
return amount / 100000000;
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import EmberObject, { computed } from '@ember/object';
|
||||
import { isEmpty, isPresent } from '@ember/utils';
|
||||
import moment from 'moment';
|
||||
|
||||
export default EmberObject.extend({
|
||||
// Contract
|
||||
id: null,
|
||||
contributorId: null,
|
||||
amount: null,
|
||||
confirmedAt: null,
|
||||
vetoed: null,
|
||||
ipfsHash: null,
|
||||
|
||||
// contributor model instance
|
||||
contributor: null,
|
||||
|
||||
// TODO contributor who submitted the contribution
|
||||
// submittedBy: null,
|
||||
|
||||
// IPFS
|
||||
kind: null,
|
||||
description: null,
|
||||
details: null,
|
||||
url: null,
|
||||
date: null,
|
||||
time: null,
|
||||
|
||||
pendingTx: null,
|
||||
|
||||
init () {
|
||||
this._super(...arguments);
|
||||
if (isEmpty(this.details)) this.set('details', {});
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { computed } from '@ember/object';
|
||||
import EmberObject from '@ember/object';
|
||||
import bignumber from 'kredits-web/utils/cps/bignumber';
|
||||
|
||||
export default EmberObject.extend({
|
||||
// Contract
|
||||
id: null,
|
||||
id: bignumber('idRaw', 'toString'),
|
||||
account: null,
|
||||
balance: 0,
|
||||
totalKreditsEarned: 0,
|
||||
contributionsCount: 0,
|
||||
balance: bignumber('balanceRaw', 'toNumber'),
|
||||
isCore: false,
|
||||
ipfsHash: null,
|
||||
|
||||
@@ -17,9 +17,15 @@ export default EmberObject.extend({
|
||||
github_username: null,
|
||||
github_uid: null,
|
||||
wiki_username: null,
|
||||
zoom_display_name: null,
|
||||
ipfsData: '',
|
||||
|
||||
serialize () {
|
||||
return JSON.stringify(this);
|
||||
}
|
||||
// Deprecated
|
||||
isCurrentUser: false,
|
||||
|
||||
avatarURL: computed('github_uid', function() {
|
||||
let github_uid = this.github_uid;
|
||||
if (github_uid) {
|
||||
return `https://avatars2.githubusercontent.com/u/${github_uid}?v=3&s=128`;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import EmberObject from '@ember/object';
|
||||
import { alias } from '@ember/object/computed';
|
||||
import bignumber from 'kredits-web/utils/cps/bignumber';
|
||||
|
||||
export default EmberObject.extend({
|
||||
// Contract
|
||||
id: bignumber('idRaw', 'toString'),
|
||||
creatorAccount: null,
|
||||
contributorId: bignumber('contributorIdRaw', 'toString'),
|
||||
amount: bignumber('amountRaw', 'toNumber'),
|
||||
votesCount: bignumber('votesCountRaw', 'toNumber'),
|
||||
votesNeeded: bignumber('votesNeededRaw', 'toNumber'),
|
||||
executed: null,
|
||||
ipfsHash: null,
|
||||
|
||||
// Shortcuts
|
||||
isExecuted: alias('executed'),
|
||||
|
||||
// IPFS
|
||||
kind: null,
|
||||
description: null,
|
||||
details: {},
|
||||
url: null,
|
||||
ipfsData: '',
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import Resolver from 'ember-resolver';
|
||||
|
||||
export default Resolver;
|
||||
+7
-33
@@ -1,41 +1,15 @@
|
||||
import EmberRouter from '@ember/routing/router';
|
||||
import config from './config/environment';
|
||||
|
||||
export default class Router extends EmberRouter {
|
||||
location = config.locationType;
|
||||
rootURL = config.rootURL;
|
||||
}
|
||||
const Router = EmberRouter.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('proposals', function() {
|
||||
this.route('new');
|
||||
});
|
||||
});
|
||||
|
||||
export default Router;
|
||||
|
||||
+19
-27
@@ -1,35 +1,27 @@
|
||||
import { inject as injectService } from '@ember/service';
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { schedule } from '@ember/runloop';
|
||||
|
||||
export default class ApplicationRoute extends Route {
|
||||
@service kredits;
|
||||
@service communityFunds;
|
||||
export default Route.extend({
|
||||
kredits: injectService(),
|
||||
|
||||
beforeModel(/* transition */) {
|
||||
return this.kredits.setup().then(() => {
|
||||
this.kredits.kredits.preflightChecks().catch(error => {
|
||||
console.error('Kredits preflight check failed!');
|
||||
console.error(error);
|
||||
});
|
||||
}).catch(error => {
|
||||
beforeModel(transition) {
|
||||
const kredits = this.kredits;
|
||||
|
||||
return kredits.setup().then(() => {
|
||||
if (kredits.get('accountNeedsUnlock')) {
|
||||
if (confirm('It looks like you have an Ethereum wallet available. Please unlock your account.')) {
|
||||
transition.retry();
|
||||
}
|
||||
}
|
||||
}).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);
|
||||
return this.kredits.loadContributorsAndProposals()
|
||||
.then(() => {
|
||||
this.kredits.addContractEventHandlers();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class BudgetExpensesRoute extends Route {
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ContributionsNewRoute extends Route {
|
||||
|
||||
model (params) {
|
||||
return { params };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
|
||||
redirect () {
|
||||
this.transitionTo('dashboard');
|
||||
}
|
||||
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ReimbursementsNewRoute extends Route {
|
||||
|
||||
model (params) {
|
||||
return { params };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { A } from '@ember/array';
|
||||
import { task } from 'ember-concurrency-decorators';
|
||||
import config from 'kredits-web/config/environment';
|
||||
|
||||
export default class CommunityFundsService extends Service {
|
||||
@service exchangeRates;
|
||||
|
||||
@tracked balancesLoaded = false;
|
||||
@tracked balances = A([]);
|
||||
|
||||
@task
|
||||
*fetchBalances () {
|
||||
yield fetch(config.btcBalanceAPI).then(res => res.json())
|
||||
.then(res => {
|
||||
return this.processBalances(res);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(`[community-funds] Fetching balances failed:`);
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
async processBalances (res) {
|
||||
await this.exchangeRates.fetchRates();
|
||||
// Format and round the approximate USD value
|
||||
const lang = navigator.language || navigator.userLanguage;
|
||||
const balanceUSD = res.confirmed_balance * this.exchangeRates.btcusd;
|
||||
res.balanceUSD = Math.round(balanceUSD).toLocaleString(lang);
|
||||
|
||||
this.balances.pushObject({
|
||||
...res,
|
||||
...{ token: { name: 'BTC', symbol: 'BTC'} }
|
||||
});
|
||||
|
||||
this.balancesLoaded = true;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import config from 'kredits-web/config/environment';
|
||||
|
||||
// Need to go through proxy for CORS headers
|
||||
const bitstampBaseUrl = `${config.corsProxy}https://www.bitstamp.net/api/v2`;
|
||||
|
||||
async function fetchFromBitstamp(currencyPair) {
|
||||
try {
|
||||
const res = await fetch(`${bitstampBaseUrl}/ticker/${currencyPair}/`).then(r => r.json());
|
||||
return parseFloat(res.vwap); // Last 24 hours volume weighted average price
|
||||
} catch(e) {
|
||||
console.error('Could not fetch exchange rate from Bitstamp:', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ExchangeRatesService extends Service {
|
||||
@tracked btceur = 0;
|
||||
@tracked btcusd = 0;
|
||||
|
||||
async fetchRates (source='bitstamp') {
|
||||
switch(source) {
|
||||
case 'bitstamp':
|
||||
this.btceur = await fetchFromBitstamp('btceur');
|
||||
this.btcusd = await fetchFromBitstamp('btcusd');
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
-709
@@ -1,776 +1,223 @@
|
||||
import ethers from 'ethers';
|
||||
import Kredits from '@kredits/contracts';
|
||||
import ethers from 'npm:ethers';
|
||||
import Kredits from 'npm:kredits-contracts';
|
||||
import RSVP from 'rsvp';
|
||||
|
||||
import Service from '@ember/service';
|
||||
import EmberObject from '@ember/object';
|
||||
import { computed } from '@ember/object';
|
||||
import { alias, filterBy, notEmpty, sort } from '@ember/object/computed';
|
||||
import { alias, notEmpty } from '@ember/object/computed';
|
||||
import { isEmpty } from '@ember/utils';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
import { task, taskGroup } from 'ember-concurrency';
|
||||
|
||||
import groupBy from 'kredits-web/utils/group-by';
|
||||
import processContributorData from 'kredits-web/utils/process-contributor-data';
|
||||
import processContributionData from 'kredits-web/utils/process-contribution-data';
|
||||
import processReimbursementData from 'kredits-web/utils/process-reimbursement-data';
|
||||
import formatKredits from 'kredits-web/utils/format-kredits';
|
||||
import switchNetwork from 'kredits-web/utils/switch-network';
|
||||
|
||||
import config from 'kredits-web/config/environment';
|
||||
import Contributor from 'kredits-web/models/contributor';
|
||||
import Contribution from 'kredits-web/models/contribution';
|
||||
import Reimbursement from 'kredits-web/models/reimbursement';
|
||||
// Lets us access the model classes dynamically
|
||||
const models = { Contributor, Contribution, Reimbursement };
|
||||
import Contributor from 'kredits-web/models/contributor'
|
||||
import Proposal from 'kredits-web/models/proposal'
|
||||
|
||||
|
||||
export default Service.extend({
|
||||
|
||||
browserCache: service(),
|
||||
|
||||
currentBlock: null,
|
||||
currentUserAccounts: null, // default to not having an account. this is the wen web3 is loaded.
|
||||
currentUser: null,
|
||||
contributors: null,
|
||||
contributions: null,
|
||||
reimbursements: null,
|
||||
githubAccessToken: null,
|
||||
|
||||
currentUserIsContributor: notEmpty('currentUser'),
|
||||
currentUserIsCore: alias('currentUser.isCore'),
|
||||
hasAccounts: notEmpty('currentUserAccounts'),
|
||||
accountNeedsUnlock: computed('currentUserAccounts', function() {
|
||||
return this.currentUserAccounts && isEmpty(this.currentUserAccounts);
|
||||
}),
|
||||
|
||||
contributorsMined: filterBy('contributors', 'id'),
|
||||
contributorsSorting: Object.freeze(['name:asc']),
|
||||
contributorsSorted: sort('contributorsMined', 'contributorsSorting'),
|
||||
|
||||
// When data was loaded from cache, we need to fetch updates from the network
|
||||
contributorsNeedSync: false,
|
||||
contributionsNeedSync: false,
|
||||
reimbursementsNeedSync: false,
|
||||
|
||||
missingHistoricContributionsCount: 0,
|
||||
|
||||
init () {
|
||||
this._super(...arguments);
|
||||
this.set('contributors', []);
|
||||
this.set('contributions', []);
|
||||
this.set('reimbursements', []);
|
||||
|
||||
if (window.ethereum) {
|
||||
window.ethereum.on('chainChanged', this.handleUserChainChanged);
|
||||
window.ethereum.on('accountsChanged', this.handleAccountsChanged);
|
||||
}
|
||||
},
|
||||
|
||||
handleUserChainChanged (chainId) {
|
||||
console.log('User-provided chain ID changed to', chainId);
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
handleAccountsChanged (accounts) {
|
||||
console.log('User-provided accounts changed to', accounts);
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
// This is called in the application route's beforeModel(). So it is
|
||||
// initialized before everything else, and we can rely on the ethProvider and
|
||||
// the potential currentUserAccounts to be available
|
||||
getEthProvider () {
|
||||
let ethProvider;
|
||||
|
||||
return new Promise(resolve => {
|
||||
function instantiateWithoutWallet () {
|
||||
console.debug('[kredits] Creating new instance from npm module class');
|
||||
console.debug(`[kredits] providerURL: ${config.web3ProviderUrl}`);
|
||||
ethProvider = new ethers.providers.JsonRpcProvider(config.web3ProviderUrl);
|
||||
resolve({
|
||||
ethProvider: ethProvider,
|
||||
ethSigner: null
|
||||
});
|
||||
}
|
||||
|
||||
async function instantiateWithWallet (web3Provider, context) {
|
||||
console.debug('[kredits] Using user-provided Web3 instance, e.g. from Metamask');
|
||||
ethProvider = new ethers.providers.Web3Provider(web3Provider);
|
||||
const network = await ethProvider.getNetwork();
|
||||
const accounts = await ethProvider.listAccounts();
|
||||
const chainId = config.web3ChainId;
|
||||
|
||||
if (isEmpty(accounts)) return instantiateWithoutWallet();
|
||||
|
||||
if (network.chainId !== chainId) {
|
||||
return switchNetwork();
|
||||
} else {
|
||||
context.set('currentUserAccounts', accounts);
|
||||
// this is called in the routes beforeModel(). So it is initialized before everything else
|
||||
// and we can rely on the ethProvider and the potential currentUserAccounts to be available
|
||||
getEthProvider: function() {
|
||||
return new RSVP.Promise((resolve) => {
|
||||
let ethProvider;
|
||||
let networkId;
|
||||
if (typeof window.web3 !== 'undefined') {
|
||||
console.debug('[kredits] Using user-provided instance, e.g. from Mist browser or Metamask');
|
||||
networkId = parseInt(window.web3.version.network);
|
||||
ethProvider = new ethers.providers.Web3Provider(
|
||||
window.web3.currentProvider,
|
||||
{ chainId: networkId }
|
||||
);
|
||||
ethProvider.listAccounts().then((accounts) => {
|
||||
this.set('currentUserAccounts', accounts);
|
||||
const ethSigner = accounts.length === 0 ? null : ethProvider.getSigner();
|
||||
resolve({
|
||||
ethProvider,
|
||||
ethSigner
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (window.ethereum) {
|
||||
instantiateWithWallet(window.ethereum, this);
|
||||
});
|
||||
} else {
|
||||
instantiateWithoutWallet();
|
||||
console.debug('[kredits] Creating new instance from npm module class');
|
||||
networkId = parseInt(config.contractMetadata.networkId);
|
||||
console.debug(`[kredits] networkId=${networkId} providerURL: ${config.web3ProviderUrl}`);
|
||||
ethProvider = new ethers.providers.JsonRpcProvider(
|
||||
config.web3ProviderUrl,
|
||||
{ chainId: networkId }
|
||||
);
|
||||
resolve({
|
||||
ethProvider: ethProvider,
|
||||
ethSigner: null
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async connectWallet () {
|
||||
const provider = new ethers.providers.Web3Provider(window.ethereum);
|
||||
const network = await provider.getNetwork();
|
||||
const chainId = config.web3ChainId;
|
||||
const chainIdHex = `0x${Number(chainId).toString(16)}`;
|
||||
setup() {
|
||||
return this.getEthProvider().then((providerAndSigner) => {
|
||||
|
||||
try {
|
||||
await window.ethereum.request({ method: 'eth_requestAccounts' });
|
||||
if (network.chainId !== chainId) await switchNetwork(chainIdHex);
|
||||
} catch (err) {
|
||||
console.log('Connecting wallet failed:', err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async setup () {
|
||||
const kredits = await this.getEthProvider().then(providerAndSigner => {
|
||||
return new Kredits(providerAndSigner.ethProvider, providerAndSigner.ethSigner, {
|
||||
let kredits = new Kredits(providerAndSigner.ethProvider, providerAndSigner.ethSigner, {
|
||||
ipfsConfig: config.ipfs
|
||||
});
|
||||
return kredits
|
||||
.init()
|
||||
.then((kredits) => {
|
||||
this.set('kredits', kredits);
|
||||
if (this.currentUserAccounts && this.currentUserAccounts.length > 0) {
|
||||
this.getCurrentUser.then((contributorData) => {
|
||||
this.set('currentUser', contributorData);
|
||||
});
|
||||
}
|
||||
return kredits;
|
||||
});
|
||||
});
|
||||
|
||||
await kredits.init();
|
||||
this.set('kredits', kredits);
|
||||
this.set('currentBlock', await this.kredits.provider.getBlockNumber());
|
||||
this.kredits.provider.on('block', blockNumber => {
|
||||
console.debug('[kredits] New block mined:', blockNumber);
|
||||
this.set('currentBlock', blockNumber)
|
||||
});
|
||||
|
||||
if (this.currentUserAccounts && this.currentUserAccounts.length > 0) {
|
||||
this.getCurrentUser.then(contributorData => {
|
||||
this.set('currentUser', contributorData);
|
||||
});
|
||||
}
|
||||
|
||||
return kredits;
|
||||
},
|
||||
|
||||
getCurrentUser: computed('kredits.provider', 'currentUserAccounts.[]', function() {
|
||||
totalSupply: computed(function() {
|
||||
return this.kredits.Token.functions.totalSupply();
|
||||
}),
|
||||
|
||||
contributors: [],
|
||||
proposals: [],
|
||||
|
||||
loadContributorsAndProposals() {
|
||||
return this.getContributors()
|
||||
.then(contributors => this.contributors.pushObjects(contributors))
|
||||
.then(() => this.getProposals())
|
||||
.then(proposals => this.proposals.pushObjects(proposals))
|
||||
},
|
||||
|
||||
addContributor(attributes) {
|
||||
console.debug('[kredits] add contributor', attributes);
|
||||
|
||||
return this.kredits.Contributor.add(attributes)
|
||||
.then((data) => {
|
||||
console.debug('[kredits] add contributor response', data);
|
||||
return Contributor.create(attributes);
|
||||
});
|
||||
},
|
||||
|
||||
getContributors() {
|
||||
return this.kredits.Contributor.all()
|
||||
.then((contributors) => {
|
||||
return contributors.map((contributor) => {
|
||||
return Contributor.create(contributor);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
addProposal(attributes) {
|
||||
console.debug('[kredits] add proposal', attributes);
|
||||
|
||||
return this.kredits.Operator.addProposal(attributes)
|
||||
.then((data) => {
|
||||
console.debug('[kredits] add proposal response', data);
|
||||
attributes.contributor = this.contributors.findBy('id', attributes.contributorId);
|
||||
return Proposal.create(attributes);
|
||||
});
|
||||
},
|
||||
|
||||
getProposals() {
|
||||
return this.kredits.Operator.all()
|
||||
.then((proposals) => {
|
||||
return proposals.map((proposal) => {
|
||||
proposal.contributor = this.contributors.findBy('id', proposal.contributorId.toString());
|
||||
return Proposal.create(proposal);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
vote(proposalId) {
|
||||
console.debug('[kredits] vote for', proposalId);
|
||||
|
||||
return this.kredits.Operator.functions.vote(proposalId)
|
||||
.then((data) => {
|
||||
console.debug('[kredits] vote response', data);
|
||||
return data;
|
||||
});
|
||||
},
|
||||
|
||||
getCurrentUser: computed('kredits.provider', function() {
|
||||
if (isEmpty(this.currentUserAccounts)) {
|
||||
return Promise.resolve();
|
||||
return RSVP.resolve();
|
||||
}
|
||||
return this.kredits.Contributor
|
||||
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject)
|
||||
.functions.getContributorIdByAddress(this.get('currentUserAccounts.firstObject'))
|
||||
.then((id) => {
|
||||
id = id.toNumber();
|
||||
// check if the user is a contributor or not
|
||||
if (id === 0) {
|
||||
return Promise.resolve();
|
||||
return RSVP.resolve();
|
||||
} else {
|
||||
return this.kredits.Contributor.getById(id);
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
totalSupply: computed(function() {
|
||||
return this.kredits.Token.functions.totalSupply().then(total => {
|
||||
return formatKredits(total);
|
||||
})
|
||||
}),
|
||||
|
||||
totalKreditsEarned: computed(function() {
|
||||
return this.kredits.Contribution.functions.totalKreditsEarned(true);
|
||||
}),
|
||||
|
||||
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
|
||||
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
|
||||
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
|
||||
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value);
|
||||
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
|
||||
|
||||
const kreditsByContributor = contributionsGrouped.map(c => {
|
||||
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
|
||||
const contributor = this.contributors.findBy('id', c.value);
|
||||
|
||||
return EmberObject.create({
|
||||
contributor: contributor,
|
||||
amountUnconfirmed: amountUnconfirmed,
|
||||
amountConfirmed: contributor.totalKreditsEarned,
|
||||
amountTotal: contributor.totalKreditsEarned + amountUnconfirmed
|
||||
})
|
||||
});
|
||||
|
||||
contributorsWithOnlyConfirmed.forEach(c => {
|
||||
kreditsByContributor.push(EmberObject.create({
|
||||
contributor: c,
|
||||
amountUnconfirmed: 0,
|
||||
amountConfirmed: c.totalKreditsEarned,
|
||||
amountTotal: c.totalKreditsEarned
|
||||
}));
|
||||
})
|
||||
|
||||
return kreditsByContributor;
|
||||
}),
|
||||
|
||||
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
|
||||
return this.contributions
|
||||
.filter(c => c.confirmedAt > this.currentBlock);
|
||||
}),
|
||||
|
||||
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
|
||||
return this.contributions
|
||||
.filterBy('vetoed', false)
|
||||
.filter(c => c.confirmedAt <= this.currentBlock);
|
||||
}),
|
||||
|
||||
reimbursementsUnconfirmed: computed('reimbursements.[]', 'currentBlock', function() {
|
||||
return this.reimbursements
|
||||
.filter(r => r.confirmedAt > this.currentBlock);
|
||||
}),
|
||||
|
||||
reimbursementsConfirmed: computed('reimbursements.[]', 'currentBlock', function() {
|
||||
return this.reimbursements
|
||||
.filterBy('vetoed', false)
|
||||
.filter(r => r.confirmedAt <= this.currentBlock);
|
||||
}),
|
||||
|
||||
reimbursementsPending: computed('reimbursements.[]', 'pendingTx', function() {
|
||||
return this.reimbursements.filter(r => !r.id);
|
||||
}),
|
||||
|
||||
async loadInitialData () {
|
||||
const numCachedContributors = await this.browserCache.contributors.length();
|
||||
if (numCachedContributors > 0) {
|
||||
await this.loadObjectsFromCache('Contributor');
|
||||
this.set('contributorsNeedSync', true);
|
||||
} else {
|
||||
await this.fetchContributors();
|
||||
}
|
||||
|
||||
const numCachedContributions = await this.browserCache.contributions.length();
|
||||
if (numCachedContributions > 0) {
|
||||
await this.loadObjectsFromCache('Contribution');
|
||||
this.set('contributionsNeedSync', true);
|
||||
} else {
|
||||
await this.fetchContributions({ page: { size: 40 } });
|
||||
}
|
||||
|
||||
await this.updateMissingHistoricContributionsCount();
|
||||
|
||||
return Promise.resolve();
|
||||
findProposalById(proposalId) {
|
||||
return this.proposals.findBy('id', proposalId.toString());
|
||||
},
|
||||
|
||||
async updateMissingHistoricContributionsCount () {
|
||||
const contributionsCount = await this.kredits.Contribution.count;
|
||||
this.set('missingHistoricContributionsCount', contributionsCount - this.contributions.length);
|
||||
console.debug(`Missing ${this.missingHistoricContributionsCount} historic contributions (out of ${contributionsCount} overall)`)
|
||||
},
|
||||
|
||||
addContributor (attributes) {
|
||||
if (attributes.github_uid) {
|
||||
const uidInt = parseInt(attributes.github_uid);
|
||||
attributes.github_uid = uidInt;
|
||||
}
|
||||
|
||||
console.debug('[kredits] add contributor', attributes);
|
||||
|
||||
return this.kredits.Contributor.add(attributes, { gasLimit: 350000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] add contributor response', data);
|
||||
});
|
||||
},
|
||||
|
||||
updateContributor (id, attributes) {
|
||||
if (attributes.github_uid) {
|
||||
const uidInt = parseInt(attributes.github_uid);
|
||||
attributes.github_uid = uidInt;
|
||||
}
|
||||
|
||||
console.debug('[kredits] update contributor', attributes);
|
||||
|
||||
return this.kredits.Contributor.updateProfile(id, attributes, { gasLimit: 350000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] updateProfile response', data);
|
||||
});
|
||||
},
|
||||
|
||||
async fetchContributor (id) {
|
||||
console.debug(`[kredits] Fetching contributor from the network`);
|
||||
return this.kredits.Contributor.getById(id)
|
||||
.then(data => this.loadContributorFromData(data))
|
||||
},
|
||||
|
||||
fetchContributors () {
|
||||
console.debug(`[kredits] Fetching all contributors from the network`);
|
||||
return this.kredits.Contributor.all()
|
||||
.then(contributors => {
|
||||
return contributors.forEach(data => {
|
||||
this.loadContributorFromData(data);
|
||||
return;
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return this.cacheLoadedContributors();
|
||||
});
|
||||
},
|
||||
|
||||
loadContributorFromData (data) {
|
||||
const contributor = Contributor.create(processContributorData(data));
|
||||
const loadedContributor = this.contributors.findBy('id', contributor.id);
|
||||
if (loadedContributor) { this.contributors.removeObject(loadedContributor); }
|
||||
this.contributors.pushObject(contributor);
|
||||
return contributor;
|
||||
},
|
||||
|
||||
async cacheLoadedContributors () {
|
||||
for (const c of this.contributors) {
|
||||
await this.browserCache.contributors.setItem(c.id.toString(), c.serialize());
|
||||
}
|
||||
console.debug(`[kredits] Cached ${this.contributors.length} contributors in browser storage`);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
async loadContributorsFromCache () {
|
||||
return this.browserCache.contributors.iterate((value/*, key , iterationNumber */) => {
|
||||
this.contributors.pushObject(Contributor.create(JSON.parse(value)));
|
||||
}).then((/* result */) => {
|
||||
console.debug(`[kredits] Loaded ${this.contributors.length} contributors from cache`);
|
||||
});
|
||||
},
|
||||
|
||||
syncContributors: task(function * () {
|
||||
yield this.fetchContributors();
|
||||
this.set('contributorsNeedSync', false);
|
||||
}),
|
||||
|
||||
addContribution (attributes) {
|
||||
console.debug('[kredits] Adding contribution', attributes);
|
||||
|
||||
return this.kredits.Contribution.add(attributes, { gasLimit: 300000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] Contribution.add response', data);
|
||||
attributes.contributor = this.contributors.findBy('id', attributes.contributorId);
|
||||
const contribution = Contribution.create(attributes);
|
||||
contribution.set('pendingTx', data);
|
||||
contribution.set('confirmedAtBlock', this.currentBlock + 40320);
|
||||
this.contributions.pushObject(contribution);
|
||||
return contribution;
|
||||
});
|
||||
},
|
||||
|
||||
fetchContributions (options = { page: { size: 200 } }) {
|
||||
console.debug(`[kredits] Fetching contributions from the network`);
|
||||
return this.kredits.Contribution.all(options)
|
||||
.then(contributions => {
|
||||
return contributions.map(data => {
|
||||
const contribution = this.loadContributionFromData(data);
|
||||
return contribution;
|
||||
});
|
||||
})
|
||||
.then(contributions => {
|
||||
const cacheWrites = contributions.map(c => {
|
||||
return this.browserCache.contributions.setItem(c.id.toString(), c.serialize());
|
||||
});
|
||||
return Promise.all(cacheWrites).then(() => {
|
||||
console.debug(`[kredits] Cached ${contributions.length} contributions in browser storage`);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
loadContributionFromData(data) {
|
||||
const contribution = Contribution.create(processContributionData(data));
|
||||
contribution.set('contributor', this.contributors.findBy('id', data.contributorId));
|
||||
const loadedContribution = this.contributions.findBy('id', contribution.id);
|
||||
if (loadedContribution) { this.contributions.removeObject(loadedContribution); }
|
||||
this.contributions.pushObject(contribution);
|
||||
return contribution;
|
||||
},
|
||||
|
||||
async cacheLoadedContributions () {
|
||||
for (const c of this.contributions) {
|
||||
await this.browserCache.contributions.setItem(c.id.toString(), c.serialize());
|
||||
}
|
||||
console.debug(`[kredits] Cached ${this.contributions.length} contributions in browser storage`);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
async loadContributionsFromCache () {
|
||||
return this.browserCache.contributions.iterate((value/*, key , iterationNumber */) => {
|
||||
this.contributions.pushObject(Contribution.create(JSON.parse(value)));
|
||||
}).then((/* result */) => {
|
||||
console.debug(`[kredits] Loaded ${this.contributions.length} contributions from cache`);
|
||||
});
|
||||
},
|
||||
|
||||
contributionTasks: taskGroup().enqueue(),
|
||||
|
||||
syncContributions: task(function * () {
|
||||
yield this.fetchNewContributions.perform();
|
||||
yield this.syncUnconfirmedContributions.perform();
|
||||
yield this.updateMissingHistoricContributionsCount();
|
||||
this.set('contributionsNeedSync', false);
|
||||
}).group('contributionTasks'),
|
||||
|
||||
fetchNewContributions: task(function * () {
|
||||
const count = yield this.kredits.Contribution.count;
|
||||
const lastKnownContributionId = Math.max.apply(null, this.contributions.mapBy('id'));
|
||||
const toFetch = count - lastKnownContributionId;
|
||||
|
||||
if (toFetch > 0) {
|
||||
console.debug(`[kredits] Fetching ${toFetch} new contributions`);
|
||||
for (let id = lastKnownContributionId; id <= count; id++) {
|
||||
const data = yield this.kredits.Contribution.getById(id);
|
||||
const c = this.loadContributionFromData(data);
|
||||
yield this.browserCache.contributions.setItem(c.id.toString(), c.serialize());
|
||||
}
|
||||
} else {
|
||||
console.debug(`[kredits] No new contributions to fetch`);
|
||||
}
|
||||
}),
|
||||
|
||||
fetchMissingContributions: task(function * () {
|
||||
const count = yield this.kredits.Contribution.count;
|
||||
const allIds = [...Array(count+1).keys()];
|
||||
allIds.shift(); // remove first item, which is 0
|
||||
const loadedContributions = new Set(this.contributions.mapBy('id'));
|
||||
const toFetch = allIds.filter(id => !loadedContributions.has(id));
|
||||
if (toFetch.length === 0) {
|
||||
console.debug(`[kredits] No contributions left to fetch`);
|
||||
return;
|
||||
}
|
||||
console.debug(`[kredits] Fetching ${toFetch.length} past contributions`);
|
||||
let countFetched = 0;
|
||||
|
||||
for (let id = count; id > 0; id--) {
|
||||
if (loadedContributions.has(id)) {
|
||||
continue;
|
||||
} else {
|
||||
const data = yield this.kredits.Contribution.getById(id);
|
||||
const c = this.loadContributionFromData(data);
|
||||
yield this.browserCache.contributions.setItem(c.id.toString(), c.serialize());
|
||||
countFetched++;
|
||||
if (countFetched % 20 === 0) {
|
||||
console.debug(`[kredits] Fetched ${countFetched} more contributions`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.debug(`[kredits] Cached ${countFetched} past contributions`);
|
||||
}).group('contributionTasks'),
|
||||
|
||||
syncUnconfirmedContributions: task(function * () {
|
||||
if (this.contributionsUnconfirmed.length > 0) {
|
||||
console.debug(`[kredits] Syncing unconfirmed contributions`);
|
||||
for (const c of this.contributionsUnconfirmed) {
|
||||
const data = yield this.kredits.Contribution.getById(c.id);
|
||||
const contribution = this.loadContributionFromData(data);
|
||||
yield this.browserCache.contributions.setItem(c.id.toString(), contribution.serialize());
|
||||
}
|
||||
} else {
|
||||
console.debug(`[kredits] No unconfirmed contributions to sync`);
|
||||
}
|
||||
}),
|
||||
|
||||
veto (contributionId) {
|
||||
console.debug('[kredits] veto against', contributionId);
|
||||
const contribution = this.contributions.findBy('id', contributionId);
|
||||
|
||||
return this.kredits.Contribution.functions.veto(contributionId, { gasLimit: 300000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] veto response', data);
|
||||
contribution.set('pendingTx', data);
|
||||
return data;
|
||||
});
|
||||
},
|
||||
|
||||
//
|
||||
// Generic data handling (for objects that can be vetoed)
|
||||
//
|
||||
|
||||
fetchObjects(objectClass, options = { page: { size: 200 } }) {
|
||||
const objectClassLowerCase = objectClass.toLowerCase();
|
||||
console.debug(`[kredits] Fetching ${objectClassLowerCase}s from the network`);
|
||||
return this.kredits[objectClass].all(options)
|
||||
.then(objects => {
|
||||
return objects.map(data => {
|
||||
const classInstance = this[`load${objectClass}FromData`](data);
|
||||
return classInstance;
|
||||
});
|
||||
})
|
||||
.then(objects => {
|
||||
const cacheWrites = objects.map(o => {
|
||||
return this.browserCache[objectClassLowerCase+'s']
|
||||
.setItem(o.id.toString(), o.serialize());
|
||||
});
|
||||
return Promise.all(cacheWrites).then(() => {
|
||||
console.debug(`[kredits] Cached ${objects.length} ${objectClassLowerCase+'s'} in browser storage`);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
removeObjectFromCollectionIfLoaded (collection, objectId) {
|
||||
const loadedObj = this[collection].findBy('id', objectId);
|
||||
if (loadedObj) { this[collection].removeObject(loadedObj); }
|
||||
},
|
||||
|
||||
async cacheLoadedObjects (collection) {
|
||||
for (const o of this[collection]) {
|
||||
await this.browserCache[collection].setItem(o.id, o.serialize());
|
||||
}
|
||||
console.debug(`[kredits] Cached ${this[collection].length} ${collection} in browser storage`);
|
||||
},
|
||||
|
||||
async loadObjectsFromCache (objectClass) {
|
||||
const collection = objectClass.toLowerCase()+'s';
|
||||
return this.browserCache[collection].iterate((value/*, key , iterationNumber */) => {
|
||||
const obj = models[objectClass].create(JSON.parse(value));
|
||||
this.removeObjectFromCollectionIfLoaded(collection, obj.id)
|
||||
this[collection].pushObject(obj);
|
||||
}).then((/* result */) => {
|
||||
console.debug(`[kredits] Loaded ${this[collection].length} ${collection} from cache`);
|
||||
});
|
||||
},
|
||||
|
||||
syncTaskGroup: taskGroup().enqueue(),
|
||||
|
||||
fetchNewObjects: task(function * (objectClass) {
|
||||
const collection = objectClass.toLowerCase()+'s';
|
||||
const count = yield this.kredits[objectClass].functions[`${collection}Count`]();
|
||||
const lastKnownObjectId = Math.max.apply(null, this[collection].mapBy('id'));
|
||||
const toFetch = count - lastKnownObjectId;
|
||||
|
||||
if (toFetch > 0) {
|
||||
console.debug(`[kredits] Fetching ${toFetch} new ${collection}`);
|
||||
for (let id = lastKnownObjectId; id <= count; id++) {
|
||||
const data = yield this.kredits[objectClass].getById(id);
|
||||
const o = this[`load${objectClass}FromData`](data);
|
||||
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
|
||||
}
|
||||
} else {
|
||||
console.debug(`[kredits] No new ${collection} to fetch`);
|
||||
}
|
||||
}),
|
||||
|
||||
fetchMissingObjects: task(function * (objectClass) {
|
||||
const collection = objectClass.toLowerCase()+'s';
|
||||
const count = yield this.kredits[objectClass].functions[`${collection}Count`]();
|
||||
const allIds = [...Array(count+1).keys()];
|
||||
allIds.shift(); // remove first item, which is 0
|
||||
const loadedObjects = new Set(this[collection].mapBy('id'));
|
||||
const toFetch = allIds.filter(id => !loadedObjects.has(id));
|
||||
if (toFetch.length === 0) {
|
||||
console.debug(`[kredits] No ${collection} left to fetch`);
|
||||
return;
|
||||
}
|
||||
console.debug(`[kredits] Fetching ${toFetch.length} past ${collection}`);
|
||||
let countFetched = 0;
|
||||
|
||||
for (let id = count; id > 0; id--) {
|
||||
if (loadedObjects.has(id)) {
|
||||
continue;
|
||||
} else {
|
||||
const data = yield this.kredits[objectClass].getById(id);
|
||||
const o = this[`load${objectClass}FromData`](data);
|
||||
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
|
||||
countFetched++;
|
||||
if (countFetched % 20 === 0) {
|
||||
console.debug(`[kredits] Fetched ${countFetched} more ${collection}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.debug(`[kredits] Cached ${countFetched} past ${collection}`);
|
||||
}),
|
||||
|
||||
syncUnconfirmedObjects: task(function * (objectClass) {
|
||||
const collection = objectClass.toLowerCase()+'s';
|
||||
if (this.get(`${collection}Unconfirmed`).length > 0) {
|
||||
console.debug(`[kredits] Syncing unconfirmed ${collection}`);
|
||||
for (const o of this[`${collection}Unconfirmed`]) {
|
||||
if (isEmpty(o.id)) return;
|
||||
const data = yield this.kredits[objectClass].getById(o.id);
|
||||
const object = this[`load${objectClass}FromData`](data);
|
||||
yield this.browserCache[collection]
|
||||
.setItem(o.id.toString(), object.serialize());
|
||||
}
|
||||
} else {
|
||||
console.debug(`[kredits] No unconfirmed ${collection} to sync`);
|
||||
}
|
||||
}),
|
||||
|
||||
vetoAgainstObject (objectClass, objectId) {
|
||||
console.debug(`[kredits] veto against ${objectClass.toLowerCase()}`, objectId);
|
||||
const collection = objectClass.toLowerCase()+'s';
|
||||
const object = this[collection].findBy('id', objectId);
|
||||
|
||||
return this.kredits[objectClass].functions.veto(objectId, { gasLimit: 300000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] veto response', data);
|
||||
object.set('pendingTx', data);
|
||||
return data;
|
||||
});
|
||||
},
|
||||
|
||||
//
|
||||
// Reimbursements
|
||||
//
|
||||
|
||||
loadReimbursementFromData(data) {
|
||||
const obj = Reimbursement.create(processReimbursementData(data));
|
||||
obj.set('contributor', this.contributors.findBy('id', data.recipientId));
|
||||
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
|
||||
this.reimbursements.pushObject(obj);
|
||||
return obj;
|
||||
},
|
||||
|
||||
addReimbursement (attributes) {
|
||||
console.debug('[kredits] add reimbursement', attributes);
|
||||
|
||||
return this.kredits.Reimbursement.add(attributes, { gasLimit: 300000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] add reimbursement response', data);
|
||||
const reimbursement = Reimbursement.create(attributes);
|
||||
reimbursement.setProperties({
|
||||
contributor: this.contributors.findBy('id', attributes.recipientId),
|
||||
pendingTx: data,
|
||||
confirmedAt: this.currentBlock + 40320
|
||||
});
|
||||
this.reimbursements.pushObject(reimbursement);
|
||||
|
||||
// Listen to tx mining/execution status
|
||||
data.wait()
|
||||
.then(d => console.debug('[kredits] tx successful', d))
|
||||
.catch(e => {
|
||||
window.alert('The transaction failed to execute. Please check the browser console.');
|
||||
console.log('[kredits] tx error', e);
|
||||
});
|
||||
|
||||
return reimbursement;
|
||||
});
|
||||
},
|
||||
|
||||
syncReimbursements: task(function * () {
|
||||
yield this.fetchNewObjects.perform('Reimbursement');
|
||||
yield this.syncUnconfirmedObjects.perform('Reimbursement');
|
||||
this.set('reimbursementsNeedSync', false);
|
||||
}).group('syncTaskGroup'),
|
||||
|
||||
fetchMissingReimbursements: task(function * () {
|
||||
yield this.fetchMissingObjects.perform('Reimbursement');
|
||||
}).group('syncTaskGroup'),
|
||||
|
||||
vetoReimbursement (id) {
|
||||
console.debug('[kredits] veto against reimbursement', id);
|
||||
const reimbursement = this.reimbursements.findBy('id', id);
|
||||
|
||||
return this.kredits.Reimbursement.functions.veto(id, { gasLimit: 300000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] veto response', data);
|
||||
reimbursement.set('pendingTx', data);
|
||||
return data;
|
||||
});
|
||||
},
|
||||
|
||||
//
|
||||
// Contract events
|
||||
//
|
||||
|
||||
addContractEventHandlers () {
|
||||
this.kredits.Contributor
|
||||
.on('ContributorProfileUpdated', this.handleContributorChange.bind(this))
|
||||
.on('ContributorAccountUpdated', this.handleContributorChange.bind(this))
|
||||
.on('ContributorAdded', this.handleContributorChange.bind(this))
|
||||
|
||||
this.kredits.Contribution
|
||||
.on('ContributionAdded', this.handleContributionAdded.bind(this))
|
||||
.on('ContributionVetoed', this.handleContributionVetoed.bind(this))
|
||||
|
||||
this.kredits.Reimbursement
|
||||
.on('ReimbursementAdded', this.handleReimbursementAdded.bind(this))
|
||||
.on('ReimbursementVetoed', this.handleReimbursementVetoed.bind(this))
|
||||
addContractEventHandlers() {
|
||||
// Operator events
|
||||
this.kredits.Operator
|
||||
.on('ProposalCreated', this.handleProposalCreated.bind(this))
|
||||
.on('ProposalVoted', this.handleProposalVoted.bind(this))
|
||||
.on('ProposalExecuted', this.handleProposalExecuted.bind(this));
|
||||
|
||||
// Token events
|
||||
this.kredits.Token
|
||||
.on('Transfer', this.handleTransfer.bind(this));
|
||||
},
|
||||
|
||||
async handleContributorChange (contributorId, ...args) {
|
||||
console.debug('[kredits] Contributor add/update event received for ID', contributorId);
|
||||
console.debug('[kredits] Event data:', args);
|
||||
const contributorData = await this.kredits.Contributor.getById(contributorId);
|
||||
const newContributor = Contributor.create(contributorData);
|
||||
handleProposalCreated(proposalId) {
|
||||
let proposal = this.findProposalById(proposalId);
|
||||
|
||||
// TODO check for actual differences in the contributor data first
|
||||
|
||||
const oldContributor = this.contributors.findBy('id', contributorId);
|
||||
if (oldContributor) {
|
||||
// console.debug('[kredits] cached contributor', oldContributor);
|
||||
this.contributors.removeObject(oldContributor);
|
||||
if (proposal) {
|
||||
console.debug('[events] proposal exists, not adding from event');
|
||||
return;
|
||||
}
|
||||
|
||||
// console.debug('[kredits] incoming contributor data', newContributor);
|
||||
this.contributors.pushObject(newContributor);
|
||||
this.kredits.Operator.getById(proposalId)
|
||||
.then((proposal) => {
|
||||
proposal.contributor = this.contributors.findBy('id', proposal.contributorId.toString());
|
||||
this.proposals.pushObject(Proposal.create(proposal));
|
||||
});
|
||||
},
|
||||
|
||||
async handleContributionAdded (id, contributorId, amount) {
|
||||
console.debug('[kredits] ContributionAdded event received', { id, contributorId, amount });
|
||||
// TODO: We may want to reload that proposal to show the voter as voted
|
||||
handleProposalVoted(proposalId, voterId, totalVotes) {
|
||||
let proposal = this.findProposalById(proposalId);
|
||||
|
||||
const pendingContribution = this.contributions.find(c => {
|
||||
return (c.id === null) &&
|
||||
(c.contributorId === contributorId) &&
|
||||
(c.amount.toString() === amount.toString());
|
||||
});
|
||||
|
||||
if (pendingContribution) {
|
||||
this.contributions.removeObject(pendingContribution);
|
||||
}
|
||||
|
||||
const data = await this.kredits.Contribution.getById(id);
|
||||
const c = this.loadContributionFromData(data);
|
||||
await this.browserCache.contributions.setItem(c.id.toString(), c.serialize());
|
||||
},
|
||||
|
||||
async handleContributionVetoed (contributionId) {
|
||||
console.debug('[kredits] ContributionVetoed event received for #', contributionId);
|
||||
const c = this.contributions.findBy('id', contributionId);
|
||||
|
||||
if (c) {
|
||||
console.debug('[kredits] Updating contribution', c);
|
||||
c.set('vetoed', true);
|
||||
c.set('pendingTx', null);
|
||||
await this.browserCache.contributions.setItem(c.id.toString(), c.serialize());
|
||||
if (proposal) {
|
||||
proposal.set('votesCount', totalVotes);
|
||||
}
|
||||
},
|
||||
|
||||
async handleReimbursementAdded (id, addedByAccount, amount) {
|
||||
console.debug('[kredits] ReimbursementAdded event received', { id, addedByAccount, amount });
|
||||
handleProposalExecuted(proposalId, contributorId, amount) {
|
||||
let proposal = this.findProposalById(proposalId);
|
||||
|
||||
const pendingReimbursement = this.reimbursementsPending.find(r => {
|
||||
return r.amount.toString() === amount.toString();
|
||||
});
|
||||
|
||||
if (pendingReimbursement) {
|
||||
console.debug('[kredits] Found a pending reimbursement matching the event. Replacing it with the final record...');
|
||||
this.reimbursements.removeObject(pendingReimbursement);
|
||||
if (proposal.get('isExecuted')) {
|
||||
console.debug('[events] proposal already executed, not adding from event');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await this.kredits.Reimbursement.getById(id);
|
||||
const r = this.loadReimbursementFromData(data);
|
||||
this.browserCache.reimbursements.setItem(r.id.toString(), r.serialize());
|
||||
proposal.set('executed', true);
|
||||
|
||||
this.contributors
|
||||
.findBy('id', contributorId.toString())
|
||||
.incrementProperty('balance', amount.toNumber());
|
||||
},
|
||||
|
||||
async handleReimbursementVetoed (id) {
|
||||
console.debug(`[kredits] ReimbursementVetoed received for #${id}`);
|
||||
const r = this.reimbursements.findBy('id', id);
|
||||
console.debug('[kredits] reimbursement', r);
|
||||
|
||||
if (r) {
|
||||
r.set('vetoed', true);
|
||||
r.set('pendingTx', null);
|
||||
this.browserCache.reimbursements.setItem(r.id.toString(), r.serialize());
|
||||
}
|
||||
},
|
||||
|
||||
handleTransfer (from, to, value) {
|
||||
handleTransfer(from, to, value) {
|
||||
value = value.toNumber();
|
||||
|
||||
this.contributors
|
||||
@@ -780,5 +227,5 @@ export default Service.extend({
|
||||
this.contributors
|
||||
.findBy('address', to)
|
||||
.incrementProperty('balance', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
button, input[type=submit], .button {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 2rem;
|
||||
background-color: rgba(22, 21, 40, 0.6);
|
||||
border: 1px solid rgba(22, 21, 40, 1);
|
||||
border-radius: 3px;
|
||||
color: $primary-color;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.1em;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&+button, &+input[type=submit], &+.button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(22, 21, 40, 0.8);
|
||||
}
|
||||
|
||||
&:focus, &:active, &.active {
|
||||
border-color: $primary-color;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
&.small {
|
||||
font-size: 0.86rem;
|
||||
padding: 0.2rem 0.8rem;
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger:not(:disabled) {
|
||||
color: $red;
|
||||
background-color: rgba(40, 21, 21, 0.6);
|
||||
border-color: rgba(40, 21, 21, 1);
|
||||
&:hover { background-color: rgba(40, 21, 21, 0.8); }
|
||||
&:focus, &:active, &.active { border-color: $red; }
|
||||
}
|
||||
|
||||
&.green:not(:disabled) {
|
||||
color: $green;
|
||||
background-color: rgba(21, 40, 21, 0.6);
|
||||
border-color: rgba(21, 40, 21, 1);
|
||||
&:hover { background-color: rgba(21, 40, 21, 0.8); }
|
||||
&:focus, &:active, &.active { border-color: $green; }
|
||||
}
|
||||
|
||||
&.pink:not(:disabled) {
|
||||
color: $pink;
|
||||
background-color: rgba(40, 21, 40, 0.6);
|
||||
border-color: rgba(40, 21, 40, 1);
|
||||
&:hover { background-color: rgba(40, 21, 40, 0.8); }
|
||||
&:focus, &:active, &.active { border-color: $pink; }
|
||||
}
|
||||
|
||||
&.purple:not(:disabled) {
|
||||
color: $purple;
|
||||
background-color: rgba(24, 21, 40, 0.6);
|
||||
border-color: rgba(24, 21, 40, 1);
|
||||
&:hover { background-color: rgba(24, 21, 40, 0.8); }
|
||||
&:focus, &:active, &.active { border-color: $purple; }
|
||||
}
|
||||
|
||||
&.yellow:not(:disabled) {
|
||||
color: $yellow;
|
||||
background-color: rgba(40, 40, 21, 0.6);
|
||||
border-color: rgba(40, 40, 21, 1);
|
||||
&:hover { background-color: rgba(40, 40, 21, 0.8); }
|
||||
&:focus, &:active, &.active { border-color: $yellow; }
|
||||
}
|
||||
|
||||
&.icon {
|
||||
svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
vertical-align: middle;
|
||||
margin-right: 1rem;
|
||||
|
||||
.fg {
|
||||
fill: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -9,6 +9,13 @@ $primary-color: $blue;
|
||||
|
||||
$body-text-color: #fff;
|
||||
|
||||
$item-background-color: rgba(255,255,255,0.1);
|
||||
$item-highlighted-background-color: rgba(255,255,255,0.2);
|
||||
$item-border-color: rgba(255,255,255,0.2);
|
||||
@mixin body-background {
|
||||
background-image: linear-gradient(to bottom, rgba(22, 21, 40, .4), rgba(0, 0, 0, .75)), url('/img/bg.jpg');
|
||||
background-repeat: none;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
background-color: rgba(255, 0, 255, .2);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
ul.item-list {
|
||||
list-style: none;
|
||||
|
||||
> li {
|
||||
padding: 0.8rem 1.2rem;
|
||||
font-size: 1.2rem;
|
||||
background-color: $item-background-color;
|
||||
border-bottom: 1px solid $item-border-color;
|
||||
|
||||
&:first-of-type {
|
||||
border-top: 1px solid $item-border-color;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: $item-highlighted-background-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
@include loading-border-top;
|
||||
|
||||
> li {
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.spaced {
|
||||
> li {
|
||||
border-top: 1px solid $item-border-color;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-145
@@ -1,11 +1,11 @@
|
||||
#topbar {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1rem 2rem;
|
||||
padding: 1rem;
|
||||
|
||||
@include media-max(small) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&#dashboard {
|
||||
&#index {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-row-gap: 2rem;
|
||||
@@ -13,126 +13,16 @@ main {
|
||||
"stats"
|
||||
"contributions";
|
||||
}
|
||||
|
||||
&#budget {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-row-gap: 2rem;
|
||||
grid-template-areas:
|
||||
"aside"
|
||||
"content";
|
||||
}
|
||||
|
||||
&.center-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
section {
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div#content {
|
||||
section {
|
||||
&:first-of-type {
|
||||
@include media-max(small) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
.content {
|
||||
a {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
&.text-lg {
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Remove after switch to Tailwind CSS
|
||||
&.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
&.actions {
|
||||
text-align: center;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#intro {
|
||||
padding: 2rem;
|
||||
background-color: rgba(0,0,0,.2);
|
||||
font-size: 1.6rem;
|
||||
|
||||
@include media-max(small) {
|
||||
padding: 2rem 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: inherit;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.5em;
|
||||
font-size: 1.2rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover, &:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 550px) {
|
||||
main {
|
||||
&#dashboard {
|
||||
&#index {
|
||||
grid-column-gap: 4rem;
|
||||
grid-row-gap: 2rem;
|
||||
grid-template-columns: 2fr 4fr;
|
||||
grid-template-areas:
|
||||
"stats contributions";
|
||||
|
||||
&.with-details {
|
||||
grid-column-gap: 3rem;
|
||||
grid-template-columns: 2fr 4fr 2fr;
|
||||
grid-template-areas:
|
||||
"stats contributions details";
|
||||
}
|
||||
}
|
||||
|
||||
&#budget {
|
||||
grid-column-gap: 3rem;
|
||||
grid-template-columns: 2fr 4fr 2fr;
|
||||
grid-template-areas:
|
||||
"aside content empty";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,39 +34,20 @@ main section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@include media-max(small) {
|
||||
@include media($mobile) {
|
||||
margin-bottom: 5rem;
|
||||
|
||||
&#proposals-open, &#proposals-closed {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
text-align: center;
|
||||
|
||||
&.with-nav {
|
||||
display: grid;
|
||||
grid-template-columns: auto 5rem;
|
||||
grid-template-areas:
|
||||
"title" "actions";
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: inline-block;
|
||||
// padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@include media-max(small) {
|
||||
margin-bottom: 2rem;
|
||||
@include media($mobile) {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Remove after switch to Tailwind CSS
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
$breakpoints-max: (
|
||||
small: 600px,
|
||||
medium: 960px,
|
||||
large: 1280px
|
||||
);
|
||||
|
||||
$breakpoints-min: (
|
||||
small: 601px,
|
||||
medium: 961px,
|
||||
large: 1281px
|
||||
);
|
||||
|
||||
@mixin media-max($screen-size) {
|
||||
@if map-has-key($breakpoints-max, $screen-size) {
|
||||
@media (max-width: map-get($breakpoints-max, $screen-size)) {
|
||||
@content;
|
||||
}
|
||||
} @else {
|
||||
// Debugging
|
||||
@warn "'#{$screen-size}' has not been declared as a breakpoint."
|
||||
}
|
||||
}
|
||||
|
||||
@mixin media-min($screen-size) {
|
||||
@if map-has-key($breakpoints-min, $screen-size) {
|
||||
@media (min-width: map-get($breakpoints-min, $screen-size)) {
|
||||
@content;
|
||||
}
|
||||
} @else {
|
||||
// Debugging
|
||||
@warn "'#{$screen-size}' has not been declared as a breakpoint."
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
@mixin loading-border-top {
|
||||
&::before {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.2) 40%, #68d7fb 60%, rgba(255, 255, 255, 0.2));
|
||||
background-size: 200% 200%;
|
||||
animation: kitt 2.5s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kitt {
|
||||
0%{ background-position: 0% 0%}
|
||||
50%{ background-position: 100% 0%}
|
||||
100%{ background-position: 0% 0%}
|
||||
}
|
||||
+50
-33
@@ -2,6 +2,7 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
*:hover, *:active, *:focus {
|
||||
@@ -10,25 +11,22 @@
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@import "mediaqueries";
|
||||
@import "bourbon/bourbon";
|
||||
@import "settings/neat";
|
||||
@import "neat/neat";
|
||||
@import "settings/breakpoints";
|
||||
@import "layout";
|
||||
@import "colors";
|
||||
|
||||
$font-family-sans: 'Open Sans', sans-serif;
|
||||
|
||||
html, body {
|
||||
body {
|
||||
@include body-background;
|
||||
font-family: $font-family-sans;
|
||||
font-weight: 300;
|
||||
font-size: 12px;
|
||||
color: $body-text-color;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image: linear-gradient(to bottom, rgba(22, 21, 40, .4), rgba(0, 0, 0, .75)), url('/img/bg.jpg');
|
||||
background-repeat: none;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 900px) {
|
||||
body {
|
||||
background-size: cover;
|
||||
@@ -41,26 +39,25 @@ h1, h2, h3, h4, h5, input, button {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
section {
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.4rem;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
&#people {
|
||||
.content {
|
||||
p.stats {
|
||||
margin-bottom: 1rem;
|
||||
padding-top: 3rem;
|
||||
font-size: 1rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
span.number {
|
||||
font-weight: 600;
|
||||
}
|
||||
@include media($mobile) {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,14 +67,46 @@ section {
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@include media-max(small) {
|
||||
width: 75%;
|
||||
@include media($mobile) {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
&#proposals-open, &#proposals-closed {
|
||||
.actions {
|
||||
padding-top: 3rem;
|
||||
font-size: 1rem;
|
||||
color: $primary-color;
|
||||
text-align: center;
|
||||
@include media($mobile) {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button, input[type=submit] {
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(22, 21, 40, 1);
|
||||
background-color: rgba(22, 21, 40, 0.6);
|
||||
color: $primary-color;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.1em;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(22, 21, 40, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 550px) {
|
||||
section {
|
||||
h2 {
|
||||
@@ -86,20 +115,8 @@ section {
|
||||
}
|
||||
}
|
||||
|
||||
@import "buttons";
|
||||
@import "forms";
|
||||
@import "sugar";
|
||||
@import "item-list";
|
||||
|
||||
@import "components/budget-balances";
|
||||
@import "components/contribution-details";
|
||||
@import "components/contribution-list";
|
||||
@import "components/contributor-list";
|
||||
@import "components/contributor-profile";
|
||||
@import "components/expense-list";
|
||||
@import "components/external-account-link";
|
||||
@import "components/loading-spinner";
|
||||
@import "components/reimbursement-list";
|
||||
@import "components/topbar";
|
||||
@import "components/topbar-account-panel";
|
||||
@import "components/user-avatar";
|
||||
@import "components/loading-spinner";
|
||||
@import "components/contributor-list";
|
||||
@import "components/add-contributor";
|
||||
@import "components/proposal-list";
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/// Throws Sass warnings to announce library deprecations. You can disable them
|
||||
/// by setting the `$output-bourbon-deprecation-warnings` variable to `false`.
|
||||
///
|
||||
/// @access private
|
||||
|
||||
@mixin _bourbon-deprecate($feature, $message: null) {
|
||||
@if $output-bourbon-deprecation-warnings == true {
|
||||
@warn "[Bourbon] [Deprecation] `#{$feature}` is deprecated and will be " +
|
||||
"removed in 5.0.0. #{$message}";
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin _bourbon-deprecate-for-prefixing($feature) {
|
||||
@include _bourbon-deprecate($feature, "We suggest using an automated " +
|
||||
"prefixing tool, like Autoprefixer.");
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// The following features have been deprecated and will be removed in the next MAJOR version release
|
||||
|
||||
@mixin inline-block {
|
||||
@include _bourbon-deprecate("inline-block");
|
||||
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@mixin button ($style: simple, $base-color: #4294f0, $text-size: inherit, $padding: 7px 18px) {
|
||||
@include _bourbon-deprecate("button");
|
||||
|
||||
@if type-of($style) == string and type-of($base-color) == color {
|
||||
@include buttonstyle($style, $base-color, $text-size, $padding);
|
||||
}
|
||||
|
||||
@if type-of($style) == string and type-of($base-color) == number {
|
||||
$padding: $text-size;
|
||||
$text-size: $base-color;
|
||||
$base-color: #4294f0;
|
||||
|
||||
@if $padding == inherit {
|
||||
$padding: 7px 18px;
|
||||
}
|
||||
|
||||
@include buttonstyle($style, $base-color, $text-size, $padding);
|
||||
}
|
||||
|
||||
@if type-of($style) == color and type-of($base-color) == color {
|
||||
$base-color: $style;
|
||||
$style: simple;
|
||||
@include buttonstyle($style, $base-color, $text-size, $padding);
|
||||
}
|
||||
|
||||
@if type-of($style) == color and type-of($base-color) == number {
|
||||
$padding: $text-size;
|
||||
$text-size: $base-color;
|
||||
$base-color: $style;
|
||||
$style: simple;
|
||||
|
||||
@if $padding == inherit {
|
||||
$padding: 7px 18px;
|
||||
}
|
||||
|
||||
@include buttonstyle($style, $base-color, $text-size, $padding);
|
||||
}
|
||||
|
||||
@if type-of($style) == number {
|
||||
$padding: $base-color;
|
||||
$text-size: $style;
|
||||
$base-color: #4294f0;
|
||||
$style: simple;
|
||||
|
||||
@if $padding == #4294f0 {
|
||||
$padding: 7px 18px;
|
||||
}
|
||||
|
||||
@include buttonstyle($style, $base-color, $text-size, $padding);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Selector Style Button
|
||||
@mixin buttonstyle($type, $b-color, $t-size, $pad) {
|
||||
// Grayscale button
|
||||
@if $type == simple and $b-color == grayscale($b-color) {
|
||||
@include simple($b-color, true, $t-size, $pad);
|
||||
}
|
||||
|
||||
@if $type == shiny and $b-color == grayscale($b-color) {
|
||||
@include shiny($b-color, true, $t-size, $pad);
|
||||
}
|
||||
|
||||
@if $type == pill and $b-color == grayscale($b-color) {
|
||||
@include pill($b-color, true, $t-size, $pad);
|
||||
}
|
||||
|
||||
@if $type == flat and $b-color == grayscale($b-color) {
|
||||
@include flat($b-color, true, $t-size, $pad);
|
||||
}
|
||||
|
||||
// Colored button
|
||||
@if $type == simple {
|
||||
@include simple($b-color, false, $t-size, $pad);
|
||||
}
|
||||
|
||||
@else if $type == shiny {
|
||||
@include shiny($b-color, false, $t-size, $pad);
|
||||
}
|
||||
|
||||
@else if $type == pill {
|
||||
@include pill($b-color, false, $t-size, $pad);
|
||||
}
|
||||
|
||||
@else if $type == flat {
|
||||
@include flat($b-color, false, $t-size, $pad);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple Button
|
||||
@mixin simple($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
|
||||
$color: hsl(0, 0, 100%);
|
||||
$border: adjust-color($base-color, $saturation: 9%, $lightness: -14%);
|
||||
$inset-shadow: adjust-color($base-color, $saturation: -8%, $lightness: 15%);
|
||||
$stop-gradient: adjust-color($base-color, $saturation: 9%, $lightness: -11%);
|
||||
$text-shadow: adjust-color($base-color, $saturation: 15%, $lightness: -18%);
|
||||
|
||||
@if is-light($base-color) {
|
||||
$color: hsl(0, 0, 20%);
|
||||
$text-shadow: adjust-color($base-color, $saturation: 10%, $lightness: 4%);
|
||||
}
|
||||
|
||||
@if $grayscale == true {
|
||||
$border: grayscale($border);
|
||||
$inset-shadow: grayscale($inset-shadow);
|
||||
$stop-gradient: grayscale($stop-gradient);
|
||||
$text-shadow: grayscale($text-shadow);
|
||||
}
|
||||
|
||||
border: 1px solid $border;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 1px 0 0 $inset-shadow;
|
||||
color: $color;
|
||||
display: inline-block;
|
||||
font-size: $textsize;
|
||||
font-weight: bold;
|
||||
@include linear-gradient ($base-color, $stop-gradient);
|
||||
padding: $padding;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 0 $text-shadow;
|
||||
background-clip: padding-box;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
$base-color-hover: adjust-color($base-color, $saturation: -4%, $lightness: -5%);
|
||||
$inset-shadow-hover: adjust-color($base-color, $saturation: -7%, $lightness: 5%);
|
||||
$stop-gradient-hover: adjust-color($base-color, $saturation: 8%, $lightness: -14%);
|
||||
|
||||
@if $grayscale == true {
|
||||
$base-color-hover: grayscale($base-color-hover);
|
||||
$inset-shadow-hover: grayscale($inset-shadow-hover);
|
||||
$stop-gradient-hover: grayscale($stop-gradient-hover);
|
||||
}
|
||||
|
||||
@include linear-gradient ($base-color-hover, $stop-gradient-hover);
|
||||
|
||||
box-shadow: inset 0 1px 0 0 $inset-shadow-hover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active:not(:disabled),
|
||||
&:focus:not(:disabled) {
|
||||
$border-active: adjust-color($base-color, $saturation: 9%, $lightness: -14%);
|
||||
$inset-shadow-active: adjust-color($base-color, $saturation: 7%, $lightness: -17%);
|
||||
|
||||
@if $grayscale == true {
|
||||
$border-active: grayscale($border-active);
|
||||
$inset-shadow-active: grayscale($inset-shadow-active);
|
||||
}
|
||||
|
||||
border: 1px solid $border-active;
|
||||
box-shadow: inset 0 0 8px 4px $inset-shadow-active, inset 0 0 8px 4px $inset-shadow-active;
|
||||
}
|
||||
}
|
||||
|
||||
// Shiny Button
|
||||
@mixin shiny($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
|
||||
$color: hsl(0, 0, 100%);
|
||||
$border: adjust-color($base-color, $red: -117, $green: -111, $blue: -81);
|
||||
$border-bottom: adjust-color($base-color, $red: -126, $green: -127, $blue: -122);
|
||||
$fourth-stop: adjust-color($base-color, $red: -79, $green: -70, $blue: -46);
|
||||
$inset-shadow: adjust-color($base-color, $red: 37, $green: 29, $blue: 12);
|
||||
$second-stop: adjust-color($base-color, $red: -56, $green: -50, $blue: -33);
|
||||
$text-shadow: adjust-color($base-color, $red: -140, $green: -141, $blue: -114);
|
||||
$third-stop: adjust-color($base-color, $red: -86, $green: -75, $blue: -48);
|
||||
|
||||
@if is-light($base-color) {
|
||||
$color: hsl(0, 0, 20%);
|
||||
$text-shadow: adjust-color($base-color, $saturation: 10%, $lightness: 4%);
|
||||
}
|
||||
|
||||
@if $grayscale == true {
|
||||
$border: grayscale($border);
|
||||
$border-bottom: grayscale($border-bottom);
|
||||
$fourth-stop: grayscale($fourth-stop);
|
||||
$inset-shadow: grayscale($inset-shadow);
|
||||
$second-stop: grayscale($second-stop);
|
||||
$text-shadow: grayscale($text-shadow);
|
||||
$third-stop: grayscale($third-stop);
|
||||
}
|
||||
|
||||
@include linear-gradient(top, $base-color 0%, $second-stop 50%, $third-stop 50%, $fourth-stop 100%);
|
||||
|
||||
border: 1px solid $border;
|
||||
border-bottom: 1px solid $border-bottom;
|
||||
border-radius: 5px;
|
||||
box-shadow: inset 0 1px 0 0 $inset-shadow;
|
||||
color: $color;
|
||||
display: inline-block;
|
||||
font-size: $textsize;
|
||||
font-weight: bold;
|
||||
padding: $padding;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 -1px 1px $text-shadow;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
$first-stop-hover: adjust-color($base-color, $red: -13, $green: -15, $blue: -18);
|
||||
$second-stop-hover: adjust-color($base-color, $red: -66, $green: -62, $blue: -51);
|
||||
$third-stop-hover: adjust-color($base-color, $red: -93, $green: -85, $blue: -66);
|
||||
$fourth-stop-hover: adjust-color($base-color, $red: -86, $green: -80, $blue: -63);
|
||||
|
||||
@if $grayscale == true {
|
||||
$first-stop-hover: grayscale($first-stop-hover);
|
||||
$second-stop-hover: grayscale($second-stop-hover);
|
||||
$third-stop-hover: grayscale($third-stop-hover);
|
||||
$fourth-stop-hover: grayscale($fourth-stop-hover);
|
||||
}
|
||||
|
||||
@include linear-gradient(top, $first-stop-hover 0%,
|
||||
$second-stop-hover 50%,
|
||||
$third-stop-hover 50%,
|
||||
$fourth-stop-hover 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active:not(:disabled),
|
||||
&:focus:not(:disabled) {
|
||||
$inset-shadow-active: adjust-color($base-color, $red: -111, $green: -116, $blue: -122);
|
||||
|
||||
@if $grayscale == true {
|
||||
$inset-shadow-active: grayscale($inset-shadow-active);
|
||||
}
|
||||
|
||||
box-shadow: inset 0 0 20px 0 $inset-shadow-active;
|
||||
}
|
||||
}
|
||||
|
||||
// Pill Button
|
||||
@mixin pill($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
|
||||
$color: hsl(0, 0, 100%);
|
||||
$border-bottom: adjust-color($base-color, $hue: 8, $saturation: -11%, $lightness: -26%);
|
||||
$border-sides: adjust-color($base-color, $hue: 4, $saturation: -21%, $lightness: -21%);
|
||||
$border-top: adjust-color($base-color, $hue: -1, $saturation: -30%, $lightness: -15%);
|
||||
$inset-shadow: adjust-color($base-color, $hue: -1, $saturation: -1%, $lightness: 7%);
|
||||
$stop-gradient: adjust-color($base-color, $hue: 8, $saturation: 14%, $lightness: -10%);
|
||||
$text-shadow: adjust-color($base-color, $hue: 5, $saturation: -19%, $lightness: -15%);
|
||||
|
||||
@if is-light($base-color) {
|
||||
$color: hsl(0, 0, 20%);
|
||||
$text-shadow: adjust-color($base-color, $saturation: 10%, $lightness: 4%);
|
||||
}
|
||||
|
||||
@if $grayscale == true {
|
||||
$border-bottom: grayscale($border-bottom);
|
||||
$border-sides: grayscale($border-sides);
|
||||
$border-top: grayscale($border-top);
|
||||
$inset-shadow: grayscale($inset-shadow);
|
||||
$stop-gradient: grayscale($stop-gradient);
|
||||
$text-shadow: grayscale($text-shadow);
|
||||
}
|
||||
|
||||
border: 1px solid $border-top;
|
||||
border-color: $border-top $border-sides $border-bottom;
|
||||
border-radius: 16px;
|
||||
box-shadow: inset 0 1px 0 0 $inset-shadow;
|
||||
color: $color;
|
||||
display: inline-block;
|
||||
font-size: $textsize;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
@include linear-gradient ($base-color, $stop-gradient);
|
||||
padding: $padding;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 -1px 1px $text-shadow;
|
||||
background-clip: padding-box;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
$base-color-hover: adjust-color($base-color, $lightness: -4.5%);
|
||||
$border-bottom: adjust-color($base-color, $hue: 8, $saturation: 13.5%, $lightness: -32%);
|
||||
$border-sides: adjust-color($base-color, $hue: 4, $saturation: -2%, $lightness: -27%);
|
||||
$border-top: adjust-color($base-color, $hue: -1, $saturation: -17%, $lightness: -21%);
|
||||
$inset-shadow-hover: adjust-color($base-color, $saturation: -1%, $lightness: 3%);
|
||||
$stop-gradient-hover: adjust-color($base-color, $hue: 8, $saturation: -4%, $lightness: -15.5%);
|
||||
$text-shadow-hover: adjust-color($base-color, $hue: 5, $saturation: -5%, $lightness: -22%);
|
||||
|
||||
@if $grayscale == true {
|
||||
$base-color-hover: grayscale($base-color-hover);
|
||||
$border-bottom: grayscale($border-bottom);
|
||||
$border-sides: grayscale($border-sides);
|
||||
$border-top: grayscale($border-top);
|
||||
$inset-shadow-hover: grayscale($inset-shadow-hover);
|
||||
$stop-gradient-hover: grayscale($stop-gradient-hover);
|
||||
$text-shadow-hover: grayscale($text-shadow-hover);
|
||||
}
|
||||
|
||||
@include linear-gradient ($base-color-hover, $stop-gradient-hover);
|
||||
|
||||
background-clip: padding-box;
|
||||
border: 1px solid $border-top;
|
||||
border-color: $border-top $border-sides $border-bottom;
|
||||
box-shadow: inset 0 1px 0 0 $inset-shadow-hover;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 -1px 1px $text-shadow-hover;
|
||||
}
|
||||
|
||||
&:active:not(:disabled),
|
||||
&:focus:not(:disabled) {
|
||||
$active-color: adjust-color($base-color, $hue: 4, $saturation: -12%, $lightness: -10%);
|
||||
$border-active: adjust-color($base-color, $hue: 6, $saturation: -2.5%, $lightness: -30%);
|
||||
$border-bottom-active: adjust-color($base-color, $hue: 11, $saturation: 6%, $lightness: -31%);
|
||||
$inset-shadow-active: adjust-color($base-color, $hue: 9, $saturation: 2%, $lightness: -21.5%);
|
||||
$text-shadow-active: adjust-color($base-color, $hue: 5, $saturation: -12%, $lightness: -21.5%);
|
||||
|
||||
@if $grayscale == true {
|
||||
$active-color: grayscale($active-color);
|
||||
$border-active: grayscale($border-active);
|
||||
$border-bottom-active: grayscale($border-bottom-active);
|
||||
$inset-shadow-active: grayscale($inset-shadow-active);
|
||||
$text-shadow-active: grayscale($text-shadow-active);
|
||||
}
|
||||
|
||||
background: $active-color;
|
||||
border: 1px solid $border-active;
|
||||
border-bottom: 1px solid $border-bottom-active;
|
||||
box-shadow: inset 0 0 6px 3px $inset-shadow-active;
|
||||
text-shadow: 0 -1px 1px $text-shadow-active;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat Button
|
||||
@mixin flat($base-color, $grayscale: false, $textsize: inherit, $padding: 7px 18px) {
|
||||
$color: hsl(0, 0, 100%);
|
||||
|
||||
@if is-light($base-color) {
|
||||
$color: hsl(0, 0, 20%);
|
||||
}
|
||||
|
||||
background-color: $base-color;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
color: $color;
|
||||
display: inline-block;
|
||||
font-size: $textsize;
|
||||
font-weight: bold;
|
||||
padding: $padding;
|
||||
text-decoration: none;
|
||||
background-clip: padding-box;
|
||||
|
||||
&:hover:not(:disabled){
|
||||
$base-color-hover: adjust-color($base-color, $saturation: 4%, $lightness: 5%);
|
||||
|
||||
@if $grayscale == true {
|
||||
$base-color-hover: grayscale($base-color-hover);
|
||||
}
|
||||
|
||||
background-color: $base-color-hover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active:not(:disabled),
|
||||
&:focus:not(:disabled) {
|
||||
$base-color-active: adjust-color($base-color, $saturation: -4%, $lightness: -5%);
|
||||
|
||||
@if $grayscale == true {
|
||||
$base-color-active: grayscale($base-color-active);
|
||||
}
|
||||
|
||||
background-color: $base-color-active;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Flexible grid
|
||||
@function flex-grid($columns, $container-columns: $fg-max-columns) {
|
||||
@if $output-bourbon-deprecation-warnings == true {
|
||||
@warn "[Bourbon] [Deprecation] `flex-grid` is deprecated and will be " +
|
||||
"removed in 5.0.0. For grid functions, check out Bourbon's sister library" +
|
||||
"Neat.";
|
||||
}
|
||||
|
||||
$width: $columns * $fg-column + ($columns - 1) * $fg-gutter;
|
||||
$container-width: $container-columns * $fg-column + ($container-columns - 1) * $fg-gutter;
|
||||
@return percentage($width / $container-width);
|
||||
}
|
||||
|
||||
// Flexible gutter
|
||||
@function flex-gutter($container-columns: $fg-max-columns, $gutter: $fg-gutter) {
|
||||
@if $output-bourbon-deprecation-warnings == true {
|
||||
@warn "[Bourbon] [Deprecation] `flex-gutter` is deprecated and will be " +
|
||||
"removed in 5.0.0. For grid functions, check out Bourbon's sister library" +
|
||||
"Neat.";
|
||||
}
|
||||
|
||||
$container-width: $container-columns * $fg-column + ($container-columns - 1) * $fg-gutter;
|
||||
@return percentage($gutter / $container-width);
|
||||
}
|
||||
|
||||
@function grid-width($n) {
|
||||
@if $output-bourbon-deprecation-warnings == true {
|
||||
@warn "[Bourbon] [Deprecation] `grid-width` is deprecated and will be " +
|
||||
"removed in 5.0.0. For grid functions, check out Bourbon's sister library" +
|
||||
"Neat.";
|
||||
}
|
||||
|
||||
@return $n * $gw-column + ($n - 1) * $gw-gutter;
|
||||
}
|
||||
|
||||
@function golden-ratio($value, $increment) {
|
||||
@if $output-bourbon-deprecation-warnings == true {
|
||||
@warn "[Bourbon] [Deprecation] `golden-ratio` is deprecated and will be " +
|
||||
"removed in 5.0.0. You can use the `modular-scale` function instead.";
|
||||
}
|
||||
|
||||
@return modular-scale($increment, $value, $ratio: $golden);
|
||||
}
|
||||
|
||||
@mixin box-sizing($box) {
|
||||
@include _bourbon-deprecate-for-prefixing("box-sizing");
|
||||
|
||||
@include prefixer(box-sizing, $box, webkit moz spec);
|
||||
}
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
// Bourbon 4.3.0
|
||||
// http://bourbon.io
|
||||
// Copyright 2011-2017 thoughtbot, inc.
|
||||
// MIT License
|
||||
|
||||
@import "settings/deprecation-warnings";
|
||||
@import "settings/prefixer";
|
||||
@import "settings/px-to-em";
|
||||
@import "settings/asset-pipeline";
|
||||
|
||||
@import "bourbon-deprecate";
|
||||
|
||||
@import "functions/assign-inputs";
|
||||
@import "functions/contains";
|
||||
@import "functions/contains-falsy";
|
||||
@import "functions/is-length";
|
||||
@import "functions/is-light";
|
||||
@import "functions/is-number";
|
||||
@import "functions/is-size";
|
||||
@import "functions/px-to-em";
|
||||
@import "functions/px-to-rem";
|
||||
@import "functions/shade";
|
||||
@import "functions/strip-units";
|
||||
@import "functions/tint";
|
||||
@import "functions/transition-property-name";
|
||||
@import "functions/unpack";
|
||||
@import "functions/modular-scale";
|
||||
|
||||
@import "helpers/convert-units";
|
||||
@import "helpers/directional-values";
|
||||
@import "helpers/font-source-declaration";
|
||||
@import "helpers/gradient-positions-parser";
|
||||
@import "helpers/linear-angle-parser";
|
||||
@import "helpers/linear-gradient-parser";
|
||||
@import "helpers/linear-positions-parser";
|
||||
@import "helpers/linear-side-corner-parser";
|
||||
@import "helpers/radial-arg-parser";
|
||||
@import "helpers/radial-positions-parser";
|
||||
@import "helpers/radial-gradient-parser";
|
||||
@import "helpers/render-gradients";
|
||||
@import "helpers/shape-size-stripper";
|
||||
@import "helpers/str-to-num";
|
||||
|
||||
@import "css3/animation";
|
||||
@import "css3/appearance";
|
||||
@import "css3/backface-visibility";
|
||||
@import "css3/background";
|
||||
@import "css3/background-image";
|
||||
@import "css3/border-image";
|
||||
@import "css3/calc";
|
||||
@import "css3/columns";
|
||||
@import "css3/filter";
|
||||
@import "css3/flex-box";
|
||||
@import "css3/font-face";
|
||||
@import "css3/font-feature-settings";
|
||||
@import "css3/hidpi-media-query";
|
||||
@import "css3/hyphens";
|
||||
@import "css3/image-rendering";
|
||||
@import "css3/keyframes";
|
||||
@import "css3/linear-gradient";
|
||||
@import "css3/perspective";
|
||||
@import "css3/placeholder";
|
||||
@import "css3/radial-gradient";
|
||||
@import "css3/selection";
|
||||
@import "css3/text-decoration";
|
||||
@import "css3/transform";
|
||||
@import "css3/transition";
|
||||
@import "css3/user-select";
|
||||
|
||||
@import "addons/border-color";
|
||||
@import "addons/border-radius";
|
||||
@import "addons/border-style";
|
||||
@import "addons/border-width";
|
||||
@import "addons/buttons";
|
||||
@import "addons/clearfix";
|
||||
@import "addons/ellipsis";
|
||||
@import "addons/font-stacks";
|
||||
@import "addons/hide-text";
|
||||
@import "addons/margin";
|
||||
@import "addons/padding";
|
||||
@import "addons/position";
|
||||
@import "addons/prefixer";
|
||||
@import "addons/retina-image";
|
||||
@import "addons/size";
|
||||
@import "addons/text-inputs";
|
||||
@import "addons/timing-functions";
|
||||
@import "addons/triangle";
|
||||
@import "addons/word-wrap";
|
||||
|
||||
@import "bourbon-deprecated-upcoming";
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/// Provides a quick method for targeting `border-color` on specific sides of a box. Use a `null` value to “skip” a side.
|
||||
///
|
||||
/// @param {Arglist} $vals
|
||||
/// List of arguments
|
||||
///
|
||||
/// @example scss - Usage
|
||||
/// .element {
|
||||
/// @include border-color(#a60b55 #76cd9c null #e8ae1a);
|
||||
/// }
|
||||
///
|
||||
/// @example css - CSS Output
|
||||
/// .element {
|
||||
/// border-left-color: #e8ae1a;
|
||||
/// border-right-color: #76cd9c;
|
||||
/// border-top-color: #a60b55;
|
||||
/// }
|
||||
///
|
||||
/// @require {mixin} directional-property
|
||||
///
|
||||
/// @output `border-color`
|
||||
|
||||
@mixin border-color($vals...) {
|
||||
@include directional-property(border, color, $vals...);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/// Provides a quick method for targeting `border-radius` on both corners on the side of a box.
|
||||
///
|
||||
/// @param {Number} $radii
|
||||
/// List of arguments
|
||||
///
|
||||
/// @example scss - Usage
|
||||
/// .element-one {
|
||||
/// @include border-top-radius(5px);
|
||||
/// }
|
||||
///
|
||||
/// .element-two {
|
||||
/// @include border-left-radius(3px);
|
||||
/// }
|
||||
///
|
||||
/// @example css - CSS Output
|
||||
/// .element-one {
|
||||
/// border-top-left-radius: 5px;
|
||||
/// border-top-right-radius: 5px;
|
||||
/// }
|
||||
///
|
||||
/// .element-two {
|
||||
/// border-bottom-left-radius: 3px;
|
||||
/// border-top-left-radius: 3px;
|
||||
/// }
|
||||
///
|
||||
/// @output `border-radius`
|
||||
|
||||
@mixin border-top-radius($radii) {
|
||||
border-top-left-radius: $radii;
|
||||
border-top-right-radius: $radii;
|
||||
}
|
||||
|
||||
@mixin border-right-radius($radii) {
|
||||
border-bottom-right-radius: $radii;
|
||||
border-top-right-radius: $radii;
|
||||
}
|
||||
|
||||
@mixin border-bottom-radius($radii) {
|
||||
border-bottom-left-radius: $radii;
|
||||
border-bottom-right-radius: $radii;
|
||||
}
|
||||
|
||||
@mixin border-left-radius($radii) {
|
||||
border-bottom-left-radius: $radii;
|
||||
border-top-left-radius: $radii;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user