Compare commits

...

163 Commits

Author SHA1 Message Date
Râu Cao 8cc3da40b7 2.0.0 2023-08-14 18:28:02 +02:00
Râu Cao bdeab512b6 Upgrade kredits-contracts 2023-08-14 17:57:04 +02:00
Râu Cao 97ebebe28c Merge pull request #214 from 67P/chore/remove_last_ethereum_bits
Remove mentions of Ethereum from UI and code, refactor wallet connect
2023-08-14 17:53:44 +02:00
Râu Cao d5edb93ec5 Use new hubot URL 2023-08-14 17:50:47 +02:00
Râu Cao 0ecc4079c2 Ember code updates 2023-08-14 17:50:10 +02:00
Râu Cao 3f080cf553 Open external link in new tab
closes #170
2023-02-02 12:32:50 +08:00
Râu Cao 0bdb4d2021 Connect wallet and fill in address during signup
In case there's a wallet available, ask the user to connect it and fill
in their account address in the form field.

Also, check the status code when submitting it to the back-end and show
an error message instead of the success/completion page in case the
contributor could not be created.
2023-01-21 12:39:14 +08:00
Râu Cao 1e7d8491f9 Refactor wallet connect, automatically propose network 2023-01-20 17:29:20 +08:00
Râu Cao 4356288497 Rename signup route, address property 2023-01-20 17:29:20 +08:00
Râu Cao 87bf8ff2df Inspect user txs on RSK explorer 2023-01-20 17:29:20 +08:00
Râu Cao 26975636a0 Update README 2023-01-20 17:29:17 +08:00
Râu Cao dad56a7b31 Merge pull request #212 from 67P/bugfix/load_contributor
Fix dashboard hanging when loading app from contributor URL
2023-01-20 17:29:05 +08:00
Râu Cao d069fa02ab Add links 2023-01-15 14:41:25 +08:00
Râu Cao d219abc358 RSK -> Rootstock 2023-01-15 14:39:54 +08:00
Râu Cao 13989bf973 Update README
refs #18
2023-01-15 14:38:43 +08:00
Râu Cao b3ab69d599 Update CI badge in README 2023-01-15 14:28:19 +08:00
Râu Cao fe3816501c Fix dashboard hanging when loading app from contributor URL
Loading the app from /dashboard/contributors/:id is failing, because the
contributors are loaded later. This fetches the requested contributor
separately when launching the app from a contributor profile URL.
2023-01-15 14:24:51 +08:00
Râu Cao 854402aa73 Merge pull request #213 from 67P/feature/github_actions_ci
Switch from Travis CI to GitHub Actions
2023-01-15 14:24:14 +08:00
Râu Cao abc19c715b Fix double builds in CI 2023-01-15 14:20:48 +08:00
Râu Cao b6434ac8d2 Remove Travis config 2023-01-15 14:17:30 +08:00
Râu Cao 4788b8c51c Add GitHub Actions CI config 2023-01-15 14:17:09 +08:00
Râu Cao 6a72a5b418 2.0.0-beta.3 2023-01-15 13:18:42 +08:00
Râu Cao d90ddd464a Hide confirmed-in content when confirmed 2023-01-15 13:16:38 +08:00
Râu Cao 2626543f3e Fix linter errors 2023-01-15 13:09:39 +08:00
Râu Cao 088db24534 Merge pull request #207 from 67P/feature/optional_full_sync
Make full data sync optional, start via button
2023-01-15 12:36:59 +08:00
Râu Cao f7b7daa024 Merge pull request #210 from 67P/feature/confirmation-eta
Add confirmed-in component, show ETA for proposed reimbursements
2023-01-15 12:36:43 +08:00
Râu Cao bb11e2267b Merge pull request #211 from 67P/feature/expense-list
Expense list UI improvements
2023-01-15 12:35:17 +08:00
Râu Cao 7cd023a21b Update app/components/add-reimbursement/template.hbs
Co-authored-by: Manuel Wiedenmann <manuel@funkensturm.de>
2023-01-15 12:34:40 +08:00
Râu Cao 1095bf0218 Add more button colors, icon style for small buttons
Plus tag icon for the tag buttons
2023-01-13 15:58:39 +08:00
Râu Cao 138cec0389 Localize date in expense list 2023-01-13 15:58:39 +08:00
Râu Cao a93be41e08 Add expense-list component, DRY up code, add tags 2023-01-13 15:58:39 +08:00
Râu Cao 89f6fa0b5c Use component argument in tests 2023-01-13 15:57:50 +08:00
Râu Cao e2a80eafd1 Remove dev logs 2023-01-13 14:14:24 +08:00
Râu Cao 662e76979b Show confirmation ETA for proposed reimbursements 2023-01-11 14:19:03 +08:00
Râu Cao 6c7de97e38 Get duration in human time 2023-01-11 14:10:35 +08:00
Râu Cao fe27b010da Add confirmed-in component 2023-01-11 13:59:41 +08:00
Râu Cao ff716c68ea Update current block when new one is mined 2023-01-11 13:26:58 +08:00
Râu Cao 84dded2f10 Do not automatically push new release versions 2023-01-11 12:44:56 +08:00
Râu Cao ee664c06c1 2.0.0-beta.2 2023-01-11 12:44:32 +08:00
Râu Cao 62b618b657 Add type to button element 2023-01-11 12:42:15 +08:00
Râu Cao 348dc03429 Merge pull request #209 from 67P/feature/veto_reimbursements
Finish Reimbursement UI (MVP)
2023-01-09 11:34:37 +08:00
Râu Cao d159fca816 Add todo note to async action 2023-01-09 11:31:31 +08:00
Râu Cao a582f41fde Cache reimbursement data on incoming events 2022-12-31 15:07:02 +07:00
Râu Cao 2082b51c5b Add expenses from file
Allow to upload a JSON file containing a list of expense items
2022-12-31 15:05:36 +07:00
Râu Cao e543708b42 Add IPFS inspect button to reimbursements 2022-12-31 12:20:59 +07:00
Râu Cao 0b0dea095f Reject empty tag strings 2022-12-31 12:20:27 +07:00
Râu Cao c7eb81450c Allow to veto reimbursements 2022-12-30 22:26:32 +07:00
Râu Cao ca1ccf6d93 Formatting 2022-12-30 22:25:50 +07:00
Râu Cao 0b79d48be9 Merge pull request #208 from 67P/bugfix/204-reimbursement_form
Fix timezone issue in expense item form
2022-12-30 22:24:43 +07:00
Râu Cao f8cc453d7e Fix timezone issue in expense item form
Dates being added with 00:00 time still carry the timezone set for the
OS/browser, and thus get changed to the previous day when in a positive
offset zone. This change removes the timezone offset entirely when
adding the line item, so that the chosen date is always assumed to be in
UTC time.

fixes #204
2022-12-30 22:20:16 +07:00
Râu Cao f86190030f Make full data sync optional, start via button 2022-11-14 22:58:15 +01:00
Râu Cao 08bedda288 2.0.0-beta.1 2022-11-14 15:56:57 +01:00
Râu Cao 7c8ca45b27 Merge pull request #205 from 67P/feature/improve_caching
Improve caching, fix Firefox private mode usage
2022-11-14 15:48:33 +01:00
Râu Cao ff3fe49dd0 Wording 2022-11-11 21:11:17 +01:00
Râu Cao 92056517a6 Cache contribution vetos
Fixes potentially not marking contributions as vetoed if there's no
incoming event after loading the app. Also improves performance either
way.
2022-11-11 21:11:17 +01:00
Râu Cao 11f19c8344 Refactor adding new contributions from events
Fixes contributions not being added when not already in memory (from
creation). Simplifies the entire function.
2022-11-11 21:11:13 +01:00
Râu Cao 0f8d7d84ca Don't cache contribution details in browser storage 2022-11-11 17:02:45 +01:00
Râu Cao e1780109aa Merge pull request #203 from 67P/feature/rsk-testnet
Migrate/switch to RSK testnet
2022-11-11 17:01:36 +01:00
Râu Cao 8c2f6aa38b Update Travis CI config 2022-11-11 16:57:51 +01:00
Râu Cao afbce03a4a Adapt test for rsk storage namespace 2022-11-11 16:38:39 +01:00
Râu Cao 1f2dd9d198 Use new @kredits/contracts 2022-11-03 16:57:55 +01:00
Râu Cao e4814923f9 Update contracts module branch 2022-10-31 12:48:49 +01:00
Râu Cao ad015f1cee Use required chain ID and network name
Use the chain ID when loading the web3 wallet/provider, and use the
network name for the localstorage namespace.
2022-10-24 16:45:02 +02:00
Râu Cao 0c5aebf7b4 WIP Migrate to RSK testnet 2022-10-24 16:30:38 +02:00
Râu Cao 52f7e86fc9 Merge pull request #202 from 67P/chore/fix_js_linting
Fix ESLint setup
2022-10-06 16:29:52 +02:00
Râu Cao fb66bd4f0d Fix some linter errors and warnings 2022-10-06 12:29:57 +02:00
Râu Cao 19553b9777 Fix eslint setup
babel-eslint isn't working with node 16 anymore

