Initial commit

This commit is contained in:
Marco van Dijk 2022-03-01 23:53:37 +01:00
commit c2c07aadf7
53 changed files with 4735 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
package-lock.json
yarn.lock
log.txt
build/
TODO

2
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/node_modules
/src/config.js

View File

@ -0,0 +1,16 @@
module.exports = {
apps : [{
name : "backend",
script : "./src/index.js",
cwd : "/var/www/backend",
env_production: {
NODE_ENV: "production"
},
env_development: {
NODE_ENV: "development"
},
env_local: {
NODE_ENV: "local"
}
}]
}

34
backend/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "LivepeerEvents",
"version": "0.4.2",
"description": "",
"main": "./src/index.js",
"module": "./src/server.js",
"scripts": {
"prod": "NODE_ENV=production pm2 start ecosystem.config.js",
"start": "NODE_ENV=production node ./src/index.js",
"dev": "NODE_ENV=development nodemon ./src/index.js",
"local": "NODE_ENV=local nodemon ./src/index.js"
},
"keywords": [],
"author": "Marco van Dijk",
"license": "WTFPL",
"dependencies": {
"@alch/alchemy-web3": "^1.2.4",
"alchemy-api": "^1.3.3",
"coinmarketcap-api": "^3.1.1",
"connect-mongo": "^3.1.2",
"crypto-js": "^3.1.9-1",
"esm": "^3.2.20",
"express": "^4.17.1",
"express-session": "^1.17.0",
"install": "^0.13.0",
"joi": "^14.3.1",
"mongoose": "^5.12.3",
"npm": "^8.5.2",
"web3": "^1.7.0"
},
"devDependencies": {
"nodemon": "^1.18.10"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
backend/src/index.js Normal file
View File

@ -0,0 +1,4 @@
//Starting point of backend. Simply imports ESM and calls the actual server.js to be loaded.
require = require("esm")(module)
module.exports = require("./server.js")

View File

@ -0,0 +1,31 @@
import mongoose from 'mongoose';
//database schema for users
const EventSchema = new mongoose.Schema({
address: {
type: String,
required: true
},
transactionHash: {
type: String,
required: true
},
transactionUrl: {
type: String,
required: true
},
name: {
type: String,
required: true
},
data: {
type: Object,
required: true
},
}, { timestamps: true });
//define variable User, which corresponds with the schema
const Event = mongoose.model('Event', EventSchema);
//export for use outside of this file
export default Event;

View File

@ -0,0 +1,31 @@
import mongoose from 'mongoose';
const timelapseSchema = new mongoose.Schema({
ownerId: {
type: mongoose.ObjectId,
ref: 'User',
required: true
},
OwnerIp: {
type: String,
required: true
},
fullFilename: {
type: String,
required: true
},
upvotes: {
type: Number,
required: false,
default: 0
},
downvotes: {
type: Number,
required: false,
default: 0
}
}, { timestamps: true });
const timelapseObj = mongoose.model('timelapseSchema', timelapseSchema);
export default timelapseObj;

View File

@ -0,0 +1,34 @@
import mongoose from 'mongoose';
//database schema for users
const UserSchema = new mongoose.Schema({
ip: {
type: String,
required: true
},
upvotedTimelapses: {
type: [mongoose.ObjectId],
ref: 'timelapseSchema',
required: false,
default: []
},
downvotedTimelapses: {
type: [mongoose.ObjectId],
ref: 'timelapseSchema',
required: false,
default: []
}
}, { timestamps: true });
//takes a database field as input, returns T/F if entry already exists.
//iterates through entire user DB, any field
UserSchema.statics.doesNotExist = async function (field) {
return await this.where(field).countDocuments() === 0;
};
//define variable User, which corresponds with the schema
const User = mongoose.model('User', UserSchema);
//export for use outside of this file
export default User;

View File

@ -0,0 +1,7 @@
//In this document all used routes are defined.
//(in this case this is just user.js and session.js)
import userRouter from './user';
import sessionRouter from './session';
import livepeerRouter from './livepeer';
export { userRouter, sessionRouter, livepeerRouter };

View File

@ -0,0 +1,233 @@
import express from "express";
import Event from '../models/event';
const fs = require('fs');
const apiRouter = express.Router();
import {
API_CMC, API_L1_HTTP, API_L2_HTTP, API_L2_WS
} from "../config";
// Get ETH price & LPT coin prices
const CoinMarketCap = require('coinmarketcap-api');
const cmcClient = new CoinMarketCap(API_CMC);
// Get gas price on ETH (L2 already gets exported by the O's themselves)
const { createAlchemyWeb3 } = require("@alch/alchemy-web3");
const web3layer1 = createAlchemyWeb3(API_L1_HTTP);
const web3layer2 = createAlchemyWeb3(API_L2_HTTP);
const web3layer2WS = createAlchemyWeb3(API_L2_WS);
// Update CMC related api calls every 5 minutes
const timeoutCMC = 300000;
let cmcPriceGet = 0;
let ethPrice = 0;
let lptPrice = 0;
let cmcQuotes = {};
let cmcCache = {};
// Update alchemy related API calls every 2 seconds
const timeoutAlchemy = 2000;
let l2Gwei = 0;
let l1Gwei = 0;
let l2block = 0;
let l1block = 0;
let arbGet = 0;
// Gas limits on common contract interactions
const redeemRewardGwei = 1053687;
const claimTicketGwei = 1333043;
const withdrawFeeGwei = 688913;
let redeemRewardCostL1 = 0;
let redeemRewardCostL2 = 0;
let claimTicketCostL1 = 0;
let claimTicketCostL2 = 0;
let withdrawFeeCostL1 = 0;
let withdrawFeeCostL2 = 0;
// Listen to smart contract emitters. Resync with DB every 5 minutes
const timeoutEvents = 300000;
let eventsCache = [];
let eventsGet = 0;
// https://arbiscan.io/address/0x35Bcf3c30594191d53231E4FF333E8A770453e40#events
const BondingManagerTargetJson = fs.readFileSync('src/abi/BondingManagerTarget.json');
const BondingManagerTargetAbi = JSON.parse(BondingManagerTargetJson);
const BondingManagerProxyAddr = "0x35Bcf3c30594191d53231E4FF333E8A770453e40";
const contractInstance = new web3layer2WS.eth.Contract(BondingManagerTargetAbi.abi, BondingManagerProxyAddr);
var BondingManagerProxyListener = contractInstance.events.allEvents(async (error, event) => {
try {
if (error) {
throw error
}
console.log('New event emitted on', BondingManagerProxyAddr);
// Push obj of event to cache and create a new entry for it in the DB
const eventObj = {
address: event.address,
transactionHash: event.transactionHash,
transactionUrl: "https://arbiscan.io/tx/" + event.transactionHash,
name: event.event,
data: event.returnValues
}
const dbObj = new Event(eventObj);
await dbObj.save();
eventsCache.push(eventObj);
}
catch (err) {
console.log("FATAL ERROR: ", err);
}
});
console.log("listening for events on", BondingManagerProxyAddr)
// Splits of the big CMC object into separate datas
const parseCmc = async function () {
try {
cmcCache = await cmcClient.getTickers({ limit: 200 });
for (var idx = 0; idx < cmcCache.data.length; idx++) {
const coinData = cmcCache.data[idx];
// Handle specific coins only for the grafana endpoint
if (coinData.symbol == "ETH") {
ethPrice = coinData.quote.USD.price;
} else if (coinData.symbol == "LPT") {
lptPrice = coinData.quote.USD.price;
}
// Sort by name->quotes for quotes endpoint
cmcQuotes[coinData.symbol] = coinData.quote.USD;
}
}
catch (err) {
res.status(400).send(err);
}
}
// Queries Alchemy for block info and fees
const parseL1Blockchain = async function () {
const l1Wei = await web3layer1.eth.getGasPrice();
l1block = await web3layer1.eth.getBlockNumber();
l1Gwei = l1Wei / 1000000000;
redeemRewardCostL1 = (redeemRewardGwei * l1Gwei) / 1000000000;
claimTicketCostL1 = (claimTicketGwei * l1Gwei) / 1000000000;
withdrawFeeCostL1 = (withdrawFeeGwei * l1Gwei) / 1000000000;
}
const parseL2Blockchain = async function () {
const l2Wei = await web3layer2.eth.getGasPrice();
l2block = await web3layer2.eth.getBlockNumber();
l2Gwei = l2Wei / 1000000000;
redeemRewardCostL2 = (redeemRewardGwei * l2Gwei) / 1000000000;
claimTicketCostL2 = (claimTicketGwei * l2Gwei) / 1000000000;
withdrawFeeCostL2 = (withdrawFeeGwei * l2Gwei) / 1000000000;
}
const parseEthBlockchain = async function () {
await Promise.all([parseL1Blockchain(), parseL2Blockchain()]);
}
// Export livepeer and eth coin prices and L1 Eth gas price
apiRouter.get("/grafana", async (req, res) => {
try {
const now = new Date().getTime();
// Update blockchain data if the cached data has expired
if (now - arbGet > timeoutAlchemy) {
await parseEthBlockchain();
arbGet = now;
}
// Update coin prices once their data has expired
if (now - cmcPriceGet > timeoutCMC) {
await parseCmc();
cmcPriceGet = now;
}
res.send({
timestamp: now,
cmcTime: cmcPriceGet,
blockchainTime: arbGet,
l1GasFeeInGwei: l1Gwei,
l2GasFeeInGwei: l2Gwei,
ethPriceInDollar: ethPrice,
lptPriceInDollar: lptPrice,
redeemRewardCostL1,
redeemRewardCostL2,
claimTicketCostL1,
claimTicketCostL2,
withdrawFeeCostL1,
withdrawFeeCostL2,
quotes: cmcQuotes
});
} catch (err) {
res.status(400).send(err);
}
});
apiRouter.get("/cmc", async (req, res) => {
try {
const now = new Date().getTime();
// Update cmc once their data has expired
if (now - cmcPriceGet > timeoutCMC) {
await parseCmc();
cmcPriceGet = now;
}
res.send(cmcCache);
} catch (err) {
res.status(400).send(err);
}
});
apiRouter.get("/blockchains", async (req, res) => {
try {
const now = new Date().getTime();
// Update blockchain data if the cached data has expired
if (now - arbGet > timeoutAlchemy) {
await parseEthBlockchain();
arbGet = now;
}
res.send({
timestamp: now,
l1block,
l2block,
blockchainTime: arbGet,
l1GasFeeInGwei: l1Gwei,
l2GasFeeInGwei: l2Gwei,
redeemRewardCostL1,
redeemRewardCostL2,
claimTicketCostL1,
claimTicketCostL2,
withdrawFeeCostL1,
withdrawFeeCostL2
});
} catch (err) {
res.status(400).send(err);
}
});
apiRouter.get("/quotes", async (req, res) => {
try {
const now = new Date().getTime();
// Update cmc once their data has expired
if (now - cmcPriceGet > timeoutCMC) {
cmcCache = await cmcClient.getTickers({ limit: 200 });
await parseCmc();
cmcPriceGet = now;
}
res.send(cmcQuotes);
} catch (err) {
res.status(400).send(err);
}
});
apiRouter.get("/getEvents", async (req, res) => {
try {
const now = new Date().getTime();
// Update cmc once their data has expired
if (now - eventsGet > timeoutEvents) {
eventsCache = await Event.find({}, {
address: 1,
transactionHash: 1,
transactionUrl: 1,
name: 1,
data: 1,
_id: 0});
eventsGet = now;
}
res.send(eventsCache);
} catch (err) {
res.status(400).send(err);
}
});
export default apiRouter;

View File

@ -0,0 +1,51 @@
import express from "express";
import User from "../models/user";
import { SESS_NAME } from "../config";
const sessionRouter = express.Router();
sessionRouter.post("", async (req, res) => {
try {
const username = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const user = await User.findOne({ ip: username });
if (user) {
console.log("User logged in as " + user.ip);
req.session.user = {ip: user.ip};
res.send({ip: user.ip});
} else {
const newUser = new User({ ip: username});
await newUser.save();
console.log("User logged in as " + user.ip);
req.session.user = {ip: newUser.ip};
res.send({ip: newUser.ip});
}
} catch (err) {
res.status(401).send(err);
}
});
//on delete request
sessionRouter.delete("", ({ session }, res) => {
try {
const user = session.user;
if (user) {
console.log(user.username + " is logging out");
session.destroy(err => {
if (err) throw (err);
res.clearCookie(SESS_NAME);
res.send(user);
});
} else {
throw new Error('Sessie kon niet worden verwijderd');
}
} catch (err) {
res.status(422).send(err);
}
});
//on get request
sessionRouter.get("", ({ session: { user } }, res) => {
res.send({ user });
});
export default sessionRouter;

106
backend/src/routes/user.js Normal file
View File

@ -0,0 +1,106 @@
//The userRouter is used to handle user related functions
import express from 'express';
import User from '../models/user';
import timelapseObj from '../models/timelapse';
const userRouter = express.Router();
userRouter.post("/getVisitorStats", async (req, res) => {
try {
const totalUserCount = await User.countDocuments();
const activeUserCount = await User.countDocuments({ $or: [
{"upvotedTimelapses.0": { "$exists": true }},
{"downvotedTimelapses.0": { "$exists": true }}
]});
res.send({totalVisitorCount: totalUserCount,
activeVisitorCount: activeUserCount});
} catch (err) {
res.status(400).send(err);
}
});
userRouter.post("/getCurrentUserVotes", async (req, res) => {
console.log(req.session);
try {
const userObj = await User.findOne({ip: req.session.user.ip}, {upvotedTimelapses: 1, downvotedTimelapses: 1, _id: 0});
res.send(userObj);
} catch (err) {
res.status(400).send(err);
}
});
userRouter.post("/getScoreByTimelapeFilename", async (req, res) => {
try {
const filename = req.body.fullFilename;
const scoreObj = await timelapseObj.findOne({ fullFilename: filename }, { upvotes: 1, downvotes: 1, _id: 1 });
if (scoreObj) {
res.send(scoreObj);
} else {
res.send({upvotes: 0, downvotes: 0});
}
} catch (err) {
res.status(400).send(err);
}
});
userRouter.post("/setVoteOnTimelapse", async (req, res) => {
try {
var voteValue = req.body.voteValue;
const fullFilename = req.body.fullFilename;
const username = req.session.user.ip;
console.log("voteValue="+voteValue);
console.log("fullFilename="+fullFilename);
console.log("username="+username);
const currentUserObj = await User.findOne({ip: username});
console.log(currentUserObj);
if (!currentUserObj){
throw new Error("User not logged in");
}
var currentTimelapseObj = await timelapseObj.findOne({fullFilename: fullFilename});
if(!currentTimelapseObj){
currentTimelapseObj = new timelapseObj({ ownerId: currentUserObj._id, OwnerIp: currentUserObj.ip, fullFilename: fullFilename});
await currentTimelapseObj.save();
}else{
console.log(currentTimelapseObj);
if(currentUserObj.upvotedTimelapses.length && currentUserObj.upvotedTimelapses.includes(currentTimelapseObj._id)){
currentTimelapseObj.upvotes = currentTimelapseObj.upvotes - 1;
await currentTimelapseObj.save()
await User.updateOne({ip: username},{
$pullAll: {
upvotedTimelapses: [currentTimelapseObj._id],
},
});
} else if(currentUserObj.downvotedTimelapses.length && currentUserObj.downvotedTimelapses.includes(currentTimelapseObj._id)){
currentTimelapseObj.downvotes = currentTimelapseObj.downvotes - 1;
await currentTimelapseObj.save()
await User.updateOne({ip: username},{
$pullAll: {
downvotedTimelapses: [currentTimelapseObj._id],
},
});
}
}
if (voteValue == 1){
await User.updateOne(
{ ip: username },
{ $push: { upvotedTimelapses: currentTimelapseObj._id } }
);
currentTimelapseObj.upvotes = currentTimelapseObj.upvotes + 1;
}else if (voteValue == -1){
await User.updateOne(
{ ip: username },
{ $push: { downvotedTimelapses: currentTimelapseObj._id } }
);
currentTimelapseObj.downvotes = currentTimelapseObj.downvotes + 1;
}
await currentTimelapseObj.save();
console.log(currentTimelapseObj);
res.send(currentTimelapseObj);
} catch (err) {
res.status(400).send(err);
}
});
export default userRouter;

89
backend/src/server.js Normal file
View File

@ -0,0 +1,89 @@
//Server logic. Imports all necessary routes, models, etc
import express from 'express';
import mongoose from 'mongoose';
import session from "express-session";
import connectStore from "connect-mongo";
import { userRouter, sessionRouter, livepeerRouter } from './routes/index';
import {
PORT, NODE_ENV, MONGO_URI, SESS_NAME, SESS_SECRET, SESS_LIFETIME , MONGO_URI_DEV, MONGO_URI_LOCAL
} from "./config";
const { NODE_ENV: mode } = process.env;
(async () => {
try {
//first connect with DB
if (mode == "production"){
await mongoose.connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false});
}else if (mode == "development"){
await mongoose.connect(MONGO_URI_DEV, { useNewUrlParser: true, useFindAndModify: false});
}else if (mode == "local"){
await mongoose.connect(MONGO_URI_LOCAL, { useNewUrlParser: true, useFindAndModify: false});
}else{
await mongoose.connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false});
}
console.log('MongoDB connected on ' + mode);
//web application framework
const app = express();
//disable powered by message, which contains information on
app.disable('x-powered-by');
//parses and validates requests to make things harder for malicious actors
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
//import session module
const MongoStore = connectStore(session);
//define session data
app.use(session({
name: SESS_NAME,
//TODO: change secret in config file
secret: SESS_SECRET,
//define where to store them
store: new MongoStore({
mongooseConnection: mongoose.connection,
collection: 'session',
ttl: parseInt(SESS_LIFETIME) / 1000,
}),
saveUninitialized: false,
proxy: NODE_ENV === "production",
resave: false,
//cookie to send to users
cookie: {
sameSite: true,
secure: NODE_ENV === 'production',
maxAge: parseInt(SESS_LIFETIME)
}
}));
//define default router
const apiRouter = express.Router();
//which handles any request starting with /api
app.use('/api', apiRouter);
//but changes to a different router for different paths
apiRouter.use('/users', userRouter);
apiRouter.use('/session', sessionRouter);
apiRouter.use('/livepeer', livepeerRouter);
// error handler
app.use(function(err, req, res, next) {
res.locals.message = err.message;
// set locals, only providing error in development
//res.locals.error = req.app.get('env') === 'development' ? err : {};
// add this line to include winston logging
console.log(`${err.status || 500} - ${err.message} - ${req.originalUrl} - ${req.method} - ${req.ip}`);
// render the error page
res.status(err.status || 500);
res.render('error');
});
//actually start server
app.listen(PORT, "0.0.0.0", function () {
console.log(`Listening on port ${PORT}`);
});
//and log any errors to the console
} catch (err) {
console.log(err);
}
})();

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "LivepeerEvents",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"ethers": "^5.4.4",
"http": "^0.0.1-security",
"https": "^1.0.0",
"md5": "^2.3.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-indiana-drag-scroll": "^2.1.0",
"react-markdown": "^7.1.1",
"react-redux": "^7.2.6",
"react-router-dom": "^6.0.2",
"react-scripts": "3.2.0",
"redux": "^4.1.2",
"redux-thunk": "^2.4.1",
"styled-components": "^5.3.3"
},
"proxy": "http://localhost:42609",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

