rewrite mithril to react

This commit is contained in:
Danny van Kooten 2016-11-23 19:40:35 +01:00
parent fbd591547c
commit a7af934f23
15 changed files with 329 additions and 293 deletions

View File

@ -15,6 +15,12 @@ This is a general draft document for thoughts and todo's.
- Referral's
- Search keywords
## Notes
- Every API response has to be JSON
- Envelope every response with `data` and `message` props.
- JS client for consuming API.
### Admin themes

View File

@ -0,0 +1,59 @@
'use strict';
import { h, render, Component } from 'preact';
import Chart from 'chart.js'
class Graph extends Component {
constructor(props) {
super(props)
this.state = {
data: []
}
this.fetchData = this.fetchData.bind(this);
this.fetchData();
}
initChart() {
new Chart(this.ctx, {
type: 'line',
data: {
labels: this.state.data.map((d) => d.Label),
datasets: [{
label: '# of Visitors',
data: this.state.data.map((d) => d.Count),
backgroundColor: 'rgba(0, 155, 255, .2)'
}]
},
options: {
scale: {
ticks: {
beginAtZero: true
}
}
}
});
}
fetchData() {
return fetch('/api/visits/count/day', {
credentials: 'include'
})
.then((r) => r.json())
.then((data) => {
this.setState({ data: data})
this.initChart()
});
}
render() {
return (
<div class="block">
<canvas width="600" height="220" ref={(el) => { this.ctx = el; }} />
</div>
)
}
}
export default Graph

View File

@ -0,0 +1,59 @@
'use strict';
import { h, render, Component } from 'preact';
class LoginForm extends Component {
constructor(props) {
super(props)
this.handleSubmit = this.handleSubmit.bind(this);
this.setState({
email: '',
password: '',
})
}
handleSubmit(e) {
e.preventDefault();
fetch('/api/session', {
method: "POST",
data: {
email: this.state.email,
password: this.state.password,
},
credentials: 'include'
}).then((r) => {
if( r.status == 200 ) {
this.props.onAuth();
console.log("Authenticated!");
}
// TODO: Handle errors
});
}
render() {
return (
<div class="block">
<h2>Login</h2>
<p>Please enter your credentials to access your Ana dashboard.</p>
<form method="POST" onSubmit={this.handleSubmit}>
<div class="form-group">
<label>Email address</label>
<input type="email" name="email" onChange={this.linkState('email')} required="required" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" onChange={this.linkState('password')} required="required" />
</div>
<div class="form-group">
<input type="submit" value="Sign in" />
</div>
</form>
</div>
)
}
}
export default LoginForm

View File