In addition to the new packages, also turn the remaining Ember errors
into warnings, so we can fix them without `npm test` failing in the
meantime.
2022-10-06 12:29:03 +02:00
Râu Cao 441dc21894 Merge pull request #201 from 67P/update-for-new-amounts
Fix for new Contribution#totalKreditsEarned type
2022-10-06 12:26:09 +02:00
bumi 0732911e13 Cleanup 2022-10-06 11:03:50 +02:00
bumi 57934ae7a3 Fix for new Contribution#totalKreditsEarned type
totalKreditsEarned is no longer a Big number but now already a number.
I think this is due to the change of the denomination.
2022-10-06 10:52:22 +02:00
basti 1e240b6539 Merge pull request #199 from 67P/chore/rename_recipient_for_reimbursements
Rename contributorId to recipientId
2022-06-12 07:17:47 +02:00
basti 2c773c147a Merge pull request #200 from 67P/feature/wbtc_to_btc
Switch from ETH/WBTC to BTC
2022-06-12 07:17:17 +02:00
basti e05449a7ff Show wallet balance for BTC instead of Gnosis Safe 2022-05-24 16:19:26 +02:00
basti 44b631c688 Switch from ETH/WBTC to RBTC/BTC 2022-05-22 17:04:28 +02:00
basti 6cd62d3764 Fix looking up contributor by ID string 2022-05-22 17:03:31 +02:00
basti 4c749bca35 Use integer for contributor ID everywhere
closes #145
2022-05-21 18:16:26 +02:00
basti 466354cff7 Update/improve README
Especially the local-contracts part.
2022-05-01 14:19:45 +02:00
basti 721c330f3c WIP Rename contributorId to recipientId 2022-04-27 18:17:33 +02:00
basti d07963ad6b Merge pull request #198 from 67P/chore/dependencies
Upgrade dependencies, fix contracts linking
2022-04-27 18:17:20 +02:00
basti eef9fa1aba Refactor kredits setup code a bit 2022-04-27 18:15:12 +02:00
basti 77dbde8bb4 Update dependencies, build configs 2022-04-27 18:14:56 +02:00
basti 64bd2477c7 Merge pull request #195 from 67P/feature/expenses
Introduce budget, reimbursements for expenses
2021-06-03 16:23:45 +02:00
basti aed29ff982 Remove obsolete property from class and template
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2020-11-17 13:40:44 +01:00
basti f3f158ff26 Update app/services/kredits.js
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2020-11-17 13:38:47 +01:00
basti 2a8fd3b001 Update app/components/topbar-account-panel/component.js
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2020-11-17 12:47:43 +01:00
basti 087cb706d2 Fix JS linting errors 2020-10-31 13:30:44 +01:00
basti b58870a03c Octanify add-reimbursement syntax
Fixes linter errors
2020-10-31 13:05:40 +01:00
basti ccda6ec354 Reset BTC amount to 0 when last expense item removed
Fix a logical bug that prevented setting the amount to 0 upon removing
the last item from the list/form.
2020-10-31 12:58:03 +01:00
basti 2045b94951 Allow HTML comments, remove obsolete comment 2020-10-31 12:49:29 +01:00
basti be16a57043 Octanify add-expense-item syntax
Fixes linter errors
2020-10-31 12:49:03 +01:00
basti ae06385039 Improve mobile layout of budget view a bit 2020-10-31 12:21:31 +01:00
basti 7b1c3c813c Finish reimbursement loading and sync 2020-10-30 15:26:32 +01:00
basti ec50e7deef Fix small layout issue with token amounts
Wasn't enough space for longer amounts
2020-10-30 15:03:54 +01:00
basti c3b1bd2f4c Make font sizes more consistent 2020-10-30 15:03:45 +01:00
basti 93e2a23ac7 WIP reimbursement sync 2020-10-09 15:40:46 +02:00
basti a41d1b1dbd Handle incoming and vetoed reimbursements 2020-10-09 15:09:35 +02:00
basti c0ccf83061 Don't allow form submission unless connected and core 2020-10-09 14:41:10 +02:00
basti 4ab5c3c372 Listen to tx status of submitted reimbursements 2020-10-09 14:29:53 +02:00
basti 5d4fb1c423 Fix reimbursement handling after creation 2020-10-01 17:23:22 +02:00
basti 361ccb103d Remove commented code 2020-10-01 12:58:07 +02:00
basti 2bf590886c Fix newly created contributions not showing
It's trying to use a non-existing block number to calculate
confirmedAtBlock, which then fails the filter to be shown.
2020-10-01 12:50:18 +02:00
basti c7b6f9e3e7 Submit reimbursements 2020-10-01 12:48:01 +02:00
basti c1eb93c596 Mark input fields as valid when re-submitting form 2020-09-30 16:57:10 +02:00
basti d85235ff88 Remove obsolete template code 2020-09-30 16:51:22 +02:00
basti 63675a1e13 Convert fiat totals to BTC and add to total amount 2020-09-30 16:46:35 +02:00
basti 87bc8f5cb5 Visualize disabled input fields by greying out text 2020-09-30 16:06:57 +02:00
basti 4abc1593cb Add total sums of EUR/USD expenses 2020-09-30 16:04:53 +02:00
basti 25b2a8b0e5 Create exchange rate service , use CORS proxy for requests 2020-09-30 15:03:55 +02:00
basti b5700092a8 Add util for fetching bitcoin exchange rate 2020-09-30 12:10:21 +02:00
basti 7288d75237 Don't load more reimbursements when already loaded 2020-09-30 10:32:36 +02:00
basti 444ec62a66 Validate expense/reimbursement forms
Adds some general helpers and styles for minimalistic form validation.
2020-09-29 19:31:58 +02:00
basti 326d46b1a1 Validate total amount 2020-09-29 18:09:48 +02:00
basti 083c873631 Scroll to current item; remove some obsolete code 2020-09-29 17:59:00 +02:00
basti 37dc91da57 Clearly highlight focused buttons 2020-09-29 17:59:00 +02:00
basti 7c86edcaaa Remove expense item from list 2020-09-29 17:59:00 +02:00
basti e4d2fdfce4 WIP Reimbursements/expenses 2020-09-29 17:58:38 +02:00
basti 4722064337 WIP expense items 2020-09-28 11:46:55 +02:00
basti 9f35add07f WIP Reimbursement UI 2020-09-10 16:28:01 +02:00
basti e86541574d Format and round the USD value of balances 2020-08-13 16:30:39 +02:00
basti 5f90bc23bc Dynamically load all balances from Safe 2020-08-13 16:13:50 +02:00
basti 1dd63b2c7a Basic styles for budget balances 2020-08-13 15:43:51 +02:00
basti f1bd20a6f4 Add helper for formatting cryptocurrencies 2020-08-13 15:19:17 +02:00
basti ec6b72008d Add whitespace between reimbursement items in list
They're a bit difficult to scan otherwise.
2020-08-13 14:58:49 +02:00
basti dec71c6f4f Load community funds balances from mainnet Gnosis Safe 2020-08-13 00:27:40 +02:00
basti 52172ea065 add-reimbursement component 2020-08-12 22:53:08 +02:00
basti f63d14d7b4 Move sorted contributors to kredits service 2020-08-12 22:51:52 +02:00
basti ef0fc11edf Add reimbursement form route and component 2020-07-25 18:24:16 +02:00
basti 083334ed18 Don't redirect to dashboard after connecting web3 account 2020-07-25 18:23:55 +02:00
basti 69a6c850bf Convert route to class 2020-07-25 18:22:02 +02:00
basti 8074d2d0e3 Hide empty sections in budget 2020-07-10 13:01:06 +02:00
basti 11c3078b55 Style reimbursement list items 2020-07-09 23:53:06 +02:00
basti fb267813fb Introduce item-list CSS, move item colors to variables 2020-07-09 22:41:11 +02:00
basti a96cca879d Use browser's locale for currency format 2020-07-09 22:15:25 +02:00
basti 4849a755dc Render currencies etc. 2020-07-09 17:07:05 +02:00
basti eaac245f4e WIP Basic reimbursement lists 2020-07-09 15:46:20 +02:00
basti 95743330e4 WIP Generic data handling and caching
Add basic support for reimbursements via new, generic data handling
functions.
2020-06-27 16:35:45 +02:00
basti 4a9dd5ff09 Re-organize functions of kredits service 2020-06-27 14:01:05 +02:00
basti 3b127b0e81 WIP Budget route and basic layout 2020-06-27 13:36:28 +02:00
basti 769317777c Refactor routes
* Add specific dashboard route
* Port application route to Octane
* Fetch/sync contributors in application route, but only sync
  contributions in dashboard route for now