21
public/50x.html Executable file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>

BIN
public/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
public/dvdvideo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
public/eth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
public/github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/grafana.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

21
public/index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#855dfe" />
<meta
name="LivepeerEvents"
content="marco@stronk.tech"
/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>LivepeerEvents</title>
<meta name="title" content="Stronk" />
<meta name="description" content="Contact: marco@stronk.tech" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

BIN
public/livepeer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
public/loading.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

14
public/manifest.json Normal file
View File

@ -0,0 +1,14 @@
{
"short_name": "LivepeerEvents",
"name": "nframe.nl",
"icons": [
{
"src": "favicon.png",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#855dfe",
"background_color": "#020643"
}

1
public/metamask.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.9 KiB

142
public/mistserver.svg Normal file
View File

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg4368"
viewBox="0 0 121.875 90.268784"
height="25.475857mm"
width="34.395832mm"
sodipodi:docname="mistserverblue.svg"
inkscape:version="0.92.2 5c3e80d, 2017-08-06">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="995"
id="namedview23"
showgrid="false"
inkscape:zoom="3.4441545"
inkscape:cx="91.00278"
inkscape:cy="71.749755"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4368"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<defs
id="defs4370" />
<metadata
id="metadata4373">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="matrix(0.60179374,0,0,0.60179374,-30.069506,-264.76137)"
id="g847"
style="stroke-width:1.55784273">
<path
inkscape:connector-curvature="0"
style="fill:#b5d3e2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path54-7"
d="m 147.69186,528.8951 5.05228,-3.32212 -49.37108,-75.06356 -5.050441,3.32211 49.369241,75.06357 v 0" />
<path
inkscape:connector-curvature="0"
style="fill:#b5d3e2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path48"
d="M 234.602,528.24099 H 67.850477 v -6.04541 H 234.602 v 6.04541" />
<path
inkscape:connector-curvature="0"
style="fill:#b5d3e2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path54"
d="m 154.76084,528.89509 -5.05228,-3.32212 49.37108,-75.06356 5.05043,3.32211 -49.36923,75.06357 v 0" />
<path
inkscape:connector-curvature="0"
style="fill:#b5d3e2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path64"
d="m 165.20657,526.60296 c 0,7.72396 -6.25913,13.98126 -13.97941,13.98126 -7.72029,0 -13.98126,-6.2573 -13.98126,-13.98126 0,-7.72029 6.26097,-13.97941 13.98126,-13.97941 7.72028,0 13.97941,6.25912 13.97941,13.97941" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path70"
d="m 150.08815,473.69925 51.00726,-23.80203 2.6551,5.42999 -49.49635,25.31477" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path46"
d="M 70.198071,524.45454 151.2267,567.71942 232.25275,524.45454 201.74568,455.4456 H 100.70661 Z M 151.2267,574.57186 62.400222,527.14098 96.76964,449.40019 H 205.6832 l 34.36905,77.74079 -88.82555,47.43088 v 0" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path50"
d="m 84.726929,525.72222 c 0,9.59968 -7.78108,17.38075 -17.379651,17.38075 -9.599122,0 -17.381124,-7.78106 -17.381124,-17.38075 0,-9.59784 7.782002,-17.38076 17.381124,-17.38076 9.598571,0 17.379651,7.78292 17.379651,17.38076" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path52"
d="m 252.48632,525.72222 c 0,9.59968 -7.78107,17.38075 -17.38074,17.38075 -9.59968,0 -17.37892,-7.78106 -17.37892,-17.38075 0,-9.59784 7.77924,-17.38076 17.37892,-17.38076 9.59967,0 17.38074,7.78292 17.38074,17.38076" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path58"
d="m 215.45866,452.79969 c 0,7.0975 -5.75242,12.8481 -12.84624,12.8481 -7.09565,0 -12.84625,-5.7506 -12.84625,-12.8481 0,-7.09381 5.7506,-12.84625 12.84625,-12.84625 7.09382,0 12.84624,5.75244 12.84624,12.84625" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path60"
d="m 112.68722,452.79969 c 0,7.0975 -5.75133,12.8481 -12.84587,12.8481 -7.095288,0 -12.847536,-5.7506 -12.847536,-12.8481 0,-7.09381 5.752247,-12.84625 12.847535,-12.84625 7.094541,0 12.845871,5.75244 12.845871,12.84625" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path62"
d="m 168.22835,477.73675 c 0,9.39145 -7.61157,17.00487 -17.00119,17.00487 -9.38963,0 -17.00304,-7.61342 -17.00304,-17.00487 0,-9.38962 7.61341,-17.0012 17.00304,-17.0012 9.38962,0 17.00119,7.61158 17.00119,17.0012" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path72"
d="m 233.57896,529.08671 -83.88016,-47.98547 4.56768,-6.72714 82.36743,49.49637 -3.05495,5.21624 v 0" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path76"
d="m 168.60607,572.57453 c 0,9.59968 -7.78107,17.37891 -17.38075,17.37891 -9.59784,0 -17.37891,-7.77923 -17.37891,-17.37891 0,-9.59968 7.78107,-17.38076 17.37891,-17.38076 9.59968,0 17.38075,7.78108 17.38075,17.38076" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path72-5"
d="m 68.873783,529.0867 83.880167,-47.98547 -4.56768,-6.72714 -82.367437,49.49637 3.05495,5.21624 v 0" />
<path
inkscape:connector-curvature="0"
style="fill:#8cb3cf;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.55784273"
id="path70-3"
d="m 152.36456,473.69925 -51.00726,-23.80203 -2.655101,5.42999 49.496351,25.31477" />
<rect
y="483.39197"
x="147.35124"
height="95"
width="7.75"
id="rect1151"
style="opacity:1;vector-effect:none;fill:#8cb3cf;fill-opacity:1;stroke:none;stroke-width:2.07712364;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
public/prometheus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *

34
src/App.js Normal file
View File

@ -0,0 +1,34 @@
import * as React from "react";
import Home from './home.js';
import Startup from './loadingScreen.js';
import Grafana from './grafana.js';
import Livepeer from './livepeer.js';
import {
BrowserRouter as Router,
Routes,
Route
} from "react-router-dom";
export default function App() {
return (
<Startup>
<Router>
<Routes>
<Route exact path='/livepeer' element={<Livepeer />} />
<Route exact path='/orchestrator' element={<Grafana />} />
<Route path='/' element={<Home />} />
</Routes>
<div id="dvdlogo">
<svg width="153px" height="69px">
<g>
<path d="M140.186,63.52h-1.695l-0.692,5.236h-0.847l0.77-5.236h-1.693l0.076-0.694h4.158L140.186,63.52L140.186,63.52z M146.346,68.756h-0.848v-4.545l0,0l-2.389,4.545l-1-4.545l0,0l-1.462,4.545h-0.771l1.924-5.931h0.695l0.924,4.006l2.078-4.006 h0.848V68.756L146.346,68.756z M126.027,0.063H95.352c0,0-8.129,9.592-9.654,11.434c-8.064,9.715-9.523,12.32-9.779,13.02 c0.063-0.699-0.256-3.304-3.686-13.148C71.282,8.7,68.359,0.062,68.359,0.062H57.881V0L32.35,0.063H13.169l-1.97,8.131 l14.543,0.062h3.365c9.336,0,15.055,3.747,13.467,10.354c-1.717,7.24-9.91,10.416-18.545,10.416h-3.24l4.191-17.783H10.502 L4.34,37.219h20.578c15.432,0,30.168-8.13,32.709-18.608c0.508-1.906,0.443-6.67-0.764-9.527c0-0.127-0.063-0.191-0.127-0.444 c-0.064-0.063-0.127-0.509,0.127-0.571c0.128-0.062,0.383,0.189,0.445,0.254c0.127,0.317,0.19,0.57,0.19,0.57l13.083,36.965 l33.344-37.6h14.1h3.365c9.337,0,15.055,3.747,13.528,10.354c-1.778,7.24-9.972,10.416-18.608,10.416h-3.238l4.191-17.783h-14.481 l-6.159,25.976h20.576c15.434,0,30.232-8.13,32.709-18.608C152.449,8.193,141.523,0.063,126.027,0.063L126.027,0.063z M71.091,45.981c-39.123,0-70.816,4.512-70.816,10.035c0,5.59,31.693,10.034,70.816,10.034c39.121,0,70.877-4.444,70.877-10.034 C141.968,50.493,110.212,45.981,71.091,45.981L71.091,45.981z M68.55,59.573c-8.956,0-16.196-1.523-16.196-3.365 c0-1.84,7.239-3.303,16.196-3.303c8.955,0,16.195,1.463,16.195,3.303C84.745,58.05,77.505,59.573,68.55,59.573L68.55,59.573z" />
</g>
</svg>
</div>
</Router>
</Startup>
);
}

27
src/actions/error.js Normal file
View File

@ -0,0 +1,27 @@
{/* Ability to receive and clear errors as dispatch actions
Requires this in files where errors can be received:
const mapStateToProps = ({ errors }) => ({
errors
});
*/}
export const RECEIVE_ERRORS = "RECEIVE_ERRORS";
export const RECEIVE_NOTIFICATIONS = "RECEIVE_NOTIFICATIONS";
export const CLEAR_ERRORS = "CLEAR_ERRORS";
export const receiveErrors = ({ message }) => ({
type: RECEIVE_ERRORS,
message
});
export const clearErrors = () => ({
type: CLEAR_ERRORS
});
const receiveNotification = (message) => ({
type: RECEIVE_NOTIFICATIONS,
message
})
export const receiveNotifications = (title, message) => async dispatch => {
return dispatch(receiveNotification({title, message}));
};

43
src/actions/livepeer.js Normal file
View File

@ -0,0 +1,43 @@
import * as apiUtil from "../util/livepeer";
import { receiveErrors } from "./error";
export const RECEIVE_QUOTES = "RECEIVE_QUOTES";
export const RECEIVE_BLOCKCHAIN_DATA = "RECEIVE_BLOCKCHAIN_DATA";
export const RECEIVE_EVENTS = "RECEIVE_EVENTS";
const setQuotes = message => ({
type: RECEIVE_QUOTES, message
});
const setBlockchainData = message => ({
type: RECEIVE_BLOCKCHAIN_DATA, message
});
const setEvents = message => ({
type: RECEIVE_EVENTS, message
});
export const getQuotes = () => async dispatch => {
const response = await apiUtil.getQuotes();
const data = await response.json();
if (response.ok) {
return dispatch(setQuotes(data));
}
return dispatch(receiveErrors(data));
};
export const getBlockchainData = () => async dispatch => {
const response = await apiUtil.getBlockchainData();
const data = await response.json();
if (response.ok) {
return dispatch(setBlockchainData(data));
}
return dispatch(receiveErrors(data));
};
export const getEvents = () => async dispatch => {
const response = await apiUtil.getEvents();
const data = await response.json();
if (response.ok) {
return dispatch(setEvents(data));
}
return dispatch(receiveErrors(data));
};

18
src/actions/session.js Normal file
View File

@ -0,0 +1,18 @@
import * as apiUtil from "../util/session";
import { receiveErrors } from "./error";
export const RECEIVE_CURRENT_USER = "RECEIVE_CURRENT_USER";
const receiveCurrentUser = user => ({
type: RECEIVE_CURRENT_USER,
user
});
export const login = () => async dispatch => {
const response = await apiUtil.login();
const data = await response.json();
if (response.ok) {
return dispatch(receiveCurrentUser(data));
}
return dispatch(receiveErrors(data));
};

50
src/actions/user.js Normal file
View File

@ -0,0 +1,50 @@
import * as apiUtil from "../util/user";
import { receiveErrors } from "./error";
export const RECEIVE_VISITOR_STATS = "RECEIVE_VISITOR_STATS";
export const RECEIVE_CURRENT_USER_VOTES = "RECEIVE_CURRENT_USER_VOTES";
const setVisitorStats = message => ({
type: RECEIVE_VISITOR_STATS, message
});
const setCurrentUserVotes = message => ({
type: RECEIVE_CURRENT_USER_VOTES, message
});
export const getVisitorStats = () => async dispatch => {
const response = await apiUtil.getVisitorStats();
const data = await response.json();
if (response.ok) {
return dispatch(setVisitorStats(data));
}
return dispatch(receiveErrors(data));
};
export const getCurrentUserVotes = () => async dispatch => {
const response = await apiUtil.getCurrentUserVotes();
const data = await response.json();
if (response.ok) {
return dispatch(setCurrentUserVotes(data));
}
return dispatch(receiveErrors(data));
};
export const getScoreByTimelapeFilename = (fullFilename) => async dispatch => {
const response = await apiUtil.getScoreByTimelapeFilename(fullFilename);
const data = await response.json();
if (response.ok) {
return data;
}
return dispatch(receiveErrors(data));
};
export const setVoteOnTimelapse = (voteValue, fullFilename) => async dispatch => {
const response = await apiUtil.setVoteOnTimelapse(voteValue, fullFilename);
const data = await response.json();
if (response.ok) {
return data;
}
return dispatch(receiveErrors(data));
};

47
src/eventButton.js Normal file
View File

@ -0,0 +1,47 @@
import React from "react";
const EventButton = (obj) => {
let eventSpecificInfo;
if (obj.name == "EarningsClaimed") {
eventSpecificInfo = <div className="row">
<p>(Round {obj.data.endRound}) Claim: {obj.data.delegator} earned {obj.data.rewards / 1000000000000000000} Eth @ Orchestrator {obj.data.delegate}</p>
</div>
} else if (obj.name == "Unbond") {
eventSpecificInfo = <div className="row">
<p>(Round {obj.data.withdrawRound}) Unbond: {obj.data.delegator} unbonded {obj.data.amount / 1000000000000000000} Eth @ Orchestrator {obj.data.delegate}</p>
</div>
} else if (obj.name == "TransferBond") {
eventSpecificInfo = <div className="row">
<p>TransferBond: transfered bond worth {obj.data.amount / 1000000000000000000} Eth from {obj.data.oldDelegator} to {obj.data.newDelegator}</p>
</div>
} else if (obj.name == "Bond") {
eventSpecificInfo = <div className="row">
<p>Bond: {obj.data.delegator} transfered bond worth {obj.data.bondedAmount / 1000000000000000000} Eth from {obj.data.oldDelegate} to {obj.data.newDelegate}</p>
</div>
} else if (obj.name == "Rebond") {
eventSpecificInfo = <div className="row">
<p>Rebond: {obj.data.delegator} @ {obj.data.delegate}</p>
</div>
} else if (obj.name == "WithdrawFees") {
eventSpecificInfo = <div className="row">
<p>WithdrawFees: {obj.data.amount / 1000000000000000000} Eth {obj.data.delegator} to {obj.data.recipient}</p>
</div>
} else {
eventSpecificInfo = <div className="row">
<p>UNIMPLEMENTED: {obj.event}</p>
</div>
}
return (
<div className="row">
<a href={obj.transactionUrl}>
<button className="waveButton">
<img alt="" src="livepeer.png" width="30" height="30" />
{eventSpecificInfo}
</button>
</a>
</div>
)
}
export default EventButton;

71
src/grafana.js Normal file
View File

@ -0,0 +1,71 @@
import React, { useEffect, useState } from "react";
import './style.css';
import {
Navigate, useParams
} from "react-router-dom";
const Grafana = () => {
let params = useParams();
const [redirectToHome, setRedirectToHome] = useState(false);
useEffect(() => {
}, [])
if (redirectToHome) {
return <Navigate push to="/" />;
}
return (
<div className="stroke" style={{ margin: 0, padding: 0 }}>
<div className="row" style={{ margin: 0, padding: 0 }}>
<button className="homeButton" onClick={() => {
setRedirectToHome(true);
}}>
<img alt="" src="/livepeer.png" width="100em" height="100em" />
</button>
</div>
<div className="stroke" style={{ margin: 0, padding: 0 }}>
<div className="flexContainer">
<div className="stroke" style={{ marginTop: 0, marginBottom: 5, paddingBottom: 0 }}>
<div className="stroke roundedOpaque" style={{}}>
<div className="row">
<h2> <img alt="" src="livepeer.png" width="30" height="30" /> <a href="https://explorer.livepeer.org/accounts/0x847791cbf03be716a7fe9dc8c9affe17bd49ae5e/">Livepeer Orchestrator</a></h2>
</div>
<div className="stroke roundedOpaque" style={{ borderRadius: "1em", backgroundColor: "#111217" }}>
<div className="flexContainer" style={{ justifyContent: "center" }}>
<iframe className="halfGrafana" src="https://grafana.stronk.tech/d-solo/71b6OZ0Gz/orchestrator-overview?orgId=1&refresh=5s&theme=dark&panelId=23763572081" height="200" frameBorder="0"></iframe>
<iframe className="halfGrafana" src="https://grafana.stronk.tech/d-solo/71b6OZ0Gz/orchestrator-overview?orgId=1&refresh=5s&theme=dark&panelId=23763572082" height="200" frameBorder="0"></iframe>
</div>
<div className="flexContainer" style={{ justifyContent: "center" }}>
<iframe className="fullGrafana" src="https://grafana.stronk.tech/d-solo/71b6OZ0Gz/orchestrator-overview?orgId=1&refresh=5s&theme=dark&panelId=23763572014" height="200" frameBorder="0"></iframe>
</div>
<div className="flexContainer" style={{ justifyContent: "center" }}>
<iframe className="fullGrafana" src="https://grafana.stronk.tech/d-solo/71b6OZ0Gz/orchestrator-overview?orgId=1&refresh=5s&theme=dark&panelId=23763572077" height="400" frameBorder="0"></iframe>
</div>
<div className="flexContainer" style={{ justifyContent: "center" }}>
<iframe className="fullGrafana" src="https://grafana.stronk.tech/d-solo/71b6OZ0Gz/orchestrator-overview?orgId=1&refresh=5s&theme=dark&panelId=23763572056" height="200" frameBorder="0"></iframe>
</div>
<div className="flexContainer" style={{ justifyContent: "center" }}>
<iframe className="fullGrafana" src="https://grafana.stronk.tech/d-solo/71b6OZ0Gz/orchestrator-overview?orgId=1&refresh=5s&theme=dark&panelId=23763572032" height="200" frameBorder="0"></iframe>
</div>
<div className="flexContainer" style={{ justifyContent: "center" }}>
<iframe className="fullGrafana" src="https://grafana.stronk.tech/d-solo/71b6OZ0Gz/orchestrator-overview?orgId=1&from=now-2d&to=now&refresh=5s&theme=dark&panelId=23763572040" height="400" frameBorder="0"></iframe>
</div>
<div className="row">
<a href="https://grafana.stronk.tech/d/71b6OZ0Gz/orchestrator-overview?orgId=1&refresh=5s&theme=dark">
<button className="waveButton">
<img alt="" src="grafana.png" width="30" height="30" />
<p>Full Statistics</p>
</button>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Grafana;

116
src/home.js Normal file
View File

@ -0,0 +1,116 @@
import * as React from "react";
import './style.css';
import {
Navigate
} from "react-router-dom";
import { connect } from "react-redux";
import {
getVisitorStats
} from "./actions/user";
const mapStateToProps = (state) => {
return {
session: state.session,
userstate: state.userstate,
errors: state.errors
}
};
const mapDispatchToProps = dispatch => ({
getVisitorStats: () => dispatch(getVisitorStats())
});
class Home extends React.Component {
state = {
redirectToGrafana: false,
redirectToLPT: false
};
constructor(props) {
super(props);
}
render() {
if (this.state.redirectToRunningServices) {
return <Navigate push to="/services" />;
}
else if (this.state.redirectToWavePortal) {
return <Navigate push to="/waveportal" />;
}
if (this.state.redirectToTimelapses) {
return <Navigate push to="/timelapse" />;
}
if (this.state.redirectToTutorialMistOcto) {
return <Navigate push to="/guides/mistocto.md" />;
}
if (this.state.redirectToGrafana) {
return <Navigate push to="/orchestrator" />;
}
if (this.state.redirectToVideoNFT) {
return <Navigate push to="/videonft" />;
}
if (this.state.redirectToLPT) {
return <Navigate push to="/livepeer" />;
}
var totalVisitorCount = 0;
var activeVisitorCount = 0;
if (this.props.userstate.visitorStats) {
totalVisitorCount = this.props.userstate.visitorStats.totalVisitorCount;
activeVisitorCount = this.props.userstate.visitorStats.activeVisitorCount
}
return (
<div className="stroke" style={{ padding: 0 }}>
<div className="row" style={{ margin: 0, padding: 0 }}>
<img alt="" src="livepeer.png" width="100em" height="100em" style={{ zIndex: 10 }} />
</div>
<div className="flexContainer">
<div className="stroke roundedOpaque">
<div className="row">
<h3> Home </h3>
</div>
<div className="row">
<button className="waveButton" onClick={() => {
this.setState({ redirectToGrafana: true });
}}>
<p>Livepeer Transcoder</p>
</button>
</div>
<div className="row">
<button className="waveButton" onClick={() => {
this.setState({ redirectToLPT: true });
}}>
<p>Livepeer Blockchain</p>
</button>
</div>
</div>
</div>
<div className="alwaysOnBottom showNeverOnMobile" style={{ margin: 0, padding: 0 }}>
<div className="row" style={{ margin: 0, padding: 0 }}>
<h4 className="lightText" style={{ margin: 0, padding: 0 }}>
Connected as {this.props.session.ip || "?"}
</h4>
</div>
<div className="row" style={{ margin: 0, padding: 0 }}>
<h3 className="lightText" style={{ margin: 0, padding: 0 }}>
{totalVisitorCount} unique visitors / {activeVisitorCount} of which have interacted with this website
</h3>
</div>
</div>
<div className="alwaysOnBottomRight" style={{ margin: 0, padding: 0 }}>
<h6 className="lightText" style={{ margin: 0, padding: 0 }}>
nframe.tech / nframe.nl
</h6>
</div>
</div>
)
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Home);

19
src/index.js Normal file
View File

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import configureStore from "./store/store";
import { Provider } from "react-redux";
import { checkLoggedIn } from "./util/session";
const renderApp = preloadedState => {
const store = configureStore(preloadedState);
window.state = store.getState;
ReactDOM.render(<Provider store={store}><App /></Provider>, document.getElementById('root'));
};
(async () => renderApp(await checkLoggedIn()))();

186
src/livepeer.js Normal file
View File

@ -0,0 +1,186 @@
import * as React from "react";
import './style.css';
import {
Navigate
} from "react-router-dom";
import ScrollContainer from 'react-indiana-drag-scroll';
import { connect } from "react-redux";
import {
getQuotes, getBlockchainData, getEvents
} from "./actions/livepeer";
import EventButton from "./eventButton";
const mapStateToProps = (state) => {
return {
session: state.session,
userstate: state.userstate,
errors: state.errors,
livepeer: state.livepeerstate
}
};
const mapDispatchToProps = dispatch => ({
getQuotes: () => dispatch(getQuotes()),
getBlockchainData: () => dispatch(getBlockchainData()),
getEvents: () => dispatch(getEvents())
});
class Livepeer extends React.Component {
state = {
redirectToHome: false,
};
constructor(props) {
super(props);
}
componentDidMount() {
this.props.getQuotes();
this.props.getBlockchainData();
this.props.getEvents();
}
render() {
if (this.state.redirectToHome) {
return <Navigate push to="/" />;
}
let lptPrice = 0;
let ethPrice = 0;
let lptPriceChange24h = 0;
let ethPriceChange24h = 0;
if (this.props.livepeer.quotes) {
if (this.props.livepeer.quotes.LPT) {
lptPrice = this.props.livepeer.quotes.LPT.price;
lptPriceChange24h = this.props.livepeer.quotes.LPT.percent_change_24h;
}
if (this.props.livepeer.quotes.ETH) {
ethPrice = this.props.livepeer.quotes.ETH.price;
ethPriceChange24h = this.props.livepeer.quotes.ETH.percent_change_24h;
}
}
let blockchainTime = 0;
let l1Block = 0;
let l2Block = 0;
let l1GasFeeInGwei = 0;
let l2GasFeeInGwei = 0;
let redeemRewardCostL1 = 0;
let redeemRewardCostL2 = 0;
let claimTicketCostL1 = 0;
let claimTicketCostL2 = 0;
let withdrawFeeCostL1 = 0;
let withdrawFeeCostL2 = 0;
if (this.props.livepeer.blockchains) {
blockchainTime = this.props.livepeer.blockchains.timestamp;
l1GasFeeInGwei = this.props.livepeer.blockchains.l1GasFeeInGwei;
l2GasFeeInGwei = this.props.livepeer.blockchains.l2GasFeeInGwei;
redeemRewardCostL1 = this.props.livepeer.blockchains.redeemRewardCostL1;
redeemRewardCostL2 = this.props.livepeer.blockchains.redeemRewardCostL2;
claimTicketCostL1 = this.props.livepeer.blockchains.claimTicketCostL1;
claimTicketCostL2 = this.props.livepeer.blockchains.claimTicketCostL2;
withdrawFeeCostL1 = this.props.livepeer.blockchains.withdrawFeeCostL1;
withdrawFeeCostL2 = this.props.livepeer.blockchains.withdrawFeeCostL2;
l1Block = this.props.livepeer.blockchains.l1block;
l2Block = this.props.livepeer.blockchains.l2block;
}
let redeemRewardCostL1USD;
let redeemRewardCostL2USD;
let claimTicketCostL1USD;
let claimTicketCostL2USD;
let withdrawFeeCostL1USD;
let withdrawFeeCostL2USD;
if (l1GasFeeInGwei && ethPrice) {
if (redeemRewardCostL1) {
redeemRewardCostL1USD = redeemRewardCostL1 * ethPrice;
}
if (claimTicketCostL1) {
claimTicketCostL1USD = claimTicketCostL1 * ethPrice;
}
if (withdrawFeeCostL1) {
withdrawFeeCostL1USD = withdrawFeeCostL1 * ethPrice;
}
}
if (l2GasFeeInGwei && ethPrice) {
if (redeemRewardCostL2) {
redeemRewardCostL2USD = redeemRewardCostL2 * ethPrice;
}
if (claimTicketCostL2) {
claimTicketCostL2USD = claimTicketCostL2 * ethPrice;
}
if (withdrawFeeCostL2) {
withdrawFeeCostL2USD = withdrawFeeCostL2 * ethPrice;
}
}
let eventsList = [];
if (this.props.livepeer.events){
eventsList = this.props.livepeer.events;
}
return (
<div className="flexContainer">
<div className="stroke" style={{ margin: 0, padding: 0 }}>
</div>
<div className="stroke" style={{ margin: 0, padding: 0 }}>
<div className="row" style={{ margin: 0, padding: 0 }}>
<button className="homeButton" onClick={() => {
this.setState({ redirectToHome: true });
}}>
<img alt="" src="livepeer.png" width="100em" height="100em" />
</button>
</div>
<div className="roundedOpaque" style={{ padding: 0, width: 'unset' }}>
{eventsList.map((eventObj, idx) => {
console.log(eventObj);
// TODO: make something that groups shit as long as the eventObj.transactionUrl is the same
return <EventButton
key={eventObj.transactionUrl+idx}
transactionUrl={eventObj.transactionUrl}
transactionHash={eventObj.transactionHash}
name={eventObj.name}
data={eventObj.data}
address={eventObj.address}
/>
})}
</div>
</div >
<div className="stroke" style={{ padding: 0 }}>
<div className="separator showOnlyOnMobile" />
<div className="main-container">
<div className="content-wrapper">
<ScrollContainer className="overflow-container" hideScrollbars={false}>
<div className="overflow-content roundedOpaque" style={{ cursor: 'grab' }}>
<h3>Price Info</h3>
<h4>Current LPT price: {lptPrice}</h4>
<h4>Current LPT price change: {lptPriceChange24h}%</h4>
<h4>Current ETH price: {ethPrice}</h4>
<h4>Current ETH price change: {ethPriceChange24h}%</h4>
<h3>Cost Info</h3>
<h5>Last updated @ {blockchainTime}</h5>
<h4>Current layer 1 gas fee in GWEI: {l1GasFeeInGwei}</h4>
<h4>Current layer 1 is at block: {l1Block}</h4>
<h4>Current layer 1 cost to redeem daily reward: {redeemRewardCostL1} eth = ${redeemRewardCostL1USD}</h4>
<h4>Current layer 1 cost to claim a winning ticket: {claimTicketCostL1} eth = ${claimTicketCostL1USD}</h4>
<h4>Current layer 1 cost to withdraw Eth fees: {withdrawFeeCostL1} eth = ${withdrawFeeCostL1USD}</h4>
<h4>Current layer 2 gas fee in GWEI: {l2GasFeeInGwei}</h4>
<h4>Current layer 2 is at block: {l2Block}</h4>
<h4>Current layer 2 cost to redeem daily reward: {redeemRewardCostL2} eth = ${redeemRewardCostL2USD}</h4>
<h4>Current layer 2 cost to claim a winning ticket: {claimTicketCostL2} eth = ${claimTicketCostL2USD}</h4>
<h4>Current layer 2 cost to withdraw Eth fees: {withdrawFeeCostL2} eth = ${withdrawFeeCostL2USD}</h4>
</div>
</ScrollContainer>
</div>
</div>
</div>
</div>
);
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Livepeer);

35
src/loadingScreen.js Normal file
View File

@ -0,0 +1,35 @@
import * as React from "react";
import { connect } from "react-redux";
import {
getVisitorStats
} from "./actions/user";
import { login } from "./actions/session";
const mapStateToProps = (state) => {
return {
session: state.session,
userstate: state.userstate,
errors: state.errors
}
};
const mapDispatchToProps = dispatch => ({
getVisitorStats: () => dispatch(getVisitorStats()),
login: () => dispatch(login())
});
class Startup extends React.Component {
componentDidMount() {
this.props.login();
this.props.getVisitorStats();
}
render() {
return this.props.children;
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Startup);

View File

@ -0,0 +1,25 @@
import { RECEIVE_CURRENT_USER } from "../../actions/session";
import { CLEAR_ERRORS, RECEIVE_ERRORS, RECEIVE_NOTIFICATIONS } from "../../actions/error";
export default (state = [], { message, type }) => {
Object.freeze(state);
switch (type) {
case RECEIVE_ERRORS:
return [...state, {
title: "Foutmelding",
message: message,
}
];
case RECEIVE_NOTIFICATIONS:
return [...state, {
title: message.title,
message: message.message,
}
];
case RECEIVE_CURRENT_USER:
case CLEAR_ERRORS:
return [];
default:
return state;
}
};

View File

@ -0,0 +1,19 @@
import {
RECEIVE_QUOTES,
RECEIVE_BLOCKCHAIN_DATA,
RECEIVE_EVENTS
} from "../../actions/livepeer";
export default (state = {}, { type, message }) => {
Object.freeze(state);
switch (type) {
case RECEIVE_QUOTES:
return { ...state, quotes: message };
case RECEIVE_BLOCKCHAIN_DATA:
return { ...state, blockchains: message };
case RECEIVE_EVENTS:
return { ...state, events: message };
default:
return { ...state };
}
};

13
src/reducers/root.js Normal file
View File

@ -0,0 +1,13 @@
import { combineReducers } from "redux";
import errors from "./errors/errors";
import session from "./session/session";
import userstate from "./userstate/userstate";
import livepeerstate from "./livepeer/livepeerstate";
{/*Reducers define how the state of the application changes when actions are sent into the store. They take in the current state and the action that was performed.
This file combines all reducers in use so they are all accessible for redux*/}
export default combineReducers({
session,
errors,
userstate,
livepeerstate
});

View File

@ -0,0 +1,13 @@
import {
RECEIVE_CURRENT_USER
} from "../../actions/session";
const _nullSession = { userId: null, username: null, ip: null }
export default (state = _nullSession, { type, user }) => {
Object.freeze(state);
switch (type) {
case RECEIVE_CURRENT_USER:
return user;
default:
return state;
}
};

View File

@ -0,0 +1,16 @@
import {
RECEIVE_VISITOR_STATS,
RECEIVE_CURRENT_USER_VOTES
} from "../../actions/user";
export default (state = {}, { type, message }) => {
Object.freeze(state);
switch (type) {
case RECEIVE_VISITOR_STATS:
return { ...state, visitorStats: message};
case RECEIVE_CURRENT_USER_VOTES:
return { ...state, currentVotes: message};
default:
return { ...state };
}
};

18
src/store/store.js Normal file
View File

@ -0,0 +1,18 @@
import { createStore, applyMiddleware, compose } from "redux";
import thunk from "redux-thunk";
import reducer from "../reducers/root";
function configureStore(preloadedState) {
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, preloadedState, composeEnhancers(
applyMiddleware(thunk)
));
return store;
}
export default (preloadedState) => (
configureStore(preloadedState)
);

550
src/style.css Normal file
View File

@ -0,0 +1,550 @@
a:hover, a:visited, a:link, a:active{
text-decoration: none;
color: rgba(0, 0, 0, 0.875);
text-align: center;
justify-content: center;
align-items: center;
}
img {
margin: 5px;
-webkit-user-drag: none;
}
body {
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-image: url("/background.jpg");
background-repeat: no-repeat center center fixed;
overflow-x: hidden;
overflow-y: auto;
user-select: none;
}
code, pre{
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
h2, h3, h1, h4, h5, h6 {
text-shadow: 0.5px 0.5px 0.8px #948dff;
color: #1a1b26;
cursor: default;
text-align: center;
justify-content: center;
align-items: center;
display: flex;
justify-content: space-evenly;
}
a {
text-shadow: 0.5px 0.5px 0.8px #948dff;
color: #1a1b26;
}
p {
text-shadow: 0.5px 0.5px 0.8px #948dff;
color: #1a1b26;
font-size: 1.2em;
}
/* width */
::-webkit-scrollbar {
width: 10px;
box-shadow: 0px 0px 8px 4px rgba(8, 7, 56, 0.692);
border-radius: 10px;
}
/* Track */
::-webkit-scrollbar-track {
background-color: rgba(146, 144, 196, 0.9);
border-radius: 10px;
}
/* Handle */
::-webkit-scrollbar-thumb {
background-color: rgba(51, 50, 78, 0.9);
border-radius: 10px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background-color: rgba(25, 25, 59, 0.9);
}
#dvdlogo {
display: block;
position: absolute;
z-index: 1;
-webkit-animation: moveX 10s linear 0s infinite alternate, moveY 17s linear 0s infinite alternate, changeColour 30s, linear, 0s, infinite, normal, none, infinite;
-moz-animation: moveX 10s linear 0s infinite alternate, moveY 17s linear 0s infinite alternate, changeColour 30s, linear, 0s, infinite, normal, none, infinite;
-o-animation: moveX 10s linear 0s infinite alternate, moveY 17s linear 0s infinite alternate, changeColour 30s, linear, 0s, infinite, normal, none, infinite;
animation: moveX 10s linear 0s infinite alternate, moveY 17s linear 0s infinite alternate, changeColour 30s, linear, 0s, infinite, normal, none, infinite;
animation-iteration-count:infinite;
}
svg {
display: block;
}
@-webkit-keyframes moveX {
from { left: 0; } to { left: calc(100vw - 153px); }
}
@-moz-keyframes moveX {
from { left: 0; } to { left: calc(100vw - 153px); }
}
@-o-keyframes moveX {
from { left: 0; } to { left: calc(100vw - 153px); }
}
@keyframes moveX {
from { left: 0; } to { left: calc(100vw - 153px); }
}
@keyframes changeColour {
0% { fill: #ff6969; }
14% { fill: #fd9644; }
28% { fill: #fed330; }
42% { fill: #2dc22d; }
56% { fill: #45d8f2; }
70% { fill: #5e6cea; }
84% { fill: #c22dc2; }
100% { fill: #ff6969; }
}
@-webkit-keyframes moveY {
from { top: 0; } to { top: calc(100vh - 69px); }
}
@-moz-keyframes moveY {
from { top: 0; } to { top: calc(100vh - 69px); }
}
@-o-keyframes moveY {
from { top: 0; } to { top: calc(100vh - 69px); }
}
@keyframes moveY {
from { top: 0; } to { top: calc(100vh - 69px); }
}
.serviceButton {
width: 100%;
margin: 0.4em;
margin-left: 2em;
margin-right: 2em;
text-decoration: none;
color: rgba(0, 0, 0, 0.875);
text-align: center;
justify-content: center;
align-items: center;
}
.camBox {
display: flex;
text-align: center;
justify-content: center;
align-items: center;
justify-content: space-evenly;
}
.cam {
display: flex;
width: 1920px;
height: 1080px;
align-self: center;
margin: auto;
}
.fullGrafana {
width: 900px;
}
.halfGrafana {
width: 450px;
}
.lightText {
color: rgba(162, 161, 255, 0.5);
}
.hostinfo {
cursor: default;
text-align: start;
padding: 10px;
margin: 10px;
user-select: text;
margin-top: 0;
margin-bottom: 0;
font-size: x-small;
color: rgba(15, 15, 15, 0.8750);
background-color: rgba(255, 255, 255, 0.06);
-webkit-box-shadow: inset 3px 3px 12px 2px rgba(28, 28, 170, 0.2);
-moz-box-shadow: inset 3px 3px 12px 2px rgba(28, 28, 170, 0.2);
box-shadow: inset 3px 3px 12px 2px rgba(28, 28, 170, 0.2);
}
.flexContainer {
box-sizing: border-box;
height: 100%;
padding: 0;
margin: auto;
display: flex;
align-items: flex-start;
justify-content: center;
flex-direction: row;
width: auto;
}
.stroke {
box-sizing: border-box;
height: 100%;
padding-bottom: 20px;
padding-top: 20px;
margin: 10px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 2;
}
.row {
box-sizing: border-box;
width: 100%;
text-align: center;
justify-content: center;
align-items: center;
display: flex;
justify-content: space-evenly;
vertical-align: middle;
margin-left: 10px;
margin-right: 10px;
}
.flexItem {
padding: 5px;
width: 20px;
height: 20px;
margin: 10px;
line-height: 20px;
font-weight: bold;
font-size: 2em;
text-align: center;
}
.waveButton {
min-width: 200px;
cursor: pointer;
margin-left: 12px;
margin-right: 12px;
margin-top: 8px;
margin-bottom: 8px;
padding: 8px;
border: 0;
border-radius: 5px;
background-color: rgba(163, 161, 255, 0.9);
box-shadow: 4px 5px 3px 2px rgba(8, 7, 56, 0.692);
text-align: center;
justify-content: center;
align-items: center;
display: flex;
justify-content: space-evenly;
z-index: 3;
backdrop-filter: blur(6px);
}
.waveButton:hover {
background-color: rgba(122, 121, 207, 0.9);
}
.waveButton:disabled {
cursor: default;
opacity: 0.7;
}
.homeButton {
min-width: 200px;
cursor: pointer;
padding: 8px;
border: 0;
border-radius: 20px;
background-color: transparent;
text-align: center;
justify-content: center;
align-items: center;
display: flex;
justify-content: space-evenly;
z-index: 10;
}
.homeButton:hover {
box-shadow: 4px 5px 3px 2px rgba(8, 7, 56, 0.692);
backdrop-filter: blur(6px);
}
.homeButton:disabled {
cursor: default;
opacity: 0.7;
}
.searchField {
margin: auto;
display: flex;
background-color: rgba(44, 96, 238, 0.199);
padding: 5px;
border-radius: 20px;
color:rgba(8, 7, 56, 0.747);
}
.searchField::placeholder {
color:rgba(8, 7, 56, 0.445);
}
.searchField:focus {
outline: none !important;
border:2px solid rgba(49, 13, 134, 0.459);
box-shadow: 0px 0px 3px 2px rgba(53, 118, 138, 0.8);
}
.main-container {
height: calc(100vh - 40px);
display: flex;
flex-direction: column;
}
.fixed-container {
height: 50px;
padding: 10px;
border-radius: 50px;
color: white;
text-align: center;
justify-content: center;
align-items: center;
display: flex;
justify-content: space-evenly;
vertical-align: middle;
}
.content-wrapper {
display: flex;
flex: 1;
min-height: 0px; /* IMPORTANT: you need this for non-chrome browsers */
}
.overflow-container {
flex: 1;
overflow: auto;
}
.overflow-content {
color: black;
padding: 20px;
}
.noCursor {
cursor: default;
}
.roundedOpaque {
background-color: rgba(180, 175, 252, 0.80);
box-shadow: 9px 13px 18px 8px rgba(8, 7, 56, 0.692);
border-radius: 30px;
box-sizing: border-box;
backdrop-filter: blur(6px);
}
.mainContainer {
display: flex;
justify-content: center;
width: 100%;
margin-top: 64px;
}
.dataContainer {
display: flex;
flex-direction: column;
justify-content: center;
max-width: 600px;
}
.header {
text-align: center;
font-size: 32px;
font-weight: 600;
}
.bio {
text-align: center;
color: black;
margin-top: 16px;
}
.waveDiv {
text-align: center;
margin-top: 16px;
padding: 8px;
border: 0;
border-radius: 5px;
}
.blockyBlockRoundedCornersWazzup{
background-color: rgba(163, 161, 255, 0.9);
margin-top: 6px;
padding: 8px;
border-radius: 10px;
}
.stilo {
-webkit-user-select: none;
background-color: hsl(0, 0%, 90%);
transition: background-color 300ms;
object-fit: cover;
width: 100%;
}
.grid-item {
padding: 20px;
font-size: 30px;
text-align: center;
}
.grid-container {
display: grid;
grid-template-columns: auto auto auto;
padding: 10px;
width: 100%;
}
.showOnlyOnMobile {
visibility: hidden;
display:none;
}
.showNeverOnMobile {
visibility: visible;
display:block;
}
.mistvideo {
margin-top: 10px;
margin-bottom: 10px;
width: calc(50vw);
height: calc((9 / 16 ) * 50vw);
display: flex;
justify-content: center;
align-items: center;
align-content: center;
opacity: 0.95;
}
.separator {
border:none;
border-top:25px dotted rgba(56, 19, 124, 0.6);
width: 30vw;
height: 10px;
margin: 0;
}
.mistvideo-controls svg.mist.icon:hover .fill,
.mistvideo-controls svg.mist.icon:hover .semiFill,
.mistvideo-volume_container:hover .fill,
.mistvideo-volume_container:hover .semiFill {
fill: rgba(179, 14, 14, 0.875);
}
.mistvideo-controls svg.mist.icon:hover .stroke,
.mistvideo-volume_container:hover .stroke {
stroke: rgba(179, 14, 14, 0.875);
}
.markdown {
padding: 50px;
width: 50vw;
z-index: 5;
}
.markdown img {
align-self: center;
margin: auto;
width: 100%;
}
.container {
position: relative;
text-align: center;
}
.centered {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.alwaysOnBottom {
z-index: 0;
position: fixed;
left: 50%;
bottom: 0px;
transform: translate(-50%, 0);
margin: 0 auto;
user-select: none;
}
.alwaysOnBottomRight {
z-index: 0;
position: fixed;
right: 0;
bottom: 0px;
/* transform: translate(-50%, -50%); */
margin: 0 auto;
user-select: none;
}
@media (max-aspect-ratio: 1/1) {
.fullGrafana {
width: calc(100vw - 2em);
}
.halfGrafana {
width: calc(100vw - 2em);
}
.shrinkSize {
width: 50%;
}
.flexContainer {
height: 100%;
padding: 0;
margin: 0;
display: flex;
align-items: flex-start;
justify-content: center;
flex-direction: column;
}
.roundedOpaque {
background-color: none;
width: 100%;
box-shadow: none;
border-radius: 10px;
box-sizing: border-box;
}
.stroke {
margin-left: 0;
margin-right: 0;
}
.showOnlyOnMobile {
visibility: visible;
display:block;
}
.showNeverOnMobile {
visibility: hidden;
display:none;
}
.mistvideo {
width: calc(100vw - 20px);
height: calc((9 / 16 ) * (100vw - 20px));
}
.mobileNoPadding {
padding: 0;
}
.main-container {
height: calc(100vh - 60px);
}
.markdown {
width: 80vw;
margin: 20px;
padding: 20px;
}
}

28
src/util/livepeer.js Normal file
View File

@ -0,0 +1,28 @@
export const getQuotes = () => (
fetch("api/livepeer/quotes", {
method: "GET",
headers: {
"Content-Type": "application/json"
}
})
);
export const getBlockchainData = () => (
fetch("api/livepeer/blockchains", {
method: "GET",
headers: {
"Content-Type": "application/json"
}
})
);
export const getEvents = () => (
fetch("api/livepeer/getEvents", {
method: "GET",
headers: {
"Content-Type": "application/json"
}
})
);

35
src/util/session.js Normal file
View File

@ -0,0 +1,35 @@
export const login = user => (
fetch("api/session", {
method: "POST",
body: JSON.stringify(user),
headers: {
"Content-Type": "application/json"
}
})
);
export const signup = user => (
fetch("api/users", {
method: "POST",
body: JSON.stringify(user),
headers: {
"Content-Type": "application/json"
}
})
);
export const logout = () => (
fetch("api/session", { method: "DELETE" })
);
export const checkLoggedIn = async () => {
const response = await fetch('/api/session');
const { user } = await response.json();
let preloadedState = {};
if (user) {
preloadedState = {
session: user
};
}
return preloadedState;
};

40
src/util/user.js Normal file
View File

@ -0,0 +1,40 @@
export const getVisitorStats = () => (
fetch("api/users/getVisitorStats", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
);
export const getCurrentUserVotes = () => (
fetch("api/users/getCurrentUserVotes", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
);
export const getScoreByTimelapeFilename = (fullFilename) => (
fetch("api/users/getScoreByTimelapeFilename", {
method: "POST",
body: JSON.stringify({ fullFilename }),
headers: {
"Content-Type": "application/json"
}
})
);
export const setVoteOnTimelapse = (voteValue, fullFilename) => (
fetch("api/users/setVoteOnTimelapse", {
method: "POST",
body: JSON.stringify({ voteValue, fullFilename }),
headers: {
"Content-Type": "application/json"
}
})
);