@ -0,0 +1,33 @@
'use strict';
import { h, render, Component } from 'preact';
class LogoutButton extends Component {
constructor(props) {
super(props)
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(e) {
e.preventDefault();
fetch('/api/session', {
method: "DELETE",
credentials: 'include',
}).then((r) => {
if( r.status == 200 ) {
this.props.onSuccess();
console.log("No longer authenticated!");
}
});
}
render() {
return (
<a href="#" onClick={this.handleSubmit}>Sign out</a>
)
}
}
export default LogoutButton

View File

@ -0,0 +1,58 @@
'use strict';
import { h, render, Component } from 'preact';
class Pageviews extends Component {
constructor(props) {
super(props)
this.state = {
records: []
}
this.fetchRecords = this.fetchRecords.bind(this);
this.fetchRecords();
}
fetchRecords() {
return fetch('/api/pageviews', {
credentials: 'include'
}).then((r) => {
if( r.ok ) {
return r.json();
}
}).then((data) => {
this.setState({ records: data })
});
}
render() {
const tableRows = this.state.records.map( (p, i) => (
<tr>
<td>{i+1}</td>
<td><a href={p.Path}>{p.Path}</a></td>
<td>{p.Count}</td>
<td>{p.CountUnique}</td>
</tr>
));
return (
<div class="block">
<h2>Pageviews</h2>
<table class="table pageviews">
<thead>
<tr>
<th>#</th>
<th>URL</th>
<th>Pageviews</th>
<th>Unique</th>
</tr>
</thead>
<tbody>{tableRows}</tbody>
</table>
</div>
)
}
}
export default Pageviews

View File

@ -0,0 +1,37 @@
'use strict';
import { h, render, Component } from 'preact';
class Realtime extends Component {
constructor(props) {
super(props)
this.state = {
count: 0
}
this.fetchData = this.fetchData.bind(this);
this.fetchData();
}
fetchData() {
return fetch('/api/visits/count/realtime', {
credentials: 'include'
})
.then((r) => r.json())
.then((data) => {
this.setState({ count: data })
});
}
render() {
let visitors = this.state.count == 1 ? 'visitor' : 'visitors';
return (
<div class="block">
<span class="count">{this.state.count}</span> <span>{visitors} on the site right now</span>
</div>
)
}
}
export default Realtime

View File

@ -1,70 +0,0 @@
import m from 'mithril';
function handleSubmit(e) {
e.preventDefault();
fetch('/api/session', {
method: "POST",
data: {
email: this.data.email(),
password: this.data.password()
},
credentials: 'include'
}).then((r) => {
if( r.status == 200 ) {
this.onAuth();
console.log("Authenticated!");
}
// TODO: Handle errors
});
}
const Login = {
controller(args) {
this.onAuth = args.onAuth;
this.data = {
email: m.prop(''),
password: m.prop(''),
}
this.onSubmit = handleSubmit.bind(this);
},
view(c) {
return m('div.block', [
m('h2', 'Login'),
m('p', 'Please enter your credentials to login to your Ana dashboard.'),
m('form', {
method: "POST",
onsubmit: c.onSubmit
}, [
m('div.form-group', [
m('label', 'Email address'),
m('input', {
type: "email",
name: "email",
required: true,
onchange: m.withAttr("value", c.data.email )
}),
]),
m('div.form-group', [
m('label', 'Password'),
m('input', {
type: "password",
name: "password",
required: true,
onchange: m.withAttr("value", c.data.password )
}),
]),
m('div.form-group', [
m('input', {
type: "submit",
value: "Sign in"
}),
]),
])
])
}
}
export default Login

View File

@ -1,30 +0,0 @@
'use strict';
import m from 'mithril';
function handleSubmit(e) {
e.preventDefault();
fetch('/api/session', {
method: "DELETE",
credentials: 'include',
}).then((r) => {
if( r.status == 200 ) {
this.cb();
console.log("No longer authenticated!");
}
});
}
const LogoutButton = {
controller(args) {
this.cb = args.cb;
this.onSubmit = handleSubmit.bind(this);
},
view(c) {
return m('a', { href: "#", onclick: c.onSubmit }, 'Sign out');
}
}
export default LogoutButton

View File

@ -1,49 +0,0 @@
import m from 'mithril';
function fetchRecords() {
return fetch('/api/pageviews', {
credentials: 'include'
}).then((r) => {
if( r.ok ) {
return r.json();
}
}).then((data) => {
m.startComputation();
this.records(data);
m.endComputation();
});
}
const Pageviews = {
controller() {
this.records = m.prop([]);
fetchRecords.call(this) && window.setInterval(fetchRecords.bind(this), 60000);
},
view(c) {
const tableRows = c.records().map((p, i) => m('tr', [
m('td', i+1),
m('td', [
m('a', { href: p.Path }, p.Path)
]),
m('td', p.Count),
m('td', p.CountUnique)
]));
return m('div.block', [
m('h2', 'Pageviews'),
m('table.table.pageviews', [
m('thead', [
m('tr', [
m('th', '#'),
m('th', 'URL'),
m('th', 'Pageviews'),
m('th', 'Unique'),
]) // tr
]), // thead
m('tbody', tableRows )
]) // table
])
}
}
export default Pageviews

View File

@ -1,33 +0,0 @@
'use strict';
import m from 'mithril';
function fetchData() {
return fetch('/api/visits/count/realtime', {
credentials: 'include'
})
.then((r) => r.json())
.then((data) => {
this.count = data;
m.redraw();
});
}
const RealtimeVisits = {
controller(args) {
this.count = 0;
fetchData.bind(this) && window.setInterval(fetchData.bind(this), 6000);
},
view(c) {
let visitors = c.count > 1 ? 'visitors' : 'visitor';
return m('div.block', [
m('span.count', c.count),
' ',
m('span', visitors + " on the site right now.")
])
}
}
export default RealtimeVisits

View File

@ -1,61 +0,0 @@
'use strict'
import m from 'mithril';
import Chart from 'chart.js'
Chart.defaults.global.tooltips.xPadding = 12;
Chart.defaults.global.tooltips.yPadding = 12;
function fetchData() {
return fetch('/api/visits/count/day', {
credentials: 'include'
})
.then((r) => r.json())
.then((data) => {
this.data = data;
initChart.call(this);
});
}
function initChart() {
let ctx = this.chartCtx;
new Chart(ctx, {
type: 'line',
data: {
labels: this.data.map((d) => d.Label),
datasets: [{
label: '# of Visitors',
data: this.data.map((d) => d.Count),
backgroundColor: 'rgba(0, 155, 255, .2)'
}]
},
options: {
scale: {
ticks: {
beginAtZero: true
}
}
}
});
}
const VisitsGraph = {
controller(args) {
this.data = [];
fetchData.call(this);
},
view(c) {
return m('div.block', [
m('canvas', {
width: 600,
height: 200,
config: (el) => { c.chartCtx = el; }
})
])
},
}
export default VisitsGraph;

View File

@ -1,56 +1,50 @@
'use strict';
const m = require('mithril');
import Login from './components/login.js';
import Pageviews from './components/pageviews.js';
import RealtimeVisits from './components/realtime.js';
import VisitsGraph from './components/visits-graph.js';
import LogoutButton from './components/logoutButton.js';
import { h, render, Component } from 'preact';
import LoginForm from './components/LoginForm.js';
import LogoutButton from './components/LogoutButton.js';
import Pageviews from './components/Pageviews.js';
import Realtime from './components/Realtime.js';
import Graph from './components/Graph.js';
class App extends Component {
constructor(props) {
super(props)
const App = {
controller(args) {
this.state = {
authenticated: document.cookie.indexOf('auth') > -1
};
this.setState = function(nextState) {
m.startComputation();
for(var k in nextState) {
this.state[k] = nextState[k];
}
m.endComputation();
}
},
view(c) {
if( ! c.state.authenticated ) {
return m('div.container', [
m.component(Login, {
onAuth: () => {
c.setState({ authenticated: true })
}
})
]);
}
return [
m('div.container', [
m('div.header.cf', [
m('h1.pull-left', 'Ana'),
m('div.pull-right', [
m.component(LogoutButton, {
cb: () => {
c.setState({ authenticated: false })
}
})
]),
]),
m.component(RealtimeVisits),
m.component(VisitsGraph),
m.component(Pageviews),
])
]
authenticated: document.cookie.indexOf('auth') > -1,
}
}
render() {
m.mount(document.getElementById('root'), App)
// logged-in
if( this.state.authenticated ) {
return (
<div class="container">
<header class="header cf">
<h1 class="pull-left">Ana <small>open web analytics</small></h1>
<div class="pull-right">
<LogoutButton onSuccess={() => { this.setState({ authenticated: false })}} />
</div>
</header>
<Realtime />
<Graph />
<Pageviews />
</div>
)
}
// logged-out
return (
<div class="container">
<header class="header cf">
<h1 class="pull-left">Ana <small>open web analytics</small></h1>
</header>
<LoginForm onAuth={() => { this.setState({ authenticated: true })}} />
</div>
)
}
}
render(<App />, document.getElementById('root'));

View File

@ -9,6 +9,12 @@ body {
h1, h2, h3 {
color: #111;
small {
font-weight: normal;
font-style: italic;
color: #666;
}
}
table {

View File

@ -9,9 +9,11 @@
"vinyl-source-stream": "^1.1.0"
},
"dependencies": {
"babel-plugin-transform-react-jsx": "^6.8.0",
"babel-preset-es2015": "^6.18.0",
"babelify": "^7.3.0",
"chart.js": "^2.4.0",
"mithril": "^0.2.5"
"mithril": "^0.2.5",
"preact": "^6.4.0"
}
}

View File

@ -176,6 +176,15 @@ babel-generator@^6.18.0:
lodash "^4.2.0"
source-map "^0.5.0"
babel-helper-builder-react-jsx@^6.8.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.18.0.tgz#ab02f19a2eb7ace936dd87fa55896d02be59bf71"
dependencies:
babel-runtime "^6.9.0"
babel-types "^6.18.0"
esutils "^2.0.0"
lodash "^4.2.0"
babel-helper-call-delegate@^6.18.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.18.0.tgz#05b14aafa430884b034097ef29e9f067ea4133bd"
@ -263,6 +272,10 @@ babel-plugin-check-es2015-constants@^6.3.13:
dependencies:
babel-runtime "^6.0.0"
babel-plugin-syntax-jsx@^6.8.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
babel-plugin-transform-es2015-arrow-functions@^6.3.13:
version "6.8.0"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.8.0.tgz#5b63afc3181bdc9a8c4d481b5a4f3f7d7fef3d9d"
@ -432,6 +445,14 @@ babel-plugin-transform-es2015-unicode-regex@^6.3.13:
babel-runtime "^6.0.0"
regexpu-core "^2.0.0"
babel-plugin-transform-react-jsx:
version "6.8.0"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.8.0.tgz#94759942f70af18c617189aa7f3593f1644a71ab"
dependencies:
babel-helper-builder-react-jsx "^6.8.0"
babel-plugin-syntax-jsx "^6.8.0"
babel-runtime "^6.0.0"
babel-plugin-transform-regenerator@^6.16.0:
version "6.16.1"
resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.16.1.tgz#a75de6b048a14154aae14b0122756c5bed392f59"
@ -528,7 +549,7 @@ babel-types@^6.16.0, babel-types@^6.18.0, babel-types@^6.19.0, babel-types@^6.8.
lodash "^4.2.0"
to-fast-properties "^1.0.1"
babelify:
babelify@^7.3.0:
version "7.3.0"
resolved "https://registry.yarnpkg.com/babelify/-/babelify-7.3.0.tgz#aa56aede7067fd7bd549666ee16dc285087e88e5"
dependencies:
@ -1119,7 +1140,7 @@ escape-string-regexp@^1.0.2:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
esutils@^2.0.2:
esutils@^2.0.0, esutils@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
@ -2174,7 +2195,7 @@ minimist@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
mithril:
mithril@^0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/mithril/-/mithril-0.2.5.tgz#c1a50438a93ac23f11ada91188bb784c755404c2"
@ -2480,6 +2501,10 @@ pinkie@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
preact:
version "6.4.0"
resolved "https://registry.yarnpkg.com/preact/-/preact-6.4.0.tgz#1b8c99754b002639a1c33e68175eefa98a01cc14"
preserve@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"