initial commit 🐡

This commit is contained in:
Matthias Kretschmann 2019-05-05 13:34:21 +02:00
commit a296148f5b
Signed by: m
GPG Key ID: 606EEEF3C479A91F
28 changed files with 1007 additions and 0 deletions

7
.babelrc Normal file
View File

@ -0,0 +1,7 @@
{
"presets": ["@babel/env", "@babel/react"],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-runtime"
]
}

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# EditorConfig is awesome: http://EditorConfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
[*.css]
indent_size = 4

27
.eslintrc Normal file
View File

@ -0,0 +1,27 @@
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"prettier",
"prettier/react"
],
"parser": "babel-eslint",
"plugins": ["react"],
"rules": {
"quotes": ["error", "single"],
"semi": ["error", "never"]
},
"parserOptions": {
"ecmaVersion": 2017,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"env": {
"browser": true,
"es6": true,
"node": true,
"jest": true
}
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
yarn.lock
package-lock.json
builds
dist

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}

11
.stylelintrc Normal file
View File

@ -0,0 +1,11 @@
{
"extends": [
"stylelint-config-standard",
"./node_modules/prettier-stylelint/config.js"
],
"rules": {
"indentation": 4,
"declaration-empty-line-before": null,
"number-leading-zero": "never"
}
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 Matthias Kretschmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.

27
README.md Normal file
View File

@ -0,0 +1,27 @@
# ocean-balance
> Simple Electron-based desktop app to retrieve and display your total Ocean balances.
## Usage
Clone and run:
```bash
# Clone this repository
git clone git@github.com:kremalicious/ocean-balance.git
cd ocean-balance
# Install dependencies
npm install
# Run the app in dev mode
npm start
```
## Configuration
## Build package
```bash
npm run build
npm run create-installer-mac
```

5
constants.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
accounts: ['ETH ADDRESS 1', 'ETH ADDRESS 2'],
refreshInterval: '1m',
oceanTokenContract: '0x985dd3D42De1e256d09e1c10F112bCCB8015AD41'
}

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "ocean-balance",
"productName": "Ocean",
"version": "1.0.0",
"description": "A minimal Electron application",
"main": "./src/main.js",
"scripts": {
"test": "eslint ./src/**/*.{js,jsx} && stylelint ./app/*.css",
"start": "webpack-dev-server --hot --host 0.0.0.0 --config=./webpack.dev.config.js",
"build": "webpack --config webpack.build.config.js && npm run package:mac",
"package:mac": "electron-packager . --overwrite --asar=true --platform=darwin --arch=x64 --icon=src/images/app.icns --prune=true --out=./builds && open ./builds",
"package:win": "electron-packager . --overwrite --asar=true --platform=win32 --arch=ia32 --icon=assets/icons/win/icon.ico --prune=true --out=./builds --version-string.CompanyName=CE --version-string.FileDescription=CE --version-string.ProductName=\"Coinbase\"",
"package:linux": "electron-packager . --overwrite --asar=true --platform=linux --arch=x64 --icon=assets/icons/png/1024x1024.png --prune=true --out=./builds",
"create-installer-mac": "electron-installer-dmg ./builds/Ocean-darwin-x64/Ocean.app Ocean --out=./builds --overwrite --icon=app/app.icns",
"create-installer-win": "node installers/windows/createinstaller.js"
},
"repository": "https://github.com/kremalicious/ocean-balance.git",
"author": "Matthias Kretschmann",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1",
"react": "^16.8.6",
"react-dom": "^16.8.6"
},
"devDependencies": {
"@babel/core": "^7.4.4",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"@babel/preset-react": "^7.0.0",
"@babel/runtime": "^7.4.4",
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.5",
"css-loader": "^2.1.1",
"electron": "^5.0.0",
"electron-devtools-installer": "^2.2.4",
"electron-installer-dmg": "^2.0.0",
"electron-packager": "^13.1.1",
"eslint": "^5.16.0",
"eslint-config-prettier": "^4.2.0",
"eslint-plugin-react": "^7.13.0",
"file-loader": "^3.0.1",
"html-webpack-plugin": "^3.2.0",
"prettier": "^1.17.0",
"prettier-stylelint": "^0.4.2",
"style-loader": "^0.23.1",
"stylelint": "^10.0.1",
"stylelint-config-standard": "^18.3.0",
"webpack": "^4.30.0",
"webpack-cli": "^3.3.1",
"webpack-dev-server": "^3.3.1"
}
}