2020-06-27 13:36:02 +02:00
basti 174073fc42 1.17.0 2020-06-02 11:55:29 +02:00
basti 733e5e3683 Merge pull request #191 from 67P/chore/update_dependencies
Update dependencies
2020-06-02 11:52:10 +02:00
basti 090d1d0856 Update dependencies
Fixes a bunch of security audit warnings in the process.
2020-06-02 11:25:35 +02:00
basti e047cc45ba Merge pull request #190 from 67P/feature/improve_contribution_sync
Improve data sync
2020-06-02 10:55:36 +02:00
basti c64a503546 Remove unused async from function
Throws a linter warning, now that it's not an RSVP promise anymore.
2020-05-31 12:46:51 +02:00
basti 13021d665d Merge pull request #188 from 67P/feature/schema_updates
Add new contribution kinds
2020-05-31 10:45:16 +02:00
basti b5dbb2449d Merge pull request #189 from 67P/feature/data_loading
Cache data in browser storage, and load from local cache when present
2020-05-31 10:44:36 +02:00
basti 302c3315a3 Update debug logs while fetching past contributions 2020-05-29 22:28:42 +02:00
basti bac4c1c425 Load all past contributions once everything else is loaded 2020-05-29 22:04:03 +02:00
basti c54d754c89 Only sync contributions if loaded from cache 2020-05-29 20:55:19 +02:00
basti 63dd5430bc WIP Sync contributions 2020-05-29 18:56:05 +02:00
basti eb2c613ac9 Extract data loading from fetch methods 2020-05-29 13:12:39 +02:00
basti bf6d9dbc31 Remove RSVP from kredits service
Just native Promise will do.
2020-05-29 13:11:36 +02:00
basti c491f10ec3 Remove obsolete utils 2020-05-28 14:18:30 +02:00
basti d4b546bfcd Load contributions from cache if present
And then fetch more from network. Also, only load 30 from network before
first render (with nothing cached), then load more after the page has
rendered.
2020-05-28 13:59:10 +02:00
basti 69141a31c4 Process contribution data before loading models 2020-05-28 13:44:32 +02:00
basti e7c9620bbe Add network namespace for browser cache
We want to be able to cache data from multiple networks side-by-side,
without them interfering with each other.
2020-05-28 11:53:19 +02:00
basti 832de3c06f Load contributors from cache if present
And then update from network later
2020-05-28 11:33:47 +02:00
basti 50e1fc728c Cache contributors in IndexedDB 2020-05-27 17:33:36 +02:00
basti 871731110b Process contributor data
Add util function for processing contributor data, and remove bignums,
etc.
2020-05-27 16:27:08 +02:00
basti f996c89dfe Add localforage, basic cache service 2020-05-27 12:59:37 +02:00
basti 1d8e9ddf5d Remove obsolete npm cache Travis config
It's cached by default now
2020-05-27 11:19:23 +02:00
basti 352c9fe3ab Add new contribution types 2020-05-27 11:15:42 +02:00
basti e2a944ed5b Update schemas package
Use the new one from the org namespace.
2020-05-16 13:21:02 +02:00
144 changed files with 56580 additions and 35398 deletions
+18 -4
View File
@@ -2,13 +2,21 @@
module.exports = { module.exports = {
root: true, root: true,
parser: 'babel-eslint', parser: '@babel/eslint-parser',
parserOptions: { parserOptions: {
ecmaVersion: 2018, requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
ecmaFeatures: { ecmaFeatures: {
legacyDecorators: true legacyDecorators: true
} },
babelOptions: {
babelrc: false,
configFile: false,
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
],
},
}, },
globals: { globals: {
console: true console: true
@@ -28,7 +36,13 @@ module.exports = {
'no-console': 'off', 'no-console': 'off',
'ember/no-jquery': 'error', 'ember/no-jquery': 'error',
'ember/require-computed-property-dependencies': 'warn', 'ember/require-computed-property-dependencies': 'warn',
'ember/no-observers': 'warn' 'ember/no-computed-properties-in-native-classes': 'warn',
'ember/no-observers': 'warn',
'ember/no-classic-classes': 'warn',
'ember/no-classic-components': 'warn',
'ember/no-controller-access-in-routes': 'warn',
'ember/no-actions-hash': 'warn',
'ember/require-tagless-components': 'warn'
}, },
overrides: [ overrides: [
// node files // node files
+30
View File
@@ -0,0 +1,30 @@
# 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
+8 -1
View File
@@ -5,7 +5,8 @@ module.exports = {
rules: { rules: {
'simple-unless': false, 'simple-unless': false,
'no-nested-interactive': false 'no-nested-interactive': false,
'no-html-comments': false
}, },
ignore: [ ignore: [
@@ -47,6 +48,12 @@ module.exports = {
"no-action" "no-action"
] ]
}, },
{
"moduleId": "app/components/expense-list/template",
"only": [
"no-invalid-role"
]
},
{ {
"moduleId": "app/components/topbar-account-panel/template", "moduleId": "app/components/topbar-account-panel/template",
"only": [ "only": [
-31
View File
@@ -1,31 +0,0 @@
---
language: node_js
node_js:
- "10"
sudo: false
dist: xenial
notifications:
email:
on_success: never
on_failure: always
addons:
chrome: stable
cache:
directories:
- $HOME/.npm
env:
global:
# See https://git.io/vdao3 for details.
- JOBS=1
branches:
only:
- master
script:
- npm test
+37 -36
View File
@@ -1,39 +1,46 @@
[![Build Status](https://travis-ci.org/67P/kredits-web.svg?branch=master)](https://travis-ci.org/67P/kredits-web) [![Build Status](https://github.com/67P/kredits-web/actions/workflows/ci.yml/badge.svg)](https://github.com/67P/kredits-web/actions/workflows/ci.yml?query=branch%3Amaster)
# kredits-web # Kredits Web
This README outlines the details of collaborating on this Ember application. This is the main Web UI for Kosmos [Kredits](https://wiki.kosmos.org/Kredits).
A short introduction of this app could easily go here. It provides an overview of contributors and contributions, as well as the
community's budget, expenses, and reimbursements.
## Prerequisites 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
You will need the following things properly installed on your computer. You will need the following things properly installed on your computer.
* [Git](https://git-scm.com/) * [Git](https://git-scm.com/)
* [Node.js](https://nodejs.org/) (with npm) * [Node.js](https://nodejs.org/) (with npm)
* [Ember CLI](https://ember-cli.com/) * [Ember CLI](https://ember-cli.com/)
* [Google Chrome](https://google.com/chrome/) * [Google Chrome](https://google.com/chrome/) (only for running tests)
## Installation ### Installation
* `git clone git@github.com:67P/kredits-web.git` this repository * `git clone git@github.com:67P/kredits-web.git` this repository
* `cd kredits-web` * `cd kredits-web`
* `npm install` * `npm install`
## Running / Development ### Building/running for 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 app at [http://localhost:4200](http://localhost:4200).
* Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests). * 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. 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 Make use of the many generators for code, try `ember help generate` for more details
### Running Tests ### Running tests
* `ember test` * `ember test`
* `ember test --server` * `ember test --server`
@@ -47,7 +54,7 @@ Make use of the many generators for code, try `ember help generate` for more det
### Building ### Building
* `ember build` (development) * `ember build` (development)
* `ember build --environment production` (production) * `npm run build` (production)
### Deploying ### Deploying
@@ -55,17 +62,15 @@ _(You need collaborator permissions on the 5apps Deploy project.)_
`npm run deploy` `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 The smart contracts and their JavaScript wrapper library are developed in the
[kredits-contracts](https://github.com/67P/kredits-contracts) repo/package. [kredits-contracts](https://github.com/67P/kredits-contracts) repo/package.
You can run `kredits-web` on your machine, against a local, simulated Ethereum You can run `kredits-web` on your machine, against a local, simulated
network, provided e.g. by [ganache](http://truffleframework.com/ganache/). 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
[kredits-contracts](https://github.com/67P/kredits-contracts) holds all the tools to deploy the Kredits smart contracts to it.
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: These are the basic steps to get up and running:
@@ -78,37 +83,33 @@ Run a local IPFS deamon.
#### 2. kredits-contracts #### 2. kredits-contracts
Get your local Ethereum development node running. (See [kredits-contracts README](https://github.com/67P/kredits-contracts) Run a local devchain with test data. (See [kredits-contracts
for details. README](https://github.com/67P/kredits-contracts) for details.
* Clone [kredits-contracts](https://github.com/67P/kredits-contracts) * Clone [kredits-contracts](https://github.com/67P/kredits-contracts)
* `npm install` * `npm install`
* `npm run devchain` - runs a local development chain * `npm run devchain` - runs a local development chain
* `npm run bootstrap` - bootstrap runs deploys all the contracts * `npm run bootstrap` - deploys all contracts and seeds test data
* `npm link` - make the `kredits-contracts` module linkable as `kredits-contracts` on your machine (currently broken :( ) * `npm link` - makes the `kredits-contracts` module linkable as `kredits-contracts` on your machine
#### 3. kredits-web #### 3. kredits-web
With IPFS and Ethereum/ganache running, you can now start this Ember app. With IPFS and the local devchain running, you can now link the contracts and
start the Ember app:
* `npm link kredits-contracts` - link the local `kredits-contracts` package * `npm link kredits-contracts` - links the local `kredits-contracts` package (has to be done again after every `npm install`)
* `npm run start:local` - WEB3_PROVIDER_URL=http://localhost:7545 must be set for local settings * `npm run start:local` - runs the Ember app with WEB3_PROVIDER_URL=http://localhost:8545 set
If you restart the devchain your network ID changes. So kredits-contracts does not find the correct DAO address.
If that is the case the DAO address must be provided as `KREDITS_DAO_ADDRESS` environment variable.
* `npm run dao:address` in the kredits-contracts repo to find your address
* `KREDITS_DAO_ADDRESS=<0xYOURADDRESS> npm run start:local`
#### 4. Metamask network #### 4. Metamask network
Switch the network in Metamask to "Custom RPC" with the RPC URL `http://localhost:7545`. If you want to interact with the local contracts via a Web3 wallet, switch the
network to a "Custom RPC" one, with the RPC URL `http://localhost:8545`.
#### IPFS #### IPFS
Install IPFS with your favorite package manager and run If you haven't configured your IPFS node for CORS yet, you can do so by running
the following commands:
ipfs init (on initial installation)
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["localhost:4200"]' 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-Methods '["PUT", "GET", "POST"]'
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Credentials '["true"]' ipfs config --json API.HTTPHeaders.Access-Control-Allow-Credentials '["true"]'
+13 -3
View File
@@ -3,11 +3,21 @@ import { computed } from '@ember/object';
import { and, notEmpty } from '@ember/object/computed'; import { and, notEmpty } from '@ember/object/computed';
import { assign } from '@ember/polyfills'; import { assign } from '@ember/polyfills';
import moment from 'moment'; import moment from 'moment';
import { inject as service } from '@ember/service';
export default Component.extend({ export default Component.extend({
kredits: service(),
attributes: null, attributes: null,
contributors: Object.freeze([]),
contributors: computed('kredits.contributorsSorted.[]', function() {
return this.kredits.contributorsSorted.map(c => {
return {
id: c.id.toString(),
name: c.name
}
})
}),
isValidContributor: notEmpty('contributorId'), isValidContributor: notEmpty('contributorId'),
isValidKind: notEmpty('kind'), isValidKind: notEmpty('kind'),
@@ -55,6 +65,8 @@ export default Component.extend({
const attributes = this.getProperties(Object.keys(this.attributes)); const attributes = this.getProperties(Object.keys(this.attributes));
attributes.contributorId = parseInt(this.contributorId);
let dateInput = (attributes.date instanceof Array) ? let dateInput = (attributes.date instanceof Array) ?
attributes.date[0] : attributes.date; attributes.date[0] : attributes.date;
@@ -72,7 +84,5 @@ export default Component.extend({
}) })
.finally(() => this.set('inProgress', false)); .finally(() => this.set('inProgress', false));
} }
} }
}); });
+4 -1
View File
@@ -4,7 +4,7 @@
<p> <p>
<select required onchange={{action (mut this.contributorId) value="target.value"}}> <select required onchange={{action (mut this.contributorId) value="target.value"}}>
<option value="" selected disabled hidden></option> <option value="" selected disabled hidden></option>
{{#each @contributors as |contributor|}} {{#each this.contributors as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option> <option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}} {{/each}}
</select> </select>
@@ -15,11 +15,14 @@
<p> <p>
<select required onchange={{action (mut this.kind) value="target.value"}}> <select required onchange={{action (mut this.kind) value="target.value"}}>
<option value="" selected disabled hidden></option> <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="community" selected={{eq this.kind "community"}}>Community</option>
<option value="design" selected={{eq this.kind "design"}}>Design</option> <option value="design" selected={{eq this.kind "design"}}>Design</option>
<option value="dev" selected={{eq this.kind "dev"}}>Development</option> <option value="dev" selected={{eq this.kind "dev"}}>Development</option>
<option value="docs" selected={{eq this.kind "docs"}}>Documentation</option> <option value="docs" selected={{eq this.kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq this.kind "ops"}}>IT Operations</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> <option value="special" selected={{eq this.kind "special"}}>Special</option>
</select> </select>
</p> </p>
+1 -1
View File
@@ -1,6 +1,6 @@
<form onsubmit={{action "submit"}}> <form onsubmit={{action "submit"}}>
<p> <p>
<label for="c-account">Ethereum account</label> <label for="c-account">Rootstock account</label>
<Input @type="text" <Input @type="text"
@value={{this.account}} @value={{this.account}}
name="account" id="c-account" name="account" id="c-account"
@@ -0,0 +1,91 @@
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);
}
}
@@ -0,0 +1,69 @@
<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,165 @@
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;
});
}
}
@@ -0,0 +1,79 @@
<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>
@@ -0,0 +1,12 @@
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;
}
}
@@ -0,0 +1,18 @@
<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>
+24
View File
@@ -0,0 +1,24 @@
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;
}
}
+3
View File
@@ -0,0 +1,3 @@
{{#unless this.isConfirmed}}
Confirming in <strong>{{this.confirmedInBlocks}}</strong> blocks (~ {{this.confirmedInHumanTime}})
{{/unless}}
@@ -24,10 +24,9 @@ export default Component.extend({
contributors: sort('kredits.contributors', 'contributorsSorting'), contributors: sort('kredits.contributors', 'contributorsSorting'),
contributorsActive: computed('contributors.[]', 'contributions', function() { contributorsActive: computed('contributors.[]', 'contributions', function() {
const activeIds = new Set(this.contributions.mapBy('contributorId') const activeIds = new Set(this.contributions.mapBy('contributorId'));
.map(id => id.toString()));
return this.contributors.filter(c => activeIds.has(c.id.toString())); return this.contributors.filter(c => activeIds.has(c.id));
}), }),
contributionKinds: computed('contributions.[]', function() { contributionKinds: computed('contributions.[]', function() {
@@ -42,7 +41,7 @@ export default Component.extend({
c.amount <= 500) { included = false; } c.amount <= 500) { included = false; }
if (isPresent(this.contributorId) && if (isPresent(this.contributorId) &&
c.contributorId.toString() !== this.contributorId.toString()) { included = false; } c.contributorId !== parseInt(this.contributorId)) { included = false; }
if (isPresent(this.contributionKind) && if (isPresent(this.contributionKind) &&
c.kind !== this.contributionKind) { included = false; } c.kind !== this.contributionKind) { included = false; }
@@ -29,11 +29,11 @@
</div> </div>
{{/if}} {{/if}}
<ul class="contribution-list"> <ul class="item-list contribution-list {{if @loading 'loading'}}">
{{#each this.contributionsFiltered as |contribution|}} {{#each this.contributionsFiltered as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}} <li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}} {{action "openContributionDetails" contribution}}
class="{{contribution-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}"> class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta"> <p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span> <span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span> <span class="category {{contribution.kind}}">({{contribution.kind}})</span>
+1 -4
View File
@@ -2,12 +2,10 @@ import Component from '@ember/component';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
export default Component.extend({ export default Component.extend({
tagName: '',
router: service(), router: service(),
tagName: 'table',
classNames: 'contributor-list',
selectedContributorId: null, selectedContributorId: null,
actions: { actions: {
@@ -17,5 +15,4 @@ export default Component.extend({
} }
} }
}); });
+6 -2
View File
@@ -1,4 +1,7 @@
<tbody> <table class="contributor-list {{if @loading 'loading'}}">
<thead>
</thead>
<tbody>
{{#each @contributorList as |c|}} {{#each @contributorList as |c|}}
<tr role="button" <tr role="button"
onclick={{action "openContributorDetails" c.contributor}} onclick={{action "openContributorDetails" c.contributor}}
@@ -18,4 +21,5 @@
</td> </td>
</tr> </tr>
{{/each}} {{/each}}
</tbody> </tbody>
</table>
+9
View File
@@ -0,0 +1,9 @@
import Component from '@glimmer/component';
export default class ExpenseListComponent extends Component {
get showDeleteButton () {
return !!this.args.deletable;
}
}
+29
View File
@@ -0,0 +1,29 @@
<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>
@@ -0,0 +1,23 @@
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);
});
}
}
@@ -0,0 +1,38 @@
<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>&#8239;<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,46 +1,37 @@
import Component from '@ember/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { computed } from '@ember/object'; import { action } from '@ember/object';
import { isPresent } from '@ember/utils'; import { isPresent } from '@ember/utils';
export default Component.extend({ export default class TopbarAccountPanelComponent extends Component {
@service router;
@service kredits;
tagName: '', @tracked setupInProgress = false;
kredits: service(), get userHasWallet () {
router: service(),
setupInProgress: false,
userHasEthereumWallet: computed(function() {
return isPresent(window.ethereum); return isPresent(window.ethereum);
}), }
showConnectButton: computed('userHasEthereumWallet', get walletConnected () {
'kredits.hasAccounts', function() { return this.userHasWallet && this.kredits.hasAccounts;
return this.userHasEthereumWallet && }
!this.kredits.hasAccounts;
}),
actions: { get walletDisconnected () {
return this.userHasWallet && !this.kredits.hasAccounts;
}
signup() { @action
signup () {
this.router.transitionTo('signup'); this.router.transitionTo('signup');
},
async connectAccount() {
try {
await window.ethereum.enable();
this.set('setupInProgress', true);
await this.kredits.setup();
this.set('setupInProgress', false);
this.router.transitionTo('dashboard');
} catch (error) {
this.set('setupInProgress', false);
console.log('Opening Ethereum wallet failed:', error);
}
} }
@action
async connectWallet () {
this.setupInProgress = true;
await this.kredits.connectWallet();
this.setupInProgress = false;
} }
}); }
@@ -2,16 +2,15 @@
{{#if this.setupInProgress}} {{#if this.setupInProgress}}
Connecting account... Connecting account...
{{else}} {{else}}
{{#if (and this.kredits.hasAccounts this.kredits.currentUser)}} {{#if (and this.walletConnected this.kredits.currentUser)}}
{{this.kredits.currentUser.name}} {{this.kredits.currentUser.name}}
{{#if this.kredits.currentUserIsCore}}
<span class="core-flag">(core)</span>
{{/if}}
{{else}} {{else}}
<span class="anonymous">Anonymous</span> <span class="anonymous">Anonymous</span>
<button onclick={{action "signup"}} class="small" type="button">Sign up</button> <button onclick={{action "signup"}} id="signup"
{{#if this.showConnectButton}} class="small" type="button">Sign up</button>
<button onclick={{action "connectAccount"}} class="small green" type="button">Connect account</button> {{#if this.walletDisconnected}}
<button onclick={{action "connectWallet"}} id="connect"
class="small green" type="button">Connect wallet</button>
{{/if}} {{/if}}
{{/if}} {{/if}}
{{/if}} {{/if}}
+10
View File
@@ -0,0 +1,10 @@
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;
}
+4 -2
View File
@@ -1,6 +1,6 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { alias, not, sort } from '@ember/object/computed'; import { alias, gt, not, sort } from '@ember/object/computed';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
export default Controller.extend({ export default Controller.extend({
@@ -33,6 +33,8 @@ export default Controller.extend({
showQuickFilterUnconfirmed: false, showQuickFilterUnconfirmed: false,
showQuickFilterConfirmed: false, showQuickFilterConfirmed: false,
showFullContributionSync: gt('kredits.missingHistoricContributionsCount', 0),
showIntroText: computed('kredits.{hasAccounts,currentUser}', function(){ showIntroText: computed('kredits.{hasAccounts,currentUser}', function(){
return (!this.kredits.hasAccounts || !this.kredits.currentUser); return (!this.kredits.hasAccounts || !this.kredits.currentUser);
}), }),
@@ -41,7 +43,7 @@ export default Controller.extend({
vetoContribution (contributionId) { vetoContribution (contributionId) {
this.kredits.veto(contributionId).then(transaction => { this.kredits.veto(contributionId).then(transaction => {
console.debug('[controllers:index] Veto submitted to Ethereum blockhain: '+transaction.hash); console.debug('[controllers:index] Veto submitted to chain: '+transaction.hash);
}); });
}, },
+50
View File
@@ -0,0 +1,50 @@
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');
}
})
}
}
-49
View File
@@ -1,49 +0,0 @@
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import { alias, not } from '@ember/object/computed';
import { isAddress } from 'web3-utils';
import { inject as service } from '@ember/service';
import config from 'kredits-web/config/environment';
export default Controller.extend({
kredits: service(),
ethAddress: null,
githubAccessToken: alias('kredits.githubAccessToken'),
isValidEthAccount: computed('ethAddress', function() {
return isAddress(this.ethAddress);
}),
signupButtonDisabled: not('isValidEthAccount'),
actions: {
completeSignup () {
const payload = {
accessToken: this.githubAccessToken,
account: this.ethAddress
}
fetch(config.githubSignupUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(res => res.json())
.then(data => {
console.log('Created contributor:', data);
this.setProperties({
githubAccessToken: null,
ethAddress: null
});
this.transitionToRoute('signup.complete');
});
}
}
});
+4 -6
View File
@@ -1,14 +1,12 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { action } from '@ember/object';
import config from 'kredits-web/config/environment'; import config from 'kredits-web/config/environment';
export default Controller.extend({ export default class IndexController extends Controller {
actions: {
@action
connectGithub () { connectGithub () {
window.location = config.githubConnectUrl; window.location = config.githubConnectUrl;
} }
} }
});
-56
View File
@@ -1,56 +0,0 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { once } from '@ember/runloop';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([contribution]) {
this.setupRecompute(contribution);
let status = [];
if (contribution.vetoed) {
status.push('vetoed');
} else if (contribution.confirmedAt > this.currentBlock) {
status.push('unconfirmed');
} else {
status.push('confirmed');
}
if (contribution.hasPendingChanges) {
status.push('pending');
}
return status.join(' ');
},
destroy () {
if (this.teardown) this.teardown();
this._super(...arguments);
},
setupRecompute (contribution) {
if (this.teardown) this.teardown();
contribution.addObserver('vetoed' , this, this.triggerRecompute);
contribution.addObserver('confirmedAt' , this, this.triggerRecompute);
contribution.addObserver('currentBlock' , this, this.triggerRecompute);
contribution.addObserver('hasPendingChanges' , this, this.triggerRecompute);
this.teardown = () => {
contribution.removeObserver('vetoed', this, this.triggerRecompute);
contribution.removeObserver('confirmedAt', this, this.triggerRecompute);
contribution.removeObserver('currentBlock', this, this.triggerRecompute);
contribution.removeObserver('hasPendingChanges', this, this.triggerRecompute);
};
},
triggerRecompute () {
once(this, this.recompute);
}
});
+18
View File
@@ -0,0 +1,18 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtCryptoCurrency(params/*, hash*/) {
let fmtAmount;
const amount = params[0];
const code = params[1];
switch(code) {
case 'RBTC':
fmtAmount = amount / 1000000000000000000;
break;
case 'BTC':
fmtAmount = amount / 100000000;
break;
}
return fmtAmount;
});
+8
View File
@@ -0,0 +1,8 @@
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);
});
+11
View File
@@ -0,0 +1,11 @@
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]);
});
+56
View File
@@ -0,0 +1,56 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { once } from '@ember/runloop';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([item]) {
this.setupRecompute(item);
let status = [];
if (item.vetoed) {
status.push('vetoed');
} else if (item.confirmedAt > this.currentBlock) {
status.push('unconfirmed');
} else {
status.push('confirmed');
}
if (item.hasPendingChanges) {
status.push('pending');
}
return status.join(' ');
},
destroy () {
if (this.teardown) this.teardown();
this._super(...arguments);
},
setupRecompute (item) {
if (this.teardown) this.teardown();
item.addObserver('vetoed' , this, this.triggerRecompute);
item.addObserver('confirmedAt' , this, this.triggerRecompute);
item.addObserver('currentBlock' , this, this.triggerRecompute);
item.addObserver('hasPendingChanges' , this, this.triggerRecompute);
this.teardown = () => {
item.removeObserver('vetoed', this, this.triggerRecompute);
item.removeObserver('confirmedAt', this, this.triggerRecompute);
item.removeObserver('currentBlock', this, this.triggerRecompute);
item.removeObserver('hasPendingChanges', this, this.triggerRecompute);
};
},
triggerRecompute () {
once(this, this.recompute);
}
});
+5
View File
@@ -0,0 +1,5 @@
import { helper } from '@ember/component/helper';
export default helper(function satsToBtc(amount/*, hash*/) {
return amount / 100000000;
});
+10 -6
View File
@@ -1,19 +1,21 @@
import EmberObject, { computed } from '@ember/object'; import EmberObject, { computed } from '@ember/object';
import { isEmpty, isPresent } from '@ember/utils'; import { isEmpty, isPresent } from '@ember/utils';
import bignumber from 'kredits-web/utils/cps/bignumber';
import moment from 'moment'; import moment from 'moment';
export default EmberObject.extend({ export default EmberObject.extend({
// Contract // Contract
id: null, id: null,
contributorId: null, contributorId: null,
amount: null, amount: null,
confirmedAt: bignumber('confirmedAtBlock', 'toNumber'), confirmedAt: null,
vetoed: null, vetoed: null,
ipfsHash: null, ipfsHash: null,
creatorAccount: null, // contributor model instance
contributor: null,
// TODO contributor who submitted the contribution
// submittedBy: null,
// IPFS // IPFS
kind: null, kind: null,
@@ -22,7 +24,6 @@ export default EmberObject.extend({
url: null, url: null,
date: null, date: null,
time: null, time: null,
ipfsData: '',
pendingTx: null, pendingTx: null,
@@ -41,6 +42,9 @@ export default EmberObject.extend({
hasPendingChanges: computed('pendingTx', function() { hasPendingChanges: computed('pendingTx', function() {
return isPresent(this.pendingTx); return isPresent(this.pendingTx);
}) }),
serialize () {
return JSON.stringify(this);
}
}); });
+7 -7
View File
@@ -1,14 +1,12 @@
import EmberObject from '@ember/object'; import EmberObject from '@ember/object';
import bignumber from 'kredits-web/utils/cps/bignumber';
import kreditsValue from 'kredits-web/utils/cps/kredits';
export default EmberObject.extend({ export default EmberObject.extend({
// Contract // Contract
id: bignumber('idRaw', 'toString'), id: null,
account: null, account: null,
balance: kreditsValue('balanceRaw'), balance: 0,
totalKreditsEarned: bignumber('totalKreditsEarnedRaw', 'toNumber'), totalKreditsEarned: 0,
contributionsCount: bignumber('contributionsCountRaw', 'toNumber'), contributionsCount: 0,
isCore: false, isCore: false,
ipfsHash: null, ipfsHash: null,
@@ -20,6 +18,8 @@ export default EmberObject.extend({
github_uid: null, github_uid: null,
wiki_username: null, wiki_username: null,
zoom_display_name: null, zoom_display_name: null,
ipfsData: ''
serialize () {
return JSON.stringify(this);
}
}); });
+47
View File
@@ -0,0 +1,47 @@
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);
}
});
+10 -1
View File
@@ -26,7 +26,16 @@ Router.map(function() {
}); });
this.route('signup', function() { this.route('signup', function() {
this.route('github'); this.route('github');
this.route('eth-account'); this.route('account');
this.route('complete'); this.route('complete');
}); });
this.route('budget', function() {
this.route('expenses');
this.route('reimbursements', function() {});
});
this.route('reimbursements', function() {
this.route('new');
});
}); });
+23 -14
View File
@@ -1,26 +1,35 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { schedule } from '@ember/runloop';
export default Route.extend({ export default class ApplicationRoute extends Route {
kredits: service(), @service kredits;
@service communityFunds;
beforeModel(/* transition */) { beforeModel(/* transition */) {
const kredits = this.kredits; return this.kredits.setup().then(() => {
this.kredits.kredits.preflightChecks().catch(error => {
return kredits.setup().then(() => {
kredits.get('kredits').preflightChecks().catch((error) => {
console.error('Kredits preflight check failed!'); console.error('Kredits preflight check failed!');
console.error(error); console.error(error);
}); });
}).catch((error) => { }).catch(error => {
console.log('Error initializing Kredits', error); console.log('Error initializing Kredits', error);
}); });
}, }
afterModel() { model() {
return this.kredits.loadInitialData() return this.kredits.loadInitialData().then(() => {
.then(() => { this.kredits.addContractEventHandlers()
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);
}
}
+34
View File
@@ -0,0 +1,34 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { isEmpty, isPresent } from '@ember/utils';
import { schedule } from '@ember/runloop';
export default class BudgetRoute extends Route {
@service browserCache;
@service kredits;
async model () {
if (isPresent(this.kredits.reimbursements) &&
isEmpty(this.kredits.reimbursementsPending)) {
console.debug('[route:budget] Reimbursements loaded before, no need to sync or load');
return;
} else {
const numCachedReimbursements = await this.browserCache.reimbursements.length();
if (numCachedReimbursements > 0) {
await this.kredits.loadObjectsFromCache('Reimbursement');
this.kredits.set('reimbursementsNeedSync', true);
} else {
await this.kredits.fetchObjects('Reimbursement', { page: { size: 10 } });
}
}
}
afterModel() {
if (this.kredits.reimbursementsNeedSync) {
schedule('afterRender', this.kredits.syncReimbursements,
this.kredits.syncReimbursements.perform);
}
schedule('afterRender', this.kredits.fetchMissingReimbursements,
this.kredits.fetchMissingReimbursements.perform);
}
}
+4
View File
@@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default class BudgetExpensesRoute extends Route {
}
+2 -2
View File
@@ -1,9 +1,9 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
export default Route.extend({ export default class ContributionsNewRoute extends Route {
model (params) { model (params) {
return { params }; return { params };
} }
}); }
+2 -6
View File
@@ -6,19 +6,15 @@ export default Route.extend({
kredits: service(), kredits: service(),
model(params) { model(params) {
const contribution = this.kredits.contributions.findBy('id', parseInt(params.id)); return this.kredits.contributions.findBy('id', parseInt(params.id));
contribution.contributorId = contribution.contributorId.toString();
return contribution;
}, },
setupController (controller, model) { setupController (controller, model) {
this._super(controller, model); this._super(controller, model);
controller.set('attributes', model.getProperties([ controller.set('attributes', model.getProperties([
'kind', 'amount', 'description', 'url', 'details' 'contributorId', 'kind', 'amount', 'description', 'url', 'details'
])); ]));
controller.set('attributes.contributorId', model.contributorId.toString());
controller.set('attributes.date', model.jsDate); controller.set('attributes.date', model.jsDate);
} }
+21
View File
@@ -0,0 +1,21 @@
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);
}
}
+7 -1
View File
@@ -8,7 +8,13 @@ export default Route.extend({
contributors: alias('kredits.contributors'), contributors: alias('kredits.contributors'),
model (params) { model (params) {
return this.contributors.findBy('id', params.id); const contributor = this.contributors.findBy('id', params.id);
if (contributor) {
return contributor;
} else {
return this.kredits.fetchContributor(params.id);
}
}, },
setupController (controller, model) { setupController (controller, model) {
+9
View File
@@ -0,0 +1,9 @@
import Route from '@ember/routing/route';
export default class ReimbursementsNewRoute extends Route {
model (params) {
return { params };
}
}
+23
View File
@@ -0,0 +1,23 @@
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');
}
}
}
-17
View File
@@ -1,17 +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);
if (isEmpty(this.kredits.githubAccessToken)) {
this.transitionTo('signup.index');
}
}
});
+1 -1
View File
@@ -22,7 +22,7 @@ export default Route.extend({
this.kredits.set('githubAccessToken', accessToken); this.kredits.set('githubAccessToken', accessToken);
this.transitionTo('signup.eth-account'); this.transitionTo('signup.account');
} }
}); });
+37
View File
@@ -0,0 +1,37 @@
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;
}
}
+39
View File
@@ -0,0 +1,39 @@
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;
}
}
+29
View File
@@ -0,0 +1,29 @@
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');
}
}
}
+595 -152
View File
@@ -1,56 +1,198 @@
import ethers from 'ethers'; import ethers from 'ethers';
import Kredits from 'kredits-contracts'; import Kredits from '@kredits/contracts';
import RSVP from 'rsvp';
import Service from '@ember/service'; import Service from '@ember/service';
import EmberObject from '@ember/object'; import EmberObject from '@ember/object';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { alias, notEmpty } from '@ember/object/computed'; import { alias, filterBy, notEmpty, sort } from '@ember/object/computed';
import { isEmpty, isPresent } from '@ember/utils'; 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 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 formatKredits from 'kredits-web/utils/format-kredits';
import switchNetwork from 'kredits-web/utils/switch-network';
import config from 'kredits-web/config/environment'; import config from 'kredits-web/config/environment';
import Contributor from 'kredits-web/models/contributor' import Contributor from 'kredits-web/models/contributor';
import Contribution from 'kredits-web/models/contribution' 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 };
export default Service.extend({ export default Service.extend({
browserCache: service(),
currentBlock: null, currentBlock: null,
currentUserAccounts: null, // default to not having an account. this is the wen web3 is loaded. currentUserAccounts: null, // default to not having an account. this is the wen web3 is loaded.
currentUser: null, currentUser: null,
contributors: null, contributors: null,
contributions: null, contributions: null,
reimbursements: null,
githubAccessToken: null, githubAccessToken: null,
currentUserIsContributor: notEmpty('currentUser'), currentUserIsContributor: notEmpty('currentUser'),
currentUserIsCore: alias('currentUser.isCore'), currentUserIsCore: alias('currentUser.isCore'),
hasAccounts: notEmpty('currentUserAccounts'), hasAccounts: notEmpty('currentUserAccounts'),
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() { contributorsMined: filterBy('contributors', 'id'),
return this.contributions.filter(contribution => { contributorsSorting: Object.freeze(['name:asc']),
return contribution.confirmedAt > this.currentBlock; 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);
const ethSigner = accounts.length === 0 ? null : ethProvider.getSigner();
resolve({
ethProvider,
ethSigner
});
}
}
if (window.ethereum) {
instantiateWithWallet(window.ethereum, this);
} else {
instantiateWithoutWallet();
}
});
},
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)}`;
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, {
ipfsConfig: config.ipfs
});
});
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() {
if (isEmpty(this.currentUserAccounts)) {
return Promise.resolve();
}
return this.kredits.Contributor
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject)
.then((id) => {
// check if the user is a contributor or not
if (id === 0) {
return Promise.resolve();
} else {
return this.kredits.Contributor.getById(id);
}
}); });
}), }),
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() { totalSupply: computed(function() {
return this.contributions return this.kredits.Token.functions.totalSupply().then(total => {
.filterBy('vetoed', false) return formatKredits(total);
.filter(contribution => { })
return contribution.confirmedAt <= this.currentBlock; }),
});
totalKreditsEarned: computed(function() {
return this.kredits.Contribution.functions.totalKreditsEarned(true);
}), }),
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() { kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false); const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId'); const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value.toString()); const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value);
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id)) const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
const kreditsByContributor = contributionsGrouped.map(c => { const kreditsByContributor = contributionsGrouped.map(c => {
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b); const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
const contributor = this.contributors.findBy('id', c.value.toString()); const contributor = this.contributors.findBy('id', c.value);
return EmberObject.create({ return EmberObject.create({
contributor: contributor, contributor: contributor,
@@ -72,109 +214,58 @@ export default Service.extend({
return kreditsByContributor; return kreditsByContributor;
}), }),
init () { contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
this._super(...arguments); return this.contributions
this.set('contributors', []); .filter(c => c.confirmedAt > this.currentBlock);
this.set('contributions', []); }),
},
// This is called in the application route's beforeModel(). So it is contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
// initialized before everything else, and we can rely on the ethProvider and return this.contributions
// the potential currentUserAccounts to be available .filterBy('vetoed', false)
getEthProvider () { .filter(c => c.confirmedAt <= this.currentBlock);
let ethProvider; }),
return new RSVP.Promise(async (resolve) => { reimbursementsUnconfirmed: computed('reimbursements.[]', 'currentBlock', function() {
function instantiateWithoutAccount () { return this.reimbursements
console.debug('[kredits] Creating new instance from npm module class'); .filter(r => r.confirmedAt > this.currentBlock);
console.debug(`[kredits] providerURL: ${config.web3ProviderUrl}`); }),
ethProvider = new ethers.providers.JsonRpcProvider(config.web3ProviderUrl);
resolve({
ethProvider: ethProvider,
ethSigner: null
});
}
async function instantiateWithAccount (web3Provider, context) { reimbursementsConfirmed: computed('reimbursements.[]', 'currentBlock', function() {
console.debug('[kredits] Using user-provided instance, e.g. from Mist browser or Metamask'); return this.reimbursements
ethProvider = new ethers.providers.Web3Provider(web3Provider); .filterBy('vetoed', false)
.filter(r => r.confirmedAt <= this.currentBlock);
}),
const network = await ethProvider.getNetwork(); reimbursementsPending: computed('reimbursements.[]', 'pendingTx', function() {
if (isPresent(config.web3RequiredNetwork) && return this.reimbursements.filter(r => !r.id);
network.name !== config.web3RequiredNetwork) { }),
window.alert(`Please switch your Ethereum wallet to the "${config.web3RequiredNetwork}" network before connecting your account.`);
return instantiateWithoutAccount();
}
ethProvider.listAccounts().then(accounts => { async loadInitialData () {
context.set('currentUserAccounts', accounts); const numCachedContributors = await this.browserCache.contributors.length();
const ethSigner = accounts.length === 0 ? null : ethProvider.getSigner(); if (numCachedContributors > 0) {
resolve({ await this.loadObjectsFromCache('Contributor');
ethProvider, this.set('contributorsNeedSync', true);
ethSigner
});
});
}
if (window.ethereum) {
if (window.ethereum.isConnected()) {
instantiateWithAccount(window.ethereum, this);
} else { } else {
instantiateWithoutAccount(); 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 } });
} }
// Legacy dapp browsers...
else if (window.web3) { await this.updateMissingHistoricContributionsCount();
instantiateWithAccount(window.web3.currentProvider, this);
} return Promise.resolve();
// Non-dapp browsers...
else {
instantiateWithoutAccount();
}
});
}, },
setup () { async updateMissingHistoricContributionsCount () {
return this.getEthProvider().then((providerAndSigner) => { const contributionsCount = await this.kredits.Contribution.count;
let kredits = new Kredits(providerAndSigner.ethProvider, providerAndSigner.ethSigner, { this.set('missingHistoricContributionsCount', contributionsCount - this.contributions.length);
addresses: { Kernel: config.kreditsKernelAddress }, console.debug(`Missing ${this.missingHistoricContributionsCount} historic contributions (out of ${contributionsCount} overall)`)
apm: config.kreditsApmDomain,
ipfsConfig: config.ipfs
});
return kredits
.init()
.then(async (kredits) => {
this.set('kredits', kredits);
this.set('currentBlock', await kredits.provider.getBlockNumber());
if (this.currentUserAccounts && this.currentUserAccounts.length > 0) {
this.getCurrentUser.then((contributorData) => {
this.set('currentUser', contributorData);
});
}
return kredits;
});
});
},
totalSupply: computed(function() {
return this.kredits.Token.functions.totalSupply().then(total => {
return formatKredits(total);
})
}),
totalKreditsEarned: computed(function() {
return this.kredits.Contribution.functions.totalKreditsEarned(true)
.then(total => total.toNumber());
}),
loadInitialData () {
return this.getContributors()
.then(contributors => this.contributors.pushObjects(contributors))
.then(() => this.getContributions())
.then(contributions => this.contributions.pushObjects(contributions))
}, },
addContributor (attributes) { addContributor (attributes) {
@@ -205,40 +296,182 @@ export default Service.extend({
}); });
}, },
getContributors () { 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() return this.kredits.Contributor.all()
.then(contributors => { .then(contributors => {
return contributors.map(contributor => { return contributors.forEach(data => {
return Contributor.create(contributor); this.loadContributorFromData(data);
return;
}); });
})
.then(() => {
return this.cacheLoadedContributors();
}); });
}, },
addContribution (attributes) { loadContributorFromData (data) {
console.debug('[kredits] add contribution', attributes); 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;
},
return this.kredits.Contribution.addContribution(attributes, { gasLimit: 300000 }) 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 => { .then(data => {
console.debug('[kredits] add contribution response', data); console.debug('[kredits] Contribution.add response', data);
attributes.contributor = this.contributors.findBy('id', attributes.contributorId); attributes.contributor = this.contributors.findBy('id', attributes.contributorId);
const contribution = Contribution.create(attributes); const contribution = Contribution.create(attributes);
contribution.set('pendingTx', data); contribution.set('pendingTx', data);
contribution.set('confirmedAtBlock', data.blockNumber + 40320); contribution.set('confirmedAtBlock', this.currentBlock + 40320);
this.contributions.pushObject(contribution); this.contributions.pushObject(contribution);
return contribution; return contribution;
}); });
}, },
getContributions () { fetchContributions (options = { page: { size: 200 } }) {
return this.kredits.Contribution.all({page: {size: 200}}) console.debug(`[kredits] Fetching contributions from the network`);
return this.kredits.Contribution.all(options)
.then(contributions => { .then(contributions => {
return contributions.map(contribution => { return contributions.map(data => {
contribution.contributor = this.contributors.findBy('id', contribution.contributorId.toString()); const contribution = this.loadContributionFromData(data);
return Contribution.create(contribution); 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) { veto (contributionId) {
console.debug('[kredits] veto against', contributionId); console.debug('[kredits] veto against', contributionId);
const contribution = this.contributions.findBy('id', contributionId); const contribution = this.contributions.findBy('id', contributionId);
@@ -251,23 +484,197 @@ export default Service.extend({
}); });
}, },
getCurrentUser: computed('kredits.provider', 'currentUserAccounts.[]', function() { //
if (isEmpty(this.currentUserAccounts)) { // Generic data handling (for objects that can be vetoed)
return RSVP.resolve(); //
}
return this.kredits.Contributor fetchObjects(objectClass, options = { page: { size: 200 } }) {
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject) const objectClassLowerCase = objectClass.toLowerCase();
.then((id) => { console.debug(`[kredits] Fetching ${objectClassLowerCase}s from the network`);
// check if the user is a contributor or not return this.kredits[objectClass].all(options)
if (id === 0) { .then(objects => {
return RSVP.resolve(); return objects.map(data => {
} else { const classInstance = this[`load${objectClass}FromData`](data);
return this.kredits.Contributor.getById(id); 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 // Contract events
//
addContractEventHandlers () { addContractEventHandlers () {
this.kredits.Contributor this.kredits.Contributor
.on('ContributorProfileUpdated', this.handleContributorChange.bind(this)) .on('ContributorProfileUpdated', this.handleContributorChange.bind(this))
@@ -278,6 +685,10 @@ export default Service.extend({
.on('ContributionAdded', this.handleContributionAdded.bind(this)) .on('ContributionAdded', this.handleContributionAdded.bind(this))
.on('ContributionVetoed', this.handleContributionVetoed.bind(this)) .on('ContributionVetoed', this.handleContributionVetoed.bind(this))
this.kredits.Reimbursement
.on('ReimbursementAdded', this.handleReimbursementAdded.bind(this))
.on('ReimbursementVetoed', this.handleReimbursementVetoed.bind(this))
this.kredits.Token this.kredits.Token
.on('Transfer', this.handleTransfer.bind(this)); .on('Transfer', this.handleTransfer.bind(this));
}, },
@@ -288,13 +699,15 @@ export default Service.extend({
const contributorData = await this.kredits.Contributor.getById(contributorId); const contributorData = await this.kredits.Contributor.getById(contributorId);
const newContributor = Contributor.create(contributorData); const newContributor = Contributor.create(contributorData);
const oldContributor = this.contributors.findBy('id', contributorId.toString()); // TODO check for actual differences in the contributor data first
const oldContributor = this.contributors.findBy('id', contributorId);
if (oldContributor) { if (oldContributor) {
console.debug('[kredits] old contributor', oldContributor); // console.debug('[kredits] cached contributor', oldContributor);
this.contributors.removeObject(oldContributor); this.contributors.removeObject(oldContributor);
} }
console.debug('[kredits] new contributor', newContributor); // console.debug('[kredits] incoming contributor data', newContributor);
this.contributors.pushObject(newContributor); this.contributors.pushObject(newContributor);
}, },
@@ -303,27 +716,57 @@ export default Service.extend({
const pendingContribution = this.contributions.find(c => { const pendingContribution = this.contributions.find(c => {
return (c.id === null) && return (c.id === null) &&
(c.contributorId.toString() === contributorId.toString()) && (c.contributorId === contributorId) &&
(c.amount.toString() === amount.toString()); (c.amount.toString() === amount.toString());
}); });
if (pendingContribution) { if (pendingContribution) {
const attributes = await this.kredits.Contribution.getById(id);
attributes.contributor = this.contributors.findBy('id', attributes.contributorId.toString());
const newContribution = Contribution.create(attributes);
this.contributions.addObject(newContribution);
this.contributions.removeObject(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());
}
}, },
handleContributionVetoed (contributionId) { async handleReimbursementAdded (id, addedByAccount, amount) {
console.debug('[kredits] ContributionVetoed event received for ', contributionId); console.debug('[kredits] ReimbursementAdded event received', { id, addedByAccount, amount });
const contribution = this.contributions.findBy('id', contributionId);
console.debug('[kredits] contribution', contribution);
if (contribution) { const pendingReimbursement = this.reimbursementsPending.find(r => {
contribution.set('vetoed', true); return r.amount.toString() === amount.toString();
contribution.set('pendingTx', null); });
if (pendingReimbursement) {
console.debug('[kredits] Found a pending reimbursement matching the event. Replacing it with the final record...');
this.reimbursements.removeObject(pendingReimbursement);
}
const data = await this.kredits.Reimbursement.getById(id);
const r = this.loadReimbursementFromData(data);
this.browserCache.reimbursements.setItem(r.id.toString(), r.serialize());
},
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());
} }
}, },
@@ -337,5 +780,5 @@ export default Service.extend({
this.contributors this.contributors
.findBy('address', to) .findBy('address', to)
.incrementProperty('balance', value); .incrementProperty('balance', value);
}, }
}); });
+33 -12
View File
@@ -20,7 +20,7 @@ button, input[type=submit], .button {
background-color: rgba(22, 21, 40, 0.8); background-color: rgba(22, 21, 40, 0.8);
} }
&:active, &.active { &:focus, &:active, &.active {
border-color: $primary-color; border-color: $primary-color;
} }
@@ -31,32 +31,53 @@ button, input[type=submit], .button {
&.small { &.small {
font-size: 0.86rem; font-size: 0.86rem;
padding: 0.2rem 0.8rem; padding: 0.2rem 0.8rem;
svg {
width: 1em;
height: 1em;
vertical-align: middle;
margin-right: 0.4rem;
}
} }
&.danger:not(:disabled) { &.danger:not(:disabled) {
color: $red; color: $red;
background-color: rgba(40, 21, 21, 0.6); background-color: rgba(40, 21, 21, 0.6);
border-color: rgba(40, 21, 21, 1); border-color: rgba(40, 21, 21, 1);
&:hover { background-color: rgba(40, 21, 21, 0.8); }
&:hover { &:focus, &:active, &.active { border-color: $red; }
background-color: rgba(40, 21, 21, 0.8);
}
&:active, &.active {
border-color: $red;
}
} }
&.green:not(:disabled) { &.green:not(:disabled) {
color: $green; color: $green;
background-color: rgba(21, 40, 21, 0.6); background-color: rgba(21, 40, 21, 0.6);
border-color: rgba(21, 40, 21, 1); border-color: rgba(21, 40, 21, 1);
&:hover { background-color: rgba(21, 40, 21, 0.8); }
&:focus, &:active, &.active { border-color: $green; }
}
&:hover { &.pink:not(:disabled) {
background-color: rgba(21, 40, 21, 0.8); 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; }
} }
&:active, &.active {
border-color: $green; &.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 { &.icon {
+4
View File
@@ -8,3 +8,7 @@ $red: #fb6868;
$primary-color: $blue; $primary-color: $blue;
$body-text-color: #fff; $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);
+47 -11
View File
@@ -1,21 +1,27 @@
section#add-contributor, section#add-contributor,
section#add-contribution, section#add-contribution,
section#add-contribution,
section#add-item, // TODO use for all forms for adding data
section#signup { section#signup {
form { form {
h3 {
p { font-size: 1.5rem;
margin-bottom: 1.5rem; font-weight: normal;
margin-top: 2em;
&.mg-bottom-md { margin-bottom: 1em;
margin-bottom: 2rem;
} }
p {
font-size: 1.2rem;
&.label { &.label {
margin-bottom: 1.5rem;
font-size: 1rem;
margin-bottom: .5rem; margin-bottom: .5rem;
} }
&.actions { &.actions {
margin-bottom: 1.5rem;
padding-top: 1.5rem; padding-top: 1.5rem;
text-align: center; text-align: center;
a { a {
@@ -29,9 +35,34 @@ section#signup {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
opacity: 0.7; opacity: 0.7;
> p {
margin-bottom: 1.5rem;
}
} }
input[type=text], select { fieldset {
border: none;
&.horizontal {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 2rem;
&.thirds {
grid-template-columns: 1fr 1fr 1fr;
}
&.total-amounts {
grid-template-columns: 2fr 1fr 1fr;
}
label {
}
}
}
input[type=text], input[type=url], select {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border: none; border: none;
@@ -39,17 +70,24 @@ section#signup {
background-color: rgba(22, 21, 40, 0.3); background-color: rgba(22, 21, 40, 0.3);
color: #fff; color: #fff;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: normal;
transition: border-color 0.1s linear; transition: border-color 0.1s linear;
&:focus, &.valid { &:focus, &.valid {
background-color: rgba(22, 21, 40, 0.6); background-color: rgba(22, 21, 40, 0.6);
} }
&:focus { &:focus :not(:invalid) {
border-color: $blue; border-color: $blue;
} }
&::placeholder { &::placeholder {
color: rgba(238, 238, 238, 0.5); color: rgba(238, 238, 238, 0.5);
} }
&.invalid {
border-color: $red;
}
&:disabled {
color: rgba(255, 255, 255, 0.6);
}
} }
select { select {
@@ -109,7 +147,5 @@ section#signup {
color: #fff; color: #fff;
} }
} }
} }
} }
+35
View File
@@ -0,0 +1,35 @@
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;
}
}
}
+33 -5
View File
@@ -14,6 +14,15 @@ main {
"contributions"; "contributions";
} }
&#budget {
width: 100%;
display: grid;
grid-row-gap: 2rem;
grid-template-areas:
"aside"
"content";
}
&.center-column { &.center-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -29,6 +38,16 @@ main {
} }
} }
div#content {
section {
&:first-of-type {
@include media-max(small) {
margin-top: 0;
}
}
}
}
section { section {
.content { .content {
a { a {
@@ -43,15 +62,12 @@ main {
} }
} }
// TODO Remove after switch to Tailwind CSS
&.text-center { &.text-center {
text-align: center; text-align: center;
} }
p { p {
&.mg-bottom-md {
margin-bottom: 2rem;
}
&.actions { &.actions {
text-align: center; text-align: center;
padding-top: 2rem; padding-top: 2rem;
@@ -68,7 +84,7 @@ main {
@include media-max(small) { @include media-max(small) {
padding: 2rem 1rem; padding: 2rem 1rem;
font-size: 1.4rem; font-size: 1.5rem;
} }
h2 { h2 {
@@ -111,6 +127,13 @@ main {
"stats contributions details"; "stats contributions details";
} }
} }
&#budget {
grid-column-gap: 3rem;
grid-template-columns: 2fr 4fr 2fr;
grid-template-areas:
"aside content empty";
}
} }
} }
@@ -151,4 +174,9 @@ main section {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
} }
// TODO Remove after switch to Tailwind CSS
.mb-4 { margin-bottom: 1rem; }
.mb-8 { margin-bottom: 2rem; }
} }
+17
View File
@@ -0,0 +1,17 @@
@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%}
}
+7 -2
View File
@@ -47,7 +47,7 @@ a {
section { section {
h2 { h2 {
font-size: 1.4rem; font-size: 1.5rem;
color: $primary-color; color: $primary-color;
} }
@@ -88,13 +88,18 @@ section {
@import "buttons"; @import "buttons";
@import "forms"; @import "forms";
@import "sugar";
@import "item-list";
@import "components/contribution-list"; @import "components/budget-balances";
@import "components/contribution-details"; @import "components/contribution-details";
@import "components/contribution-list";
@import "components/contributor-list"; @import "components/contributor-list";
@import "components/contributor-profile"; @import "components/contributor-profile";
@import "components/expense-list";
@import "components/external-account-link"; @import "components/external-account-link";
@import "components/loading-spinner"; @import "components/loading-spinner";
@import "components/reimbursement-list";
@import "components/topbar"; @import "components/topbar";
@import "components/topbar-account-panel"; @import "components/topbar-account-panel";
@import "components/user-avatar"; @import "components/user-avatar";
@@ -0,0 +1,42 @@
section#funds {
@include media-max(small) {
margin-bottom: 2rem;
}
table.token-balances {
opacity: 1;
transition: opacity 0.3s linear;
&.loading {
opacity: 0;
}
thead {
display: none;
}
th, td {
font-size: 1.2rem;
vertical-align: text-bottom;
}
th {
font-size: 1.5rem;
text-align: left;
padding-right: 1rem;
}
td {
text-align: right;
&.amount {
font-size: 2rem;
padding-right: 1.2rem;
}
&.fiat-amount {
color: rgba(255,255,255,0.8);
}
}
}
}
@@ -16,9 +16,9 @@ section#contribution-details {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
padding: 2rem 2rem; padding: 2rem 2rem;
border-top: 1px solid rgba(255,255,255,0.2); border-top: 1px solid $item-border-color;
border-bottom: 1px solid rgba(255,255,255,0.2); border-bottom: 1px solid $item-border-color;
background-color: rgba(255,255,255,0.1); background-color: $item-background-color;
h3 { h3 {
font-size: 1.5rem; font-size: 1.5rem;
@@ -67,7 +67,7 @@ section#contribution-details {
overflow: auto; overflow: auto;
margin-top: 2rem; margin-top: 2rem;
padding-top: 2rem; padding-top: 2rem;
border-top: 1px solid rgba(255,255,255,0.2); border-top: 1px solid $item-border-color;
font-size: 1.2rem; font-size: 1.2rem;
.icon { .icon {
@@ -24,22 +24,13 @@ main section {
ul.contribution-list { ul.contribution-list {
clear: both; clear: both;
width: 100%; width: 100%;
list-style: none;
li { li {
display: grid; display: grid;
grid-template-columns: auto 5rem 5rem; grid-template-columns: auto 5rem 5rem;
grid-row-gap: 0.5rem; grid-row-gap: 0.5rem;
padding: 0.8rem 1.2rem;
background-color: rgba(255,255,255,0.1);
font-size: 1.2rem;
border-bottom: 1px solid rgba(255,255,255,0.2);
cursor: pointer; cursor: pointer;
&:first-of-type {
border-top: 1px solid rgba(255,255,255,0.2);
}
&.confirmed { &.confirmed {
grid-template-columns: auto 5rem; grid-template-columns: auto 5rem;
} }
@@ -50,10 +41,6 @@ ul.contribution-list {
opacity: 0.6; opacity: 0.6;
} }
&.selected {
background-color: rgba(255,255,255,0.2);
}
p { p {
align-self: center; align-self: center;
margin: 0; margin: 0;
@@ -113,5 +100,4 @@ ul.contribution-list {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
} }
} }
+12 -4
View File
@@ -1,19 +1,20 @@
table.contributor-list { table.contributor-list {
position: relative;
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
tr { tr {
background-color: rgba(255,255,255,0.1); background-color: $item-background-color;
border-bottom: 1px solid rgba(255,255,255,0.2); border-bottom: 1px solid $item-border-color;
cursor: pointer; cursor: pointer;
&:first-of-type { &:first-of-type {
border-top: 1px solid rgba(255,255,255,0.2); border-top: 1px solid $item-border-color;
} }
&.selected { &.selected {
background-color: rgba(255,255,255,0.2); background-color: $item-highlighted-background-color;
} }
&.current-user { &.current-user {
@@ -48,4 +49,11 @@ table.contributor-list {
} }
} }
} }
&.loading {
@include loading-border-top;
&::before {
position: absolute;
}
}
} }
@@ -8,7 +8,7 @@ section#contributor-profile {
img { img {
margin: 0 auto; margin: 0 auto;
border: 3px solid rgba(255,255,255,0.2); border: 3px solid $item-border-color;
} }
} }
@@ -18,9 +18,9 @@ section#contributor-profile {
margin: -7.2rem 0 1.5rem; margin: -7.2rem 0 1.5rem;
padding: 6rem 1.2rem 2rem; padding: 6rem 1.2rem 2rem;
border-top: 1px solid rgba(255,255,255,0.2); border-top: 1px solid $item-border-color;
border-bottom: 1px solid rgba(255,255,255,0.2); border-bottom: 1px solid $item-border-color;
background-color: rgba(255,255,255,0.1); background-color: $item-background-color;
h2 { h2 {
text-align: center; text-align: center;
+46
View File
@@ -0,0 +1,46 @@
ul.expense-list {
grid-column-start: span 2;
width: 100%;
margin-top: 0.8rem;
border-collapse: collapse;
li {
display: grid;
grid-template-columns: auto 10rem;
grid-row-gap: 0.5rem;
padding-top: 1.2rem;
border-top: 1px solid $item-border-color;
font-size: 1.2rem;
&:not(:last-child) {
padding-bottom: 1.2rem;
}
}
h4 {
font-size: 1.2rem;
font-weight: normal;
line-height: 2rem;
}
.amount {
justify-self: end;
}
.description {
font-size: 1rem;
opacity: 0.7;
grid-column-start: span 2;
}
.tags {
button {
margin-left: 0;
}
}
.actions {
justify-self: end;
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
.loading-spinner { .loading-spinner {
margin-top: 12rem; margin-top: 12rem;
text-align: center; text-align: center;
font-size: 1.4rem; font-size: 1.5rem;
color: $primary-color; color: $primary-color;
@include media-max(small) { @include media-max(small) {
@@ -0,0 +1,54 @@
ul.reimbursement-list {
width: 100%;
> li {
display: grid;
grid-template-columns: auto 12rem;
grid-row-gap: 0.5rem;
padding-top: 1.6rem;
&.vetoed {
text-decoration: line-through;
opacity: 0.6;
}
.token-amount {
text-align: right;
img {
height: 1em;
vertical-align: middle;
margin-top: -2px;
}
.amount {
font-size: 1.5rem;
}
.symbol {
font-size: 1rem;
padding-left: 0.2rem;
}
}
}
div.meta {
grid-column-start: 1;
grid-column-end: 3;
margin-top: 0.6rem;
border-top: 1px solid $item-border-color;
display: flex;
p {
flex: 1;
padding: 1.6rem 0 1rem 0;
&.confirmation-eta {
}
&.actions {
text-align: right;
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<main id="budget">
<div id="aside">
<!-- <section id="main&#45;nav"> -->
<!-- <header> -->
<!-- <h2>Budget</h2> -->
<!-- </header> -->
<!-- <div class="content"> -->
<!-- <ul> -->
<!-- <li> -->
<!-- <LinkTo @route="budget.expenses">Expenses</LinkTo> -->
<!-- </li> -->
<!-- <li> -->
<!-- <a href="#">Rewards</a> -->
<!-- </li> -->
<!-- </ul> -->
<!-- </div> -->
<!-- </section> -->
<section id="funds">
<header>
<h2>Community funds</h2>
</header>
<div class="content">
<BudgetBalances />
</div>
</section>
</div>
<div id="content">
{{#if this.reimbursementsUnconfirmed}}
<section id="expenses-unconfirmed">
<header class="with-nav">
<h2>Proposed Reimbursements</h2>
<nav>
<LinkTo @route="reimbursements.new" @title="Submit a reimbursement" class="button small green">add</LinkTo>
</nav>
</header>
<div class="content">
<ReimbursementList @items={{this.reimbursementsUnconfirmed}} />
</div>
</section>
{{/if}}
{{#if this.reimbursementsConfirmed}}
<section id="expenses-confirmed">
<header class="with-nav">
<h2>Confirmed Expenses</h2>
<nav>
<LinkTo @route="reimbursements.new" @title="Submit a reimbursement" class="button small green">add</LinkTo>
</nav>
</header>
<div class="content">
<ReimbursementList @items={{this.reimbursementsConfirmed}} />
</div>
</section>
{{/if}}
</div>
<div id="empty">
</div>
</main>
+1
View File
@@ -0,0 +1 @@
{{outlet}}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-tag"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path><line x1="7" y1="7" x2="7.01" y2="7"></line></svg>

After

Width:  |  Height:  |  Size: 355 B

+1 -2
View File
@@ -6,8 +6,7 @@
</header> </header>
<div class="content"> <div class="content">
<AddContribution @contributors={{this.sortedContributors}} <AddContribution @attributes={{this.model.params}}
@attributes={{this.model.params}}
@save={{action "save"}} /> @save={{action "save"}} />
</div> </div>
</section> </section>
+37 -3
View File
@@ -29,7 +29,8 @@
<div class="content"> <div class="content">
<ContributorList @contributorList={{this.kreditsToplist}} <ContributorList @contributorList={{this.kreditsToplist}}
@showUnconfirmedKredits={{this.showUnconfirmedKredits}} @showUnconfirmedKredits={{this.showUnconfirmedKredits}}
@selectedContributorId={{this.selectedContributorId}} /> @selectedContributorId={{this.selectedContributorId}}
@loading={{this.kredits.syncContributors.isRunning}} />
<p class="stats"> <p class="stats">
<span class="number">{{await this.kredits.totalKreditsEarned}}</span> kredits confirmed and issued to <span class="number">{{await this.kredits.totalKreditsEarned}}</span> kredits confirmed and issued to
@@ -44,6 +45,7 @@
</div> </div>
</section> </section>
{{#if this.contributionsConfirmed}}
<section id="contributions-by-type"> <section id="contributions-by-type">
<header> <header>
<h2>Contributions by type</h2> <h2>Contributions by type</h2>
@@ -52,13 +54,16 @@
<ChartContributionsByType @contributions={{this.contributions}} /> <ChartContributionsByType @contributions={{this.contributions}} />
</div> </div>
</section> </section>
{{/if}}
</div> </div>
<div id="contributions"> <div id="contributions">
{{#if this.contributionsUnconfirmed}} {{#if this.contributionsUnconfirmed}}
<section id="contributions-unconfirmed"> <section id="contributions-unconfirmed">
<header class="with-nav"> <header class="with-nav">
<h2>Latest Contributions</h2> <h2>
Latest Contributions
</h2>
<nav> <nav>
<button type="button" <button type="button"
onclick={{action "toggleQuickFilterUnconfirmed"}} onclick={{action "toggleQuickFilterUnconfirmed"}}
@@ -78,11 +83,13 @@
@vetoContribution={{action "vetoContribution"}} @vetoContribution={{action "vetoContribution"}}
@contractInteractionEnabled={{this.kredits.hasAccounts}} @contractInteractionEnabled={{this.kredits.hasAccounts}}
@selectedContributionId={{this.selectedContributionId}} @selectedContributionId={{this.selectedContributionId}}
@showQuickFilter={{this.showQuickFilterUnconfirmed}} /> @showQuickFilter={{this.showQuickFilterUnconfirmed}}
@loading={{this.kredits.syncContributions.isRunning}}/>
</div> </div>
</section> </section>
{{/if}} {{/if}}
{{#if this.contributionsConfirmed}}
<section id="contributions-confirmed"> <section id="contributions-confirmed">
<header class="with-nav"> <header class="with-nav">
<h2>Confirmed Contributions</h2> <h2>Confirmed Contributions</h2>
@@ -101,6 +108,33 @@
@showQuickFilter={{this.showQuickFilterConfirmed}} /> @showQuickFilter={{this.showQuickFilterConfirmed}} />
</div> </div>
</section> </section>
{{/if}}
{{#if this.showFullContributionSync}}
<section id="sync-all-contributions">
{{#if this.kredits.fetchMissingContributions.isIdle}}
<p class="mb-4">
There are
<strong>{{this.kredits.missingHistoricContributionsCount}}</strong>
earlier contributions, which are not currently loaded/displayed.
</p>
<p>
You can fetch all historic data in one go, and have it stored locally in
your browser:
<button type="button" {{on "click" (perform this.kredits.fetchMissingContributions)}} class="small">
fetch all data
</button>
</p>
{{else}}
<p class="mb-4">
Syncing data. Please be patient...
</p>
<p>
(You can leave this website anytime and sync missing data when you come back.)
</p>
{{/if}}
</section>
{{/if}}
</div> </div>
<div id="details"> <div id="details">
@@ -19,7 +19,7 @@
<h3>{{this.model.description}}</h3> <h3>{{this.model.description}}</h3>
<p> <p>
Kind: {{this.model.kind}} Kind: {{this.model.kind}}
<br>Status: {{contribution-status this.model}} <br>Status: {{item-status this.model}}
</p> </p>
{{#if this.model.url}} {{#if this.model.url}}
<p> <p>
@@ -31,7 +31,7 @@
<div class="actions"> <div class="actions">
<p> <p>
<a href="https://rinkeby.etherscan.io/address/{{this.model.account}}" class="button small" target="_blank" rel="noopener noreferrer">Inspect Ethereum transactions</a> <a href="https://explorer.testnet.rsk.co/address/{{this.model.account}}" class="button small" target="_blank" rel="noopener noreferrer">Inspect Rootstock transactions</a>
{{#if this.model.ipfsHash}} {{#if this.model.ipfsHash}}
<a href="{{this.ipfsGatewayUrl}}/{{this.model.ipfsHash}}" class="button small" target="_blank" rel="noopener noreferrer">Inspect IPFS profile</a> <a href="{{this.ipfsGatewayUrl}}/{{this.model.ipfsHash}}" class="button small" target="_blank" rel="noopener noreferrer">Inspect IPFS profile</a>
{{/if}} {{/if}}
+13
View File
@@ -0,0 +1,13 @@
<main class="center-column">
<section id="add-item">
<header>
<h2>Submit a Reimbursement</h2>
</header>
<div class="content">
<AddReimbursement @attributes={{this.model.params}} />
</div>
</section>
</main>
+30
View File
@@ -0,0 +1,30 @@
<header>
<h2>Complete your contributor profile</h2>
</header>
<div class="content text-lg">
<p class="mb-8">
Kredits allow you to to earn rewards for your contributions, in the form of
dynamic open-source grants. As a regular contributor, you can also take
part in the community's project governance and finances.
</p>
<p>
In order to interact with the system you will need a
<a href="https://rootstock.io" target="_blank" rel="noopener noreferrer">Rootstock</a>
wallet/account.
</p>
<form>
<p>
<label>
Rootstock address:<br>
<Input @type="text" @value={{this.accountAddress}} placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4" class={{if this.isValidEthAccount "valid" ""}} />
</label>
</p>
</form>
<p class="actions">
<button {{on "click" (fn this.completeSignup)}}
disabled={{this.signupButtonDisabled}}
type="button">
Complete my profile
</button>
</p>
</div>
+2 -1
View File
@@ -7,7 +7,8 @@
</p> </p>
<p> <p>
Why not say hi to your fellow contributors Why not say hi to your fellow contributors
<a href="https://wiki.kosmos.org/Main_Page#Community_.2F_Getting_in_touch_.2F_Getting_involved">in one of our chat rooms</a>?. <a href="https://wiki.kosmos.org/Main_Page#Community_.2F_Getting_in_touch_.2F_Getting_involved"
target="_blank" rel="noopener noreferrer">in one of our chat rooms</a>?.
</p> </p>
<p class="actions"> <p class="actions">
<LinkTo @route="dashboard" class="button small">Return to dashboard</LinkTo> <LinkTo @route="dashboard" class="button small">Return to dashboard</LinkTo>
-24
View File
@@ -1,24 +0,0 @@
<header>
<h2>Complete your contributor profile</h2>
</header>
<div class="content text-lg">
<p class="mg-bottom-md">
Kredits allow you to take part in project governance, and to earn rewards for
your contributions. For both, you will need an Ethereum wallet/account.
</p>
<form>
<p>
<label>
Ethereum account:<br>
<Input @type="text" @value={{this.ethAddress}} placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4" class={{if this.isValidEthAccount "valid" ""}} />
</label>
</p>
</form>
<p class="actions">
<button disabled={{this.signupButtonDisabled}}
onclick={{action "completeSignup"}}
type="button">
Complete my profile
</button>
</p>
</div>
-20
View File
@@ -1,20 +0,0 @@
import { computed } from '@ember/object';
import ethers from 'ethers';
export default function(dependentKey, converterMethod) {
return computed(dependentKey, {
get () {
let value = this.get(dependentKey);
if (value && ethers.utils.BigNumber.isBigNumber(value)) {
return value[converterMethod]();
} else {
return value;
}
},
set (key, value) {
const bnValue = ethers.utils.bigNumberify(value);
this.set(dependentKey, bnValue);
return bnValue[converterMethod]();
}
});
}
-17
View File
@@ -1,17 +0,0 @@
import { computed } from '@ember/object';
import ethers from 'ethers';
import formatKredits from 'kredits-web/utils/format-kredits';
export default function(dependentKey, options = {}) {
return computed(dependentKey, {
get () {
const value = this.get(dependentKey);
return formatKredits(value, options);
},
set (key, value) {
const bnValue = ethers.utils.bigNumberify(value);
this.set(dependentKey, bnValue);
return formatKredits(bnValue, options);
}
});
}
+5
View File
@@ -0,0 +1,5 @@
export default function() {
return (navigator.languages && navigator.languages.length) ?
navigator.languages[0] :
navigator.language;
}
+2 -3
View File
@@ -2,7 +2,6 @@
// Code from https://github.com/HeroicEric/ember-group-by (MIT licensed) // Code from https://github.com/HeroicEric/ember-group-by (MIT licensed)
// //
import { A } from '@ember/array'; import { A } from '@ember/array';
import { get } from '@ember/object';
import { isPresent } from '@ember/utils'; import { isPresent } from '@ember/utils';
export default function groupBy (collection, property) { export default function groupBy (collection, property) {
@@ -11,11 +10,11 @@ export default function groupBy (collection, property) {
if (items) { if (items) {
items.forEach(function(item) { items.forEach(function(item) {
let value = get(item, property); let value = item[property];
let group = groups.findBy('value', value); let group = groups.findBy('value', value);
if (isPresent(group)) { if (isPresent(group)) {
get(group, 'items').push(item); group.items.push(item);
} else { } else {
group = { property: property, value: value, items: [item] }; group = { property: property, value: value, items: [item] };
groups.push(group); groups.push(group);
+8
View File
@@ -0,0 +1,8 @@
export default function isValidAmount(inputAmount) {
const amount = parseFloat(inputAmount);
if (Number.isNaN(amount)) {
return false;
} else {
return amount > 0;
}
}
+24
View File
@@ -0,0 +1,24 @@
export default function processContributionData(data, options={}) {
const processed = {}
if (data.confirmedAtBlock && (typeof data.confirmedAtBlock.toNumber === 'function')) {
processed.confirmedAt = data.confirmedAtBlock.toNumber();
} else if (data.confirmedAt !== 'undefined') {
processed.confirmedAt = data.confirmedAt;
}
const otherProperties = [
'id', 'contributorId', 'amount', 'vetoed', 'ipfsHash', 'kind',
'description', 'url', 'date', 'time', 'pendingTx'
];
otherProperties.forEach(prop => {
processed[prop] = data[prop];
});
if (options.includeDetails) {
processed.details = data.details;
}
return processed;
}
+18
View File
@@ -0,0 +1,18 @@
export default function processContributorData(data) {
const processed = {
balance: data.balanceInt,
totalKreditsEarned: data.totalKreditsEarned,
contributionsCount: data.contributionsCount?.toNumber()
}
const otherProperties = [
'id', 'account', 'accounts', 'ipfsHash', 'isCore', 'kind', 'name', 'url',
'github_username', 'github_uid', 'wiki_username', 'zoom_display_name'
];
otherProperties.forEach(prop => {
processed[prop] = data[prop];
});
return processed;
}
+22
View File
@@ -0,0 +1,22 @@
export default function processReimbursementData(data) {
const processed = {
amount: data.amount.toNumber()
}
if (data.confirmedAtBlock && (typeof data.confirmedAtBlock.toNumber === 'function')) {
processed.confirmedAt = data.confirmedAtBlock.toNumber();
} else if (data.confirmedAt !== 'undefined') {
processed.confirmedAt = data.confirmedAt;
}
const otherProperties = [
'id', 'recipientId', 'token', 'vetoed', 'ipfsHash',
'expenses', 'pendingTx'
];
otherProperties.forEach(prop => {
processed[prop] = data[prop];
});
return processed;
}
+8
View File
@@ -0,0 +1,8 @@
export default function (file) {
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onload = event => resolve(event.target.result)
reader.onerror = error => reject(error)
reader.readAsText(file)
})
}
+29
View File
@@ -0,0 +1,29 @@
import config from 'kredits-web/config/environment';
export default async function () {
const networkName = config.web3NetworkName;
const chainId = config.web3ChainId;
const chainIdHex = `0x${Number(chainId).toString(16)}`;
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: chainIdHex }]
});
} catch (err) {
// This error code indicates that the chain has not been added to MetaMask
if (err.code === 4902) {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: chainIdHex,
chainName: networkName,
rpcUrls: [ config.web3ProviderUrl ],
nativeCurrency: { name: 'tRBTC', symbol: 'tRBTC', decimals: 18 }
}]
});
} else {
console.warn('Failed to switch chains:', err.message);
}
}
}
+20 -21
View File
@@ -25,28 +25,29 @@ module.exports = function(environment) {
// when it is created // when it is created
}, },
browserify: { web3ProviderUrl: 'https://rsk-testnet.kosmos.org',
tests: true, web3ChainId: 31,
transform: [ web3NetworkName: 'RSK Testnet',
["babelify", {
presets: ["es2015"],
global: true
}]
]
},
web3ProviderUrl: 'https://rinkeby.infura.io/v3/d4f788b7a6584f7db2fc3c268d4d09e9', githubConnectUrl: 'https://hal8000.kosmos.chat/kredits/signup/connect/github',
web3RequiredNetwork: 'rinkeby', githubSignupUrl: 'https://hal8000.kosmos.chat/kredits/signup/github',
githubConnectUrl: 'https://hal8000.chat.kosmos.org/kredits/signup/connect/github',
githubSignupUrl: 'https://hal8000.chat.kosmos.org/kredits/signup/github',
ipfs: { ipfs: {
host: 'ipfs.kosmos.org', host: 'ipfs.kosmos.org',
port: '5444', port: '5444',
protocol: 'https', protocol: 'https',
gatewayUrl: 'https://ipfs.kosmos.org/ipfs' gatewayUrl: 'https://ipfs.kosmos.org/ipfs'
} },
tokens: {
// TODO this is still the WBTC address, since contracts currently
// requires a token address for reimbursements
'BTC': '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599'
},
btcBalanceAPI: 'https://api.kosmos.org/kredits/onchain_btc_balance',
corsProxy: 'https://cors.5apps.com/?uri='
}; };
if (environment === 'development') { if (environment === 'development') {
@@ -55,7 +56,6 @@ module.exports = function(environment) {
// ENV.APP.LOG_TRANSITIONS = true; // ENV.APP.LOG_TRANSITIONS = true;
// ENV.APP.LOG_TRANSITIONS_INTERNAL = true; // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
// ENV.APP.LOG_VIEW_LOOKUPS = true; // ENV.APP.LOG_VIEW_LOOKUPS = true;
ENV.web3ProviderUrl = 'https://rinkeby.infura.io/v3/d4f788b7a6584f7db2fc3c268d4d09e9';
ENV.githubConnectUrl = 'http://localhost:8888/kredits/signup/connect/github'; ENV.githubConnectUrl = 'http://localhost:8888/kredits/signup/connect/github';
ENV.githubSignupUrl = 'http://localhost:8888/kredits/signup/github'; ENV.githubSignupUrl = 'http://localhost:8888/kredits/signup/github';
@@ -82,13 +82,12 @@ module.exports = function(environment) {
if (process.env.WEB3_PROVIDER_URL) { if (process.env.WEB3_PROVIDER_URL) {
ENV.web3ProviderUrl = process.env.WEB3_PROVIDER_URL; ENV.web3ProviderUrl = process.env.WEB3_PROVIDER_URL;
ENV.web3RequiredNetwork = null;
} }
if (process.env.KREDITS_DAO_ADDRESS) { if (process.env.WEB3_CHAIN_ID) {
ENV.kreditsKernelAddress = process.env.KREDITS_DAO_ADDRESS; ENV.web3ChainId = parseInt(process.env.WEB3_CHAIN_ID);
} }
if (process.env.KREDITS_APM_DOMAIN) { if (process.env.WEB3_NETWORK_NAME) {
ENV.kreditsApmDomain = process.env.KREDITS_APM_DOMAIN; ENV.web3NetworkName = process.env.WEB3_NETWORK_NAME;
} }
return ENV; return ENV;
+6 -6
View File
@@ -17,12 +17,12 @@ module.exports = function(defaults) {
webpack: { webpack: {
module: { module: {
rules: [ rules: [
{ // {
// inline json files instead of using fs.readFileSync // // inline json files instead of using fs.readFileSync
enforce: 'post', // enforce: 'post',
test: /kosmos-schemas\/index\.js$/, // test: /kosmos-schemas\/index\.js$/,
loader: 'transform-loader?brfs' // loader: 'transform-loader?brfs'
} // }
] ]
}, },
node: { node: {
+5
View File
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true
}
}
+32597 -14977
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -1,6 +1,6 @@
{ {
"name": "kredits-web", "name": "kredits-web",
"version": "1.16.0", "version": "2.0.0",
"private": true, "private": true,
"description": "Contribution dashboard of the Kosmos project", "description": "Contribution dashboard of the Kosmos project",
"repository": "https://github.com/67P/kredits-web", "repository": "https://github.com/67P/kredits-web",
@@ -17,66 +17,67 @@
"start": "ember serve", "start": "ember serve",
"test": "npm-run-all lint:* test:*", "test": "npm-run-all lint:* test:*",
"test:ember": "ember test", "test:ember": "ember test",
"start:local": "WEB3_PROVIDER_URL=http://localhost:7545 ember serve", "start:local": "WEB3_PROVIDER_URL=http://localhost:8545 WEB3_CHAIN_ID=1337 WEB3_NETWORK_NAME='Hardhat Devchain' ember serve",
"build": "ember build --environment=production", "build": "ember build --environment=production",
"build-prod": "rm -rf release/* && ember build -prod --output-path release", "build-prod": "rm -rf release/* && ember build -prod --output-path release",
"preversion": "npm test", "preversion": "npm test",
"version": "npm run build-prod && git add release/", "version": "npm run build-prod && git add release/",
"postversion": "git push origin master",
"deploy": "git push 5apps master" "deploy": "git push 5apps master"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.19.1",
"@babel/plugin-proposal-decorators": "^7.19.3",
"@ember/optional-features": "^1.3.0", "@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0", "@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0", "@glimmer/tracking": "^1.0.0",
"babel-eslint": "^10.1.0", "@kredits/contracts": "^7.3.0",
"babel-preset-es2015": "^6.22.0", "babel-preset-es2015": "^6.22.0",
"babelify": "^7.3.0",
"brfs": "^2.0.2",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-auto-import": "^1.5.3", "ember-auto-import": "^1.12.1",
"ember-awesome-macros": "0.41.0", "ember-awesome-macros": "0.41.0",
"ember-cli": "~3.18.0", "ember-cli": "~3.18.0",
"ember-cli-app-version": "^3.2.0", "ember-cli-app-version": "^3.2.0",
"ember-cli-babel": "^7.19.0", "ember-cli-babel": "^7.20.4",
"ember-cli-chart": "^3.6.0", "ember-cli-chart": "^3.6.0",
"ember-cli-dependency-checker": "^3.2.0", "ember-cli-dependency-checker": "^3.2.0",
"ember-cli-htmlbars": "^4.3.1", "ember-cli-htmlbars": "^5.1.2",
"ember-cli-inject-live-reload": "^2.0.2", "ember-cli-inject-live-reload": "^2.0.2",
"ember-cli-moment-shim": "^3.7.1", "ember-cli-moment-shim": "^3.8.0",
"ember-cli-sass": "^10.0.1", "ember-cli-sass": "^10.0.1",
"ember-cli-sri": "^2.1.1", "ember-cli-sri": "^2.1.1",
"ember-cli-uglify": "^3.0.0", "ember-cli-uglify": "^3.0.0",
"ember-concurrency": "^1.1.7",
"ember-concurrency-decorators": "^2.0.1",
"ember-export-application-global": "^2.0.1", "ember-export-application-global": "^2.0.1",
"ember-fetch": "^8.0.1", "ember-fetch": "^8.0.1",
"ember-flatpickr": "^2.16.2", "ember-flatpickr": "^2.16.2",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^2.1.1",
"ember-macro-helpers": "0.17.0", "ember-macro-helpers": "0.17.0",
"ember-maybe-import-regenerator": "^0.1.6", "ember-maybe-import-regenerator": "^0.1.6",
"ember-moment": "^7.8.1", "ember-moment": "^8.0.0",
"ember-promise-helpers": "^1.0.9", "ember-promise-helpers": "^1.0.9",
"ember-qunit": "^4.6.0", "ember-qunit": "^4.6.0",
"ember-resolver": "^8.0.0", "ember-resolver": "^8.0.0",
"ember-source": "~3.18.0", "ember-source": "~3.18.0",
"ember-template-lint": "^2.6.0", "ember-template-lint": "^2.8.0",
"ember-truth-helpers": "github:jmurphyau/ember-truth-helpers#31a14373a31f1f82c77537720549b47a95c28e5f", "ember-truth-helpers": "^3.0.0",
"eslint": "^7.0.0", "eslint": "^8.14.0",
"eslint-plugin-ember": "^8.4.0", "eslint-plugin-ember": "^11.0.6",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"ethers": "^4.0.47", "ethers": "^5.4.7",
"kosmos-schemas": "^2.1.0", "fetch-mock": "^9.10.7",
"kredits-contracts": "^5.5.0",
"loader.js": "^4.7.0", "loader.js": "^4.7.0",
"localforage": "^1.7.3",
"ndjson": "github:hugomrdias/ndjson#feat/readable-stream3", "ndjson": "github:hugomrdias/ndjson#feat/readable-stream3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"qunit-dom": "^1.2.0", "qunit-dom": "^1.2.0",
"sass": "^1.26.5", "sass": "^1.26.7",
"transform-loader": "^0.2.4", "transform-loader": "^0.2.4",
"tv4": "^1.3.0", "tv4": "^1.3.0",
"web3-utils": "^1.2.7" "web3-utils": "^1.7.3"
}, },
"engines": { "engines": {
"node": "10.* || >= 12" "node": ">= 14"
}, },
"ember": { "ember": {
"edition": "octane" "edition": "octane"
+7
View File
@@ -0,0 +1,7 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="64" width="64" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<g transform="translate(0.00630876,-0.00301984)">
<path fill="#f7931a" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="#FFF" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1 +0,0 @@
{"version":3,"sources":[],"names":[],"mappings":"","file":"auto-import-fastboot.js"}
@@ -1,2 +0,0 @@
//# sourceMappingURL=auto-import-fastboot-81f919f3d82c38703c7b0de8c4c4a874.map

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