120
src/App.css Normal file
View File

@ -0,0 +1,120 @@
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: #141414;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 13px;
}
html.fullscreen {
font-size: 24px;
}
#root {
position: relative;
font-size: 1rem;
line-height: 1.3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol';
font-feature-settings: 'kern' 1, 'liga' 1, 'calt' 1, 'pnum' 1, 'tnum' 0,
'onum' 0, 'lnum' 0, 'dlig' 1;
color: #e2e2e2;
display: flex;
flex-wrap: wrap;
justify-content: center;
transform: translate3d(0, 0, 0);
-webkit-app-region: drag;
-webkit-user-select: none;
}
.app__content {
padding: 5% 7%;
cursor: default;
height: calc(100vh - 35px);
transition: .15s ease-out;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
width: 100%;
}
.fullscreen .app__content {
transform: translate3d(0, -36px, 0);
}
.number-unit-wrap {
display: flex;
width: 100%;
flex-wrap: wrap;
justify-content: space-around;
}
.number-unit {
text-align: center;
flex: 1 1 20%;
margin-top: 5%;
padding-left: 2%;
padding-right: 2%;
}
.number {
margin: 0;
transition: .15s ease-out;
font-weight: 400;
-webkit-app-region: no-drag;
-webkit-user-select: auto;
font-size: 1rem;
display: inline-block;
padding: 0 .3rem;
animation: fadein .5s ease-out forwards;
border-radius: 4px;
}
.updated {
animation: updated .5s ease-out forwards;
}
.label {
color: #8b98a9;
font-size: .85rem;
display: block;
white-space: nowrap;
}
@keyframes updated {
0% {
background: rgba(255, 255, 255, .2);
}
100% {
background: rgba(255, 255, 255, 0);
}
}
@keyframes fadein {
0% {
opacity: 0;
}
50% {
background: rgba(255, 255, 255, .2);
}
100% {
opacity: 1;
background: rgba(255, 255, 255, 0);
}
}

27
src/App.jsx Normal file
View File

@ -0,0 +1,27 @@
import './App.css'
import React, { PureComponent } from 'react'
import { webFrame } from 'electron'
import AppProvider from './store/AppProvider'
import Titlebar from './components/Titlebar'
import Accounts from './components/Accounts'
//
// Disable zooming
//
webFrame.setVisualZoomLevelLimits(1, 1)
webFrame.setLayoutZoomLevelLimits(0, 0)
class App extends PureComponent {
render() {
return (
<AppProvider>
<Titlebar />
<div className="app__content">
<Accounts />
</div>
</AppProvider>
)
}
}
export default App

View File

@ -0,0 +1,40 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import { fiatFormatter, numberFormatter } from '../util/moneyFormatter'
class Account extends PureComponent {
static propTypes = {
isNativeShown: PropTypes.bool.isRequired,
account: PropTypes.shape({
address: PropTypes.string.isRequired,
balance: PropTypes.shape({
ocean: PropTypes.number.isRequired,
eur: PropTypes.number.isRequired
}).isRequired
})
}
render() {
const { balance, address } = this.props.account
const { ocean, eur } = balance
return (
<div className="number-unit">
<h1 className="number">
{this.props.isNativeShown ? (
<span className="balance-native">{fiatFormatter('EUR', eur)}</span>
) : (
<span className="balance" title={numberFormatter(ocean)}>
{numberFormatter(ocean) || 0} Ọ
</span>
)}
</h1>
<span className="label" title={address}>
{address.substring(0, 12)}...
</span>
</div>
)
}
}
export default Account

View File

@ -0,0 +1,29 @@
.main {
width: 100%;
padding: 5%;
background: #303030;
border-radius: 5px;
border: .1rem solid #41474e;
min-height: 186px;
display: flex;
align-items: center;
flex-wrap: wrap;
position: relative;
}
.number-unit-wrap--accounts {
min-height: 55px;
}
.number-unit--main {
padding-bottom: 5%;
border-bottom: 1px solid #41474e;
}
.number-unit--main .number {
font-size: 2.5rem;
}
.number-unit--main .label {
font-size: .95rem;
}

View File

@ -0,0 +1,40 @@
import React, { PureComponent } from 'react'
import { Consumer } from '../store/createContext'
import Total from './Total'
import Account from './Account'
import './Accounts.css'
export default class Accounts extends PureComponent {
state = {
isNativeShown: false
}
toggleBalances = () => {
this.setState({ isNativeShown: !this.state.isNativeShown })
}
render() {
return (
<main className="main">
<Total />
<div
className="number-unit-wrap number-unit-wrap--accounts"
onClick={this.toggleBalances}
>
<Consumer>
{({ accounts }) =>
accounts.map((account, i) => (
<Account
key={i}
account={account}
isNativeShown={this.state.isNativeShown}
/>
))
}
</Consumer>
</div>
</main>
)
}
}

View File

@ -0,0 +1,48 @@
.titlebar {
align-self: flex-start;
width: 100%;
height: 35px;
line-height: 35px;
text-align: center;
user-select: none;
background: linear-gradient(to top, #ccc 0%, #d6d6d6 1px, #ebebeb 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .5);
transition: opacity .15s ease-out;
}
.dark .titlebar {
background: linear-gradient(to top, #141416 0%, #38383c 1px, #3f3f44 100%);
box-shadow: none;
}
.fullscreen .titlebar {
opacity: 0;
}
.blur .titlebar {
background: #f6f6f6;
}
.blur.dark .titlebar {
background: linear-gradient(to top, #141416 0%, #2d2a32 1px, #2d2a32 100%);
}
.header-title {
line-height: 35px;
height: 35px;
font-weight: 400;
font-size: 13px;
color: #555;
}
.dark .header-title {
color: #b6b3ba;
}
.blur .header-title {
color: #b6b6b6;
}
.blur.dark .header-title {
color: #67666e;
}

View File

@ -0,0 +1,10 @@
import './Titlebar.css'
import React from 'react'
const Titlebar = () => (
<header className="titlebar">
<span className="header-title">Ocean</span>
</header>
)
export default Titlebar

33
src/components/Total.jsx Normal file
View File

@ -0,0 +1,33 @@
import React from 'react'
import { Consumer } from '../store/createContext'
import { numberFormatter } from '../util/moneyFormatter'
const calculateTotalBalance = accounts => {
const balanceTotalArray = []
for (const account of accounts) {
balanceTotalArray.push(account.balance.ocean)
}
// Convert array to numbers and add numbers together
const balanceTotal = balanceTotalArray.reduce(
(a, b) => Number(a) + Number(b),
0
)
return numberFormatter(balanceTotal)
}
const Total = () => (
<div className="number-unit number-unit--main">
<Consumer>
{({ accounts }) => {
const total = calculateTotalBalance(accounts)
return <h1 className="number">{total || 0} Ọ</h1>
}}
</Consumer>
<span className="label">Total balance</span>
</div>
)
export default Total

BIN
src/images/app.icns Normal file

Binary file not shown.

13
src/index.js Normal file
View File

@ -0,0 +1,13 @@
import React from 'react'
import { render } from 'react-dom'
import App from './App'
document.body.style.backgroundColor = '#141414'
// Since we are using HtmlWebpackPlugin WITHOUT a template, we should create our own root node in the body element before rendering into it
let root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
// Now we can render our application into it
render(<App />, document.getElementById('root'))

144
src/main.js Normal file
View File

@ -0,0 +1,144 @@
const path = require('path')
const { app, BrowserWindow, systemPreferences, ipcMain } = require('electron')
let mainWindow
// Keep a reference for dev mode
let isDev = false
if (
process.defaultApp ||
/[\\/]electron-prebuilt[\\/]/.test(process.execPath) ||
/[\\/]electron[\\/]/.test(process.execPath)
) {
isDev = true
}
const width = 550
const height = 380
const createWindow = () => {
mainWindow = new BrowserWindow({
width: width,
height: height,
minWidth: width,
minHeight: height,
// maxWidth: width,
// maxHeight: height,
acceptFirstMouse: true,
titleBarStyle: 'hiddenInset',
fullscreenWindowTitle: true,
backgroundColor: '#141414',
frame: false,
show: false,
webPreferences: {
nodeIntegration: true
}
})
mainWindow.loadURL(
isDev
? 'http://localhost:8080'
: `file://${path.join(__dirname, '../dist/index.html')}`
)
if (isDev) {
const {
default: installExtension,
REACT_DEVELOPER_TOOLS
} = require('electron-devtools-installer')
installExtension(REACT_DEVELOPER_TOOLS)
.then(name => {
console.log(`Added Extension: ${name}`) // eslint-disable-line no-console
})
.catch(err => {
console.log('An error occurred: ', err) // eslint-disable-line no-console
})
}
mainWindow.once('ready-to-show', () => {
mainWindow.show()
mainWindow.focus()
})
mainWindow.on('closed', () => {
mainWindow = null
})
//
// Events
//
mainWindow.on('enter-full-screen', () => {
mainWindow.webContents.executeJavaScript(
'document.getElementsByTagName(\'html\')[0].classList.add(\'fullscreen\')'
)
})
mainWindow.on('leave-full-screen', () => {
mainWindow.webContents.executeJavaScript(
'document.getElementsByTagName(\'html\')[0].classList.remove(\'fullscreen\')'
)
})
mainWindow.on('blur', () => {
mainWindow.webContents.executeJavaScript(
'document.getElementsByTagName(\'html\')[0].classList.add(\'blur\')'
)
})
mainWindow.on('focus', () => {
mainWindow.webContents.executeJavaScript(
'document.getElementsByTagName(\'html\')[0].classList.remove(\'blur\')'
)
})
// Make window bigger automatically when devtools are opened
mainWindow.webContents.on('devtools-opened', () => {
mainWindow.setSize(1024, 420, true)
})
mainWindow.webContents.on('devtools-closed', () => {
mainWindow.setSize(width, height, true)
})
// Load menubar menu items
require('./menu.js')
}
app.on('ready', () => {
createWindow()
// Switch to user theme on start, and on reload
mainWindow.webContents.on('dom-ready', () => switchTheme())
})
// Quit when all windows are closed.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
const switchTheme = () => {
if (systemPreferences.isDarkMode()) {
mainWindow.webContents.executeJavaScript(
'document.getElementsByTagName(\'html\')[0].classList.add(\'dark\')'
)
} else {
mainWindow.webContents.executeJavaScript(
'document.getElementsByTagName(\'html\')[0].classList.remove(\'dark\')'
)
}
}
// Listen for theme changes in System Preferences
systemPreferences.subscribeNotification(
'AppleInterfaceThemeChangedNotification',
() => switchTheme()
)

154
src/menu.js Normal file
View File

@ -0,0 +1,154 @@
const { app, Menu } = require('electron')
const template = [
{
label: 'Edit',
submenu: [
{
role: 'undo'
},
{
role: 'redo'
},
{
type: 'separator'
},
{
role: 'cut'
},
{
role: 'copy'
},
{
role: 'paste'
},
{
role: 'pasteandmatchstyle'
},
{
role: 'delete'
},
{
role: 'selectall'
}
]
},
{
label: 'View',
submenu: [
{
role: 'reload'
},
{
role: 'forcereload'
},
{
role: 'toggledevtools'
},
{
type: 'separator'
},
{
role: 'togglefullscreen'
}
]
},
{
role: 'window',
submenu: [
{
role: 'minimize'
},
{
role: 'close'
}
]
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click() {
require('electron').shell.openExternal('https://electron.atom.io')
}
}
]
}
]
if (process.platform === 'darwin') {
template.unshift({
label: app.getName(),
submenu: [
{
role: 'about'
},
{
type: 'separator'
},
{
role: 'services',
submenu: []
},
{
type: 'separator'
},
{
role: 'hide'
},
{
role: 'hideothers'
},
{
role: 'unhide'
},
{
type: 'separator'
},
{
role: 'quit'
}
]
})
// Edit menu
template[1].submenu.push(
{
type: 'separator'
},
{
label: 'Speech',
submenu: [
{
role: 'startspeaking'
},
{
role: 'stopspeaking'
}
]
}
)
// Window menu
template[3].submenu = [
{
role: 'close'
},
{
role: 'minimize'
},
{
role: 'zoom'
},
{
type: 'separator'
},
{
role: 'front'
}
]
}
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)

72
src/store/AppProvider.jsx Normal file
View File

@ -0,0 +1,72 @@
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import ms from 'ms'
import { Provider } from './createContext'
import { accounts, refreshInterval, oceanTokenContract } from '../../constants'
export default class AppProvider extends PureComponent {
static propTypes = {
children: PropTypes.any.isRequired
}
state = {
accounts: []
}
componentDidMount() {
this.setBalances()
setInterval(this.setBalances, ms(refreshInterval))
}
componentWillUnmount() {
this.clearAccounts()
}
clearAccounts() {
this.setState({ accounts: [] })
}
fetchBalance = async account => {
try {
const response = await fetch(
`https://api.etherscan.io/api?module=account&action=tokenbalance&contractaddress=${oceanTokenContract}&address=${account}`
)
if (response.status !== 200) {
return console.log('Non-200 response: ' + response.status) // eslint-disable-line
}
const json = await response.json()
if (!json) return
const balance = (json.result /= 1000000000000000000) // Convert from wei 10^18
return balance
} catch (error) {
console.log('Error parsing etherscan.io json:' + error) // eslint-disable-line
}
}
setBalances = () => {
this.clearAccounts()
accounts.map(async account => {
const oceanBalance = await this.fetchBalance(account)
const newAccount = {
address: account,
balance: {
ocean: oceanBalance || 0,
eur: 0
}
}
this.setState(prevState => ({
accounts: [...prevState.accounts, newAccount]
}))
})
}
render() {
return <Provider value={this.state}>{this.props.children}</Provider>
}
}

View File

@ -0,0 +1,5 @@
import { createContext } from 'react'
const { Provider, Consumer } = createContext()
export { Provider, Consumer }

View File

@ -0,0 +1,18 @@
const locale = navigator.language
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat
const numberFormatter = number =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(number)
const fiatFormatter = (currency, number) =>
new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(number)
export { numberFormatter, fiatFormatter }

14
webpack.build.config.js Normal file
View File

@ -0,0 +1,14 @@
const common = require('./webpack.common.config')
module.exports = Object.assign({}, common, {
mode: 'production',
output: {
publicPath: './'
},
stats: {
colors: true,
children: false,
chunks: false,
modules: false
}
})

42
webpack.common.config.js Normal file
View File

@ -0,0 +1,42 @@
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
// Any directories you will be adding code/files into, need to be added to this array so webpack will pick them up
const defaultInclude = [path.resolve(__dirname, 'src')]
module.exports = {
entry: path.resolve(__dirname, 'src') + '/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
include: defaultInclude
},
{
test: /\.(js|jsx)?$/,
use: ['babel-loader'],
include: defaultInclude
},
{
test: /\.(jpe?g|png|gif)$/,
use: ['file-loader?name=img/[name]__[hash:base64:5].[ext]'],
include: defaultInclude
},
{
test: /\.(eot|svg|ttf|woff|woff2)$/,
use: ['file-loader?name=font/[name]__[hash:base64:5].[ext]'],
include: defaultInclude
}
]
},
resolve: {
extensions: ['*', '.js', '.jsx']
},
target: 'electron-renderer',
plugins: [new HtmlWebpackPlugin()]
}

24
webpack.dev.config.js Normal file
View File

@ -0,0 +1,24 @@
const path = require('path')
const common = require('./webpack.common.config')
const { spawn } = require('child_process')
module.exports = Object.assign({}, common, {
mode: 'development',
output: {
publicPath: '/'
},
devtool: 'cheap-source-map',
devServer: {
contentBase: path.resolve(__dirname, 'dist'),
stats: 'minimal',
before: () => {
spawn('electron', ['.'], {
shell: true,
env: process.env,
stdio: 'inherit'
})
.on('close', () => process.exit(0))
.on('error', spawnError => console.error(spawnError)) // eslint-disable-line no-console
}
}
})