voterunner

quick and dirty votes and discussions
git clone https://git.ce9e.org/voterunner.git

commit
6d9fecab0424eebbf7271bde04efbf13dc1d1c62
parent
f901f3c2fe366154f47c1f129e1664eed093f9c2
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2020-10-18 07:44
Merge branch 'via'

Diffstat

M .gitignore 6 ++----
M Makefile 16 ++++++----------
D Procfile 1 -
M README.md 22 +---------------------
D app.js 204 ------------------------------------------------------------
D bin/manage_db.sh 33 ---------------------------------
R tpl/app.html -> index.html 4 +---
M package.json 14 ++------------
R static/scss/bar.scss -> scss/bar.scss 0
R static/scss/base.scss -> scss/base.scss 0
R static/scss/layout.scss -> scss/layout.scss 0
R static/scss/style.scss -> scss/style.scss 6 +++---
R static/scss/tree.scss -> scss/tree.scss 0
R static/scss/user.scss -> scss/user.scss 0
R static/src/template.js -> src/template.js 35 ++++++++++++++++-------------------
R static/src/utils.js -> src/utils.js 16 +++-------------
R static/src/voterunner.js -> src/voterunner.js 164 ++++++++++++++++++++++++++++++++++++-------------------------
R static/test/index.html -> test/index.html 0
R static/test/test.js -> test/test.js 98 ++++++++++++++++---------------------------------------------
D tpl/markdown.html 17 -----------------

20 files changed, 157 insertions, 479 deletions


diff --git a/.gitignore b/.gitignore

@@ -1,5 +1,3 @@
    1     1 node_modules
    2    -1 data
    3    -1 db.sqlite3
    4    -1 static/style.css
    5    -1 static/voterunner.js
   -1     2 /style.css
   -1     3 /voterunner.js

diff --git a/Makefile b/Makefile

@@ -1,14 +1,10 @@
    1    -1 all: static/voterunner.js static/style.css
   -1     1 all: voterunner.js style.css
    2     2 
    3    -1 static/voterunner.js: static/src/*.js
    4    -1 	browserify static/src/voterunner.js -o $@
   -1     3 voterunner.js: src/*.js
   -1     4 	browserify src/voterunner.js -o $@
    5     5 
    6    -1 static/style.css: static/scss/*.scss
    7    -1 	sassc static/scss/style.scss $@
   -1     6 style.css: scss/*.scss
   -1     7 	sassc scss/style.scss $@
    8     8 
    9     9 clean:
   10    -1 	rm static/voterunner.js static/style.css
   11    -1 
   12    -1 .PHONY: server
   13    -1 server: all
   14    -1 	export DATABASE_URL='sqlite3:db.sqlite3' && node app.js
   -1    10 	rm voterunner.js style.css

diff --git a/Procfile b/Procfile

@@ -1 +0,0 @@
    1    -1 web: node app.js

diff --git a/README.md b/README.md

@@ -8,8 +8,7 @@ This app tries to allow for quick and dirty votes and discussions. It is
    8     8 basically the core concept behind
    9     9 [votorolla](http://zelea.com/project/votorola/home.xht) mixed with the
   10    10 interface of [etherpad](http://etherpad.org/). Technically, it is a lot
   11    -1 of client side code with a bit of [socket.io](http://socket.io) magic on
   12    -1 the server.
   -1    11 of client side code using a [via](https://github.com/xi/via) server.
   13    12 
   14    13 The voting mechanism
   15    14 --------------------
@@ -60,25 +59,6 @@ the delegation. Now you compete directly with other ideas. Maybe you can
   60    59 convince others, but maybe you should delegate your vote again in order
   61    60 to agree on a compromise.
   62    61 
   63    -1 Install
   64    -1 -------
   65    -1 
   66    -1 Voterunner is a [node.js](http://nodejs.org/) app using
   67    -1 [PostgreSQL](http://www.postgresql.org/) as a database so the following
   68    -1 lines will bring it up:
   69    -1 
   70    -1     $ git clone https://github.com/xi/voterunner
   71    -1     $ cd voterunner
   72    -1     $ npm install
   73    -1     $ bin/manage_db.sh init
   74    -1     $ bin/manage_db.sh start
   75    -1     $ export DATABASE_URL="postgresql://:@localhost/voterunner"
   76    -1     $ node app.js
   77    -1         ... Listening on localhost:5000
   78    -1     $ open http://localhost:5000/  # introduction
   79    -1     $ open http://localhost:5000/my-topic/  # discuss on a topic
   80    -1 
   81    -1 
   82    62 Development
   83    63 -----------
   84    64 

diff --git a/app.js b/app.js

@@ -1,204 +0,0 @@
    1    -1 /*
    2    -1  * voterunner - quick and dirty votes and discussions
    3    -1  *
    4    -1  * copyright: 2013 Tobias Bengfort <tobias.bengfort@gmx.net>
    5    -1  * license: AGPL-3+
    6    -1  * url: http://voterunner.herokuapp.com/
    7    -1  */
    8    -1 
    9    -1 var url = require('url');
   10    -1 var process = require('process');
   11    -1 
   12    -1 var express = require('express');
   13    -1 var http = require('http');
   14    -1 var app = express();
   15    -1 var server = http.Server(app);
   16    -1 var io = require('socket.io').listen(server);
   17    -1 var anyDB = require('any-db');
   18    -1 var fs = require('fs');
   19    -1 var log4js = require('log4js');
   20    -1 var MarkdownIt = require('markdown-it');
   21    -1 
   22    -1 var DATABASE_URL = process.env.DATABASE_URL;
   23    -1 var SQLITE = DATABASE_URL.match(/^sqlite/);
   24    -1 var PORT = process.env.PORT || 5000;
   25    -1 var HOST = process.env.HOST || 'localhost';
   26    -1 
   27    -1 var log = log4js.getLogger();
   28    -1 var md = new MarkdownIt();
   29    -1 
   30    -1 app.use(express.static('static'));
   31    -1 server.listen(PORT, HOST, function() {
   32    -1 	log.info('Listening on ' + HOST + ':' + PORT);
   33    -1 });
   34    -1 
   35    -1 var db = anyDB.createPool(DATABASE_URL, {
   36    -1 	max: SQLITE ? 1 : 10,
   37    -1 });
   38    -1 process.on('exit', (code) => {
   39    -1 	db.close();
   40    -1 });
   41    -1 
   42    -1 var queryDB = function(sql, data) {
   43    -1 	if (SQLITE) {
   44    -1 		sql = sql.replace(/\$/g, '?');
   45    -1 	}
   46    -1 
   47    -1 	return new Promise(function(resolve, reject) {
   48    -1 		var q = db.query(sql, data, function(err, res) {
   49    -1 			if (err) {
   50    -1 				reject(err);
   51    -1 			} else {
   52    -1 				resolve(res);
   53    -1 			}
   54    -1 		});
   55    -1 	});
   56    -1 };
   57    -1 
   58    -1 // setup table
   59    -1 queryDB('CREATE TABLE IF NOT EXISTS nodes (topic TEXT, id TEXT, name TEXT, comment TEXT, delegate TEXT, UNIQUE (topic, id))');
   60    -1 
   61    -1 var escapeHTML = function(unsafe) {
   62    -1 	return unsafe
   63    -1 		.replace(/&/g, '&amp;')
   64    -1 		.replace(/</g, '&lt;')
   65    -1 		.replace(/>/g, '&gt;')
   66    -1 		.replace(/"/g, '&quot;')
   67    -1 		.replace(/'/g, '&#039;');
   68    -1 };
   69    -1 
   70    -1 var tpl = function(file, data, res) {
   71    -1 	fs.readFile('tpl/' + file, 'utf8', function(err, html) {
   72    -1 		html = html.replace(/{{{ ([^}]*)\|markdown }}}/g, function(match, key) {
   73    -1 			if (data.hasOwnProperty(key)) {
   74    -1 				return md.render(data[key]);
   75    -1 			} else {
   76    -1 				return '';
   77    -1 			}
   78    -1 		});
   79    -1 
   80    -1 		html = html.replace(/{{ ([^}]*)\|json }}/g, function(match, key) {
   81    -1 			if (data.hasOwnProperty(key)) {
   82    -1 				return '<div id="json-' + escapeHTML(key) + '" data-value="' + escapeHTML(JSON.stringify(data[key])) + '"></div>';
   83    -1 			} else {
   84    -1 				return '';
   85    -1 			}
   86    -1 		});
   87    -1 
   88    -1 		html = html.replace(/{{ ([^}]*) }}/g, function(match, key) {
   89    -1 			if (data.hasOwnProperty(key)) {
   90    -1 				return escapeHTML(data[key].toString());
   91    -1 			} else {
   92    -1 				return '';
   93    -1 			}
   94    -1 		});
   95    -1 
   96    -1 		res.send(html);
   97    -1 	});
   98    -1 };
   99    -1 
  100    -1 
  101    -1 // welcome view
  102    -1 app.get('/', function(req, res) {
  103    -1 	fs.readFile('README.md', 'utf8', function(err, markdown) {
  104    -1 		tpl('markdown.html', {'markdown': markdown}, res);
  105    -1 	});
  106    -1 });
  107    -1 
  108    -1 // json state
  109    -1 app.get('/:topic.json', function(req, res) {
  110    -1 	var topic = req.params.topic;
  111    -1 	var sql = 'SELECT id, name, comment, delegate FROM nodes WHERE topic = $1';
  112    -1 
  113    -1 	queryDB(sql, [topic]).then(function(result) {
  114    -1 		res.json(result.rows);
  115    -1 	}).catch(function(err) {
  116    -1 		res.status(500).send(err.toString());
  117    -1 	});
  118    -1 });
  119    -1 
  120    -1 // app view
  121    -1 app.get('/:topic/:id?', function(req, res) {
  122    -1 	var topic = req.params.topic;
  123    -1 
  124    -1 	var sql = 'SELECT id, name, comment, delegate FROM nodes WHERE topic = $1';
  125    -1 	queryDB(sql, [topic]).then(function(result) {
  126    -1 		tpl('app.html', {'nodes': result.rows, 'topic': topic}, res);
  127    -1 	}).catch(function(err) {
  128    -1 		res.status(500).send(err.toString());
  129    -1 	});
  130    -1 });
  131    -1 
  132    -1 // socket.io
  133    -1 io.sockets.on('connection', function(socket) {
  134    -1 	var topic;
  135    -1 	var id;
  136    -1 
  137    -1 	var handleMsg = function(action, sql, v1, v2) {
  138    -1 		// make sure that node exists, ignore error
  139    -1 		return queryDB('INSERT INTO nodes (topic, id) VALUES ($1, $2)', [topic, id]).catch(function() {
  140    -1 			return;
  141    -1 		}).then(function() {
  142    -1 			log.debug('Handeling:', action, topic, id, v1, v2);
  143    -1 			io.to(topic).emit(action, id, v1, v2);
  144    -1 
  145    -1 			if (typeof(sql) === 'string') {
  146    -1 				sql = [sql];
  147    -1 			}
  148    -1 
  149    -1 			return Promise.all(sql.map(function(s) {
  150    -1 				var params = [topic, id];
  151    -1 				var n = s.match(/\$/g).length;
  152    -1 				if (n >= 3) params.push(v1);
  153    -1 				if (n >= 4) params.push(v2);
  154    -1 
  155    -1 				return queryDB(s, params);
  156    -1 			}));
  157    -1 		});
  158    -1 	};
  159    -1 
  160    -1 	socket.on('register', function(_topic, _id) {
  161    -1 		log.debug('Registration:', _topic, _id);
  162    -1 
  163    -1 		topic = _topic;
  164    -1 		id = _id;
  165    -1 		socket.join(topic, function(err) {
  166    -1 			if (err) {
  167    -1 				log.error(err);
  168    -1 			}
  169    -1 		});
  170    -1 	});
  171    -1 
  172    -1 	socket.on('rmNode', function() {
  173    -1 		var sql = [
  174    -1 			'UPDATE nodes SET delegate = null WHERE topic = $1 AND delegate = $2',
  175    -1 			'DELETE FROM nodes WHERE topic = $1 AND id = $2',
  176    -1 		];
  177    -1 		handleMsg('rmNode', sql);
  178    -1 	});
  179    -1 	socket.on('setNodeName', function(name) {
  180    -1 		var sql = 'UPDATE nodes SET name = $3 WHERE topic = $1 AND id = $2';
  181    -1 		handleMsg('setNodeName', sql, name);
  182    -1 	});
  183    -1 	socket.on('setNodeComment', function(comment) {
  184    -1 		var sql = 'UPDATE nodes SET comment = $3 WHERE topic = $1 AND id = $2';
  185    -1 		handleMsg('setNodeComment', sql, comment);
  186    -1 	});
  187    -1 	socket.on('setDelegate', function(delegate) {
  188    -1 		var sql = 'UPDATE nodes SET delegate = $3 WHERE topic = $1 AND id = $2';
  189    -1 		handleMsg('setDelegate', sql, delegate);
  190    -1 	});
  191    -1 	socket.on('rmDelegate', function() {
  192    -1 		var sql = 'UPDATE nodes SET delegate = null WHERE topic = $1 AND id = $2';
  193    -1 		handleMsg('rmDelegate', sql);
  194    -1 	});
  195    -1 
  196    -1 	socket.on('testClear', function(done) {
  197    -1 		if (topic.substr(0, 4) === 'test') {
  198    -1 			log.debug('Handeling:', 'testClear', topic);
  199    -1 			queryDB("DELETE FROM nodes WHERE topic = $1", [topic]).then(done);
  200    -1 		} else {
  201    -1 			done();
  202    -1 		}
  203    -1 	});
  204    -1 });

diff --git a/bin/manage_db.sh b/bin/manage_db.sh

@@ -1,33 +0,0 @@
    1    -1 #!/bin/sh
    2    -1 
    3    -1 cd "$(dirname "$0")/.."
    4    -1 DB_DIR="$(pwd)/data/postgres"
    5    -1 mkdir -p $DB_DIR
    6    -1 
    7    -1 start() {
    8    -1 	pg_ctl start -w -D "$DB_DIR" -o "-h localhost" -o "-k '$DB_DIR'"
    9    -1 }
   10    -1 
   11    -1 stop() {
   12    -1 	pg_ctl stop -D "$DB_DIR"
   13    -1 }
   14    -1 
   15    -1 if [ "$1" = 'start' ]; then
   16    -1 	start
   17    -1 elif [ "$1" = 'stop' ]; then
   18    -1 	stop
   19    -1 elif [ "$1" = 'init' ]; then
   20    -1 	if test ! -d "$DB_DIR/base"; then
   21    -1 		pg_ctl initdb -D "$DB_DIR"
   22    -1 		start
   23    -1 		createdb -h "$DB_DIR" voterunner
   24    -1 		stop
   25    -1 	else
   26    -1 		echo "skipping"
   27    -1 	fi
   28    -1 elif [ "$1" = 'clean' ]; then
   29    -1 	rm -r "$DB_DIR"
   30    -1 else
   31    -1 	echo "invalid command"
   32    -1 	exit 1
   33    -1 fi

diff --git a/tpl/app.html b/index.html

@@ -2,7 +2,7 @@
    2     2 <html class="voterunner">
    3     3 <head>
    4     4 	<meta charset="utf-8">
    5    -1 	<title>voterunner - {{ topic }}</title>
   -1     5 	<title>voterunner</title>
    6     6 	<meta name="viewport" content="width=device-width" />
    7     7 	<meta name="robots" content="noindex" />
    8     8 	<link rel="shortcut icon" href="/favicon.ico"/>
@@ -36,8 +36,6 @@
   36    36 		<div id="tree"></div>
   37    37 	</div></div>
   38    38 
   39    -1 	{{ nodes|json }}
   40    -1 
   41    39 	<script type="text/javascript" src="/voterunner.js"></script>
   42    40 </body>
   43    41 </html>

diff --git a/package.json b/package.json

@@ -1,19 +1,9 @@
    1     1 {
    2     2 	"name": "voterunner",
    3     3 	"version": "0.0.1",
    4    -1 	"dependencies": {
    5    -1 		"any-db": "^2.2.0",
    6    -1 		"any-db-postgres": "^2.1.5",
    7    -1 		"any-db-sqlite3": "^2.1.4",
    8    -1 		"express": "^4.16.2",
    9    -1 		"log4js": "^0.6.38",
   10    -1 		"markdown-it": "^7.0.1",
   11    -1 		"socket.io": "^1.7.4"
   12    -1 	},
   13     4 	"devDependencies": {
   14    -1 		"mfbs": "^3.1.1",
   15    -1 		"preact": "^8.2.6",
   16    -1 		"socket.io-client": "^1.7.4"
   -1     5 		"preact": "^10.5.4",
   -1     6 		"mfbs": "^4.0.0"
   17     7 	},
   18     8 	"author": "Tobias Bengfort",
   19     9 	"license": "AGPL-3+"

diff --git a/static/scss/bar.scss b/scss/bar.scss

diff --git a/static/scss/base.scss b/scss/base.scss

diff --git a/static/scss/layout.scss b/scss/layout.scss

diff --git a/static/scss/style.scss b/scss/style.scss

@@ -7,9 +7,9 @@ $color-border: #c0c0c0;
    7     7 
    8     8 $padding: 0.4em;
    9     9 
   10    -1 @import '../../node_modules/mfbs/sass/variables';
   11    -1 @import '../../node_modules/mfbs/sass/base';
   12    -1 @import '../../node_modules/mfbs/sass/layout';
   -1    10 @import '../node_modules/mfbs/sass/variables';
   -1    11 @import '../node_modules/mfbs/sass/base';
   -1    12 @import '../node_modules/mfbs/sass/layout';
   13    13 
   14    14 @import 'base';
   15    15 @import 'layout';

diff --git a/static/scss/tree.scss b/scss/tree.scss

diff --git a/static/scss/user.scss b/scss/user.scss

diff --git a/static/src/template.js b/src/template.js

@@ -25,23 +25,19 @@ var getDelegationChain = function(nodes, node) {
   25    25 	return node.delegationChain;
   26    26 };
   27    27 
   28    -1 var getName = function(node) {
   29    -1 	return node.name || 'anonymous';
   30    -1 };
   31    -1 
   32    -1 var tplFollowers = function(nodes, id, ID) {
   33    -1 	return nodes
   -1    28 var tplFollowers = function(state, id) {
   -1    29 	return state.nodes
   34    30 		.filter(n => n.delegate === id)
   35    -1 		.sort((a, b) => getVotes(nodes, b) - getVotes(nodes, a))
   36    -1 		.map(n => tplNode(nodes, n, ID));
   -1    31 		.sort((a, b) => getVotes(state.nodes, b) - getVotes(state.nodes, a))
   -1    32 		.map(n => tplNode(state, n));
   37    33 };
   38    34 
   39    -1 var tplNode = function(nodes, node, ID) {
   -1    35 var tplNode = function(state, node) {
   40    36 	var classList = [];
   41    37 	if (node.expanded) {
   42    38 		classList.push('is-expanded');
   43    39 	}
   44    -1 	if (node.id === ID) {
   -1    40 	if (node.id === state.id) {
   45    41 		classList.push('node--self');
   46    42 	}
   47    43 
@@ -64,14 +60,16 @@ var tplNode = function(nodes, node, ID) {
   64    60 				}, node.expanded ? '\u25BC' : '\u25B6'),
   65    61 				h('button', {
   66    62 					className: 'node__delegate bar__item bar__item--button bar__item--right',
   67    -1 					title: 'delegate to ' + getName(node),
   -1    63 					title: 'delegate to ' + node.id,
   68    64 					disabled: (
   69    -1 						node.id === ID ||
   70    -1 						getDelegationChain(nodes, node).includes(ID)
   -1    65 						!navigator.onLine ||
   -1    66 						!state.id ||
   -1    67 						node.id === state.id ||
   -1    68 						getDelegationChain(state.nodes, node).includes(state.id)
   71    69 					),
   72    70 				}, '\u2795'),
   73    -1 				h('div', {className: 'node__votes bar__item bar__item--right'}, '' + getVotes(nodes, node)),
   74    -1 				h('div', {className: 'node__name bar__item' + (!node.expanded && node.comment ? '' : ' bar__item--grow')}, getName(node)),
   -1    71 				h('div', {className: 'node__votes bar__item bar__item--right'}, '' + getVotes(state.nodes, node)),
   -1    72 				h('div', {className: 'node__name bar__item' + (!node.expanded && node.comment ? '' : ' bar__item--grow')}, node.id),
   75    73 				!node.expanded && node.comment && h('div', {className: 'node__preview bar__item bar__item--grow'}, node.comment.substr(0, 100)),
   76    74 			]),
   77    75 			node.expanded && h('div', {className: 'node__comment'}, node.comment || ''),
@@ -79,19 +77,18 @@ var tplNode = function(nodes, node, ID) {
   79    77 		h('ul', {
   80    78 			className: 'tree',
   81    79 			role: 'group',
   82    -1 		}, tplFollowers(nodes, node.id, ID)),
   -1    80 		}, tplFollowers(state, node.id)),
   83    81 	]);
   84    82 };
   85    83 
   86    -1 var template = function(nodes, ID) {
   -1    84 var template = function(state) {
   87    85 	return h('ul', {
   88    86 		className: 'tree',
   89    87 		role: 'tree',
   90    -1 	}, tplFollowers(nodes, null, ID));
   -1    88 	}, tplFollowers(state, null));
   91    89 };
   92    90 
   93    91 module.exports = {
   94    92 	getVotes: getVotes,
   95    -1 	getName: getName,
   96    93 	template: template,
   97    94 };

diff --git a/static/src/utils.js b/src/utils.js

@@ -33,14 +33,14 @@ var on = function(element, eventType, selector, fn) {
   33    33 	});
   34    34 };
   35    35 
   36    -1 var initVDom = function(wrapper, template, nodes, ID, afterRender) {
   -1    36 var initVDom = function(wrapper, template, state, afterRender) {
   37    37 	wrapper.innerHTML = '';
   38    -1 	var tree = template(nodes, ID);
   -1    38 	var tree = template(state);
   39    39 	var element = preact.render(tree, wrapper);
   40    40 	afterRender();
   41    41 
   42    42 	return function(newState) {
   43    -1 		var newTree = template(newState, ID);
   -1    43 		var newTree = template(newState);
   44    44 		preact.render(newTree, wrapper, element);
   45    45 		afterRender();
   46    46 	};
@@ -51,19 +51,9 @@ var randomString = function() {
   51    51 	return Math.floor(a).toString(36);
   52    52 };
   53    53 
   54    -1 var setCookie = function(key, value, days) {
   55    -1 	localStorage[key] = value;
   56    -1 };
   57    -1 
   58    -1 var getCookie = function(key) {
   59    -1 	return localStorage[key];
   60    -1 };
   61    -1 
   62    54 module.exports = {
   63    55 	throttle: throttle,
   64    56 	on: on,
   65    57 	initVDom: initVDom,
   66    58 	randomString: randomString,
   67    -1 	setCookie: setCookie,
   68    -1 	getCookie: getCookie,
   69    59 };

diff --git a/static/src/voterunner.js b/src/voterunner.js

@@ -1,34 +1,34 @@
    1    -1 var io = require('socket.io-client');
    2     1 var template = require('./template');
    3     2 var utils = require('./utils');
    4     3 
    5     4 document.addEventListener('DOMContentLoaded', function() {
    6    -1 	var TOPIC = document.URL.split('/')[3];
    7    -1 	var ID = document.URL.split('/')[4];
    8    -1 	if (!ID) ID = utils.getCookie('id');
    9    -1 	if (!ID) ID = utils.randomString();
   10    -1 	utils.setCookie('id', ID, 100);
   11    -1 
   12    -1 	var socket = io.connect('/');
   13    -1 	window.socket = socket;  // make available for tests
   14    -1 	socket.emit('register', TOPIC, ID);
   15    -1 
   16    -1 	var nodes = JSON.parse(document.querySelector('#json-nodes').dataset.value);
   -1     5 	if (!location.hash) {
   -1     6 		location.hash = utils.randomString();
   -1     7 	}
   -1     8 	var topic = location.hash.substr(1);
   -1     9 	var url = 'https://via.ce9e.org/hmsg/voterunner/' + topic;
   -1    10 	document.title += ' - ' + topic;
   -1    11 
   -1    12 	var state = {
   -1    13 		nodes: [],
   -1    14 		id: null,
   -1    15 		dirty: false,
   -1    16 	};
   17    17 
   18    18 	var getNode = function(id) {
   19    -1 		var node = nodes.find(n => n.id === id);
   -1    19 		var node = state.nodes.find(n => n.id === id);
   20    20 		if (!node) {
   21    21 			node = {
   22    22 				id: id,
   23    23 				delegate: null,
   24    24 			};
   25    -1 			nodes.push(node);
   -1    25 			state.nodes.push(node);
   26    26 		}
   27    27 		return node;
   28    28 	};
   29    29 
   30    30 	var invalidateVotes = function() {
   31    -1 		nodes.forEach(function(node) {
   -1    31 		state.nodes.forEach(function(node) {
   32    32 			node.votes = null;
   33    33 			node.delegationChain = null;
   34    34 		});
@@ -42,26 +42,25 @@ document.addEventListener('DOMContentLoaded', function() {
   42    42 		}
   43    43 	};
   44    44 
   45    -1 	var user = nodes.find(n => n.id === ID);
   46    -1 	if (user) {
   47    -1 		document.querySelector('.user__name input').value = user.name;
   48    -1 		document.querySelector('.user__comment textarea').value = user.comment;
   49    -1 		ensureVisible(user);
   50    -1 	}
   -1    45 	var update = utils.initVDom(document.querySelector('#tree'), template.template, state, function() {
   -1    46 		var user = state.nodes.find(n => n.id === state.id);
   51    47 
   52    -1 	var updateUser = function() {
   53    -1 		document.querySelector('.user__votes').textContent = template.getVotes(nodes, user || {});
   -1    48 		document.querySelector('.user__votes').textContent = template.getVotes(state.nodes, user || {});
   54    49 
   55    50 		if (user && user.delegate) {
   56    -1 			var delegatee = getNode(user.delegate);
   57    -1 			document.querySelector('.user__delegation').textContent = 'delegated to: ' + template.getName(delegatee);
   -1    51 			document.querySelector('.user__delegation').textContent = 'delegated to: ' + user.delegate;
   58    52 		} else {
   59    53 			document.querySelector('.user__delegation').textContent = '(no delegation)';
   60    54 		}
   61    -1 	};
   62    55 
   63    -1 	var update = utils.initVDom(document.querySelector('#tree'), template.template, nodes, ID, function() {
   64    -1 		updateUser();
   -1    56 		if (!state.dirty) {
   -1    57 			document.querySelector('.user__comment textarea').value = user ? user.comment || '' : '';
   -1    58 		}
   -1    59 
   -1    60 		var disabled = !state.id || !navigator.onLine;
   -1    61 		document.querySelector('.user__rm').disabled = disabled;
   -1    62 		document.querySelector('.user__undelegate').disabled = disabled;
   -1    63 		document.querySelector('.user__comment textarea').disabled = disabled;
   65    64 	});
   66    65 
   67    66 	utils.on(document, 'click', '.node__expand', function() {
@@ -69,68 +68,101 @@ document.addEventListener('DOMContentLoaded', function() {
   69    68 		var id = nodeElement.id.substr(5);
   70    69 		var node = getNode(id);
   71    70 		node.expanded = !node.expanded;
   72    -1 		update(nodes);
   -1    71 		update(state);
   73    72 	});
   74    73 
   75    74 	utils.on(document, 'click', '.node__delegate', function() {
   76    75 		var nodeElement = this.parentElement.parentElement.parentElement;
   77    76 		var id = nodeElement.id.substr(5);
   78    -1 		socket.emit('setDelegate', id);
   -1    77 		fetch(url, {
   -1    78 			method: 'POST',
   -1    79 			body: JSON.stringify(['setDelegate', state.id, id]),
   -1    80 		});
   79    81 	});
   80    82 
   81    83 	utils.on(document, 'click', '.user__rm', function() {
   82    84 		if (confirm('Do you really want to delete this opinion?')) {
   83    -1 			socket.emit('rmNode');
   84    -1 			document.querySelector('.user__name input').value = '';
   -1    85 			fetch(url, {
   -1    86 				method: 'POST',
   -1    87 				body: JSON.stringify(['rmNode', state.id]),
   -1    88 			});
   85    89 			document.querySelector('.user__comment textarea').value = '';
   -1    90 			state.dirty = false;
   86    91 		}
   87    92 	});
   88    93 
   89    -1 	utils.on(document, 'change', '.user__name input', function() {
   90    -1 		socket.emit('setNodeName', this.value);
   -1    94 	utils.on(document, 'input', '.user__name input', function() {
   -1    95 		state.id = this.value;
   -1    96 		state.dirty = false;
   -1    97 		update(state);
   91    98 	});
   92    99 
   93   100 	utils.on(document, 'click', '.user__undelegate', function() {
   94    -1 		socket.emit('rmDelegate');
   -1   101 		fetch(url, {
   -1   102 			method: 'POST',
   -1   103 			body: JSON.stringify(['rmDelegate', state.id]),
   -1   104 		});
   95   105 	});
   96   106 
   97   107 	utils.on(document, 'input', '.user__comment textarea', utils.throttle(function() {
   98   108 		var comment = document.querySelector('.user__comment textarea').value;
   99    -1 		var node = nodes.find(n => n.id === ID);
   -1   109 		var node = state.nodes.find(n => n.id === state.id);
  100   110 		// Do not create a new node if the comment is empty.
  101   111 		// This can happen e.g. on a keydown event from the ctrl or shift keys.
  102   112 		if (node || comment) {
  103    -1 			socket.emit('setNodeComment', comment);
   -1   113 			fetch(url, {
   -1   114 				method: 'POST',
   -1   115 				body: JSON.stringify(['setNodeComment', state.id, comment]),
   -1   116 			});
   -1   117 			state.dirty = true;
  104   118 		}
  105   119 	}, 1000));
  106   120 
  107    -1 	socket.on('rmNode', function(id) {
  108    -1 		nodes = nodes.filter(function(node) {
  109    -1 			if (node.delegate === id) {
  110    -1 				node.delegate = null;
  111    -1 			}
  112    -1 			return node.id !== id;
  113    -1 		});
  114    -1 		invalidateVotes();
  115    -1 		update(nodes);
  116    -1 	});
  117    -1 	socket.on('setNodeName', function(id, name) {
  118    -1 		getNode(id).name = name;
  119    -1 		update(nodes);
  120    -1 	});
  121    -1 	socket.on('setNodeComment', function(id, comment) {
  122    -1 		getNode(id).comment = comment;
  123    -1 		update(nodes);
  124    -1 	});
  125    -1 	socket.on('setDelegate', function(id, delegate) {
  126    -1 		getNode(id).delegate = delegate;
  127    -1 		invalidateVotes();
  128    -1 		ensureVisible(user);
  129    -1 		update(nodes);
  130    -1 	});
  131    -1 	socket.on('rmDelegate', function(id) {
  132    -1 		getNode(id).delegate = null;
  133    -1 		invalidateVotes();
  134    -1 		update(nodes);
  135    -1 	});
   -1   121 	var evtSource = new EventSource(url);
   -1   122 	evtSource.onmessage = function(event) {
   -1   123 		var data = JSON.parse(event.data);
   -1   124 		var name = data[0];
   -1   125 		var id = data[1];
   -1   126 
   -1   127 		if (name === 'setNodes') {
   -1   128 			state.nodes = data[2];
   -1   129 			ensureVisible(state.nodes.find(n => n.id === state.id));
   -1   130 		} else if (!id) {
   -1   131 			return;
   -1   132 		} else if (name === 'rmNode') {
   -1   133 			state.nodes = state.nodes.filter(function(node) {
   -1   134 				if (node.delegate === id) {
   -1   135 					node.delegate = null;
   -1   136 				}
   -1   137 				return node.id !== id;
   -1   138 			});
   -1   139 			invalidateVotes();
   -1   140 		} else if (name === 'setNodeComment') {
   -1   141 			getNode(id).comment = data[2];
   -1   142 		} else if (name === 'setDelegate') {
   -1   143 			getNode(id).delegate = data[2];
   -1   144 			invalidateVotes();
   -1   145 			ensureVisible(state.nodes.find(n => n.id === state.id));
   -1   146 		} else if (name === 'rmDelegate') {
   -1   147 			getNode(id).delegate = null;
   -1   148 			invalidateVotes();
   -1   149 		}
   -1   150 		update(state);
   -1   151 
   -1   152 		if (Math.random() < 0.05) {
   -1   153 			invalidateVotes();
   -1   154 			fetch(url, {
   -1   155 				method: 'PUT',
   -1   156 				body: JSON.stringify(['setNodes', null, state.nodes]),
   -1   157 				headers: {'Last-Event-ID': event.lastEventId},
   -1   158 			});
   -1   159 		}
   -1   160 	};
   -1   161 
   -1   162 	window.testClear = function(done) {
   -1   163 		fetch(url, {
   -1   164 			method: 'PUT',
   -1   165 			body: JSON.stringify(['setNodes', null, []]),
   -1   166 		}).then(done);
   -1   167 	};
  136   168 });

diff --git a/static/test/index.html b/test/index.html

diff --git a/static/test/test.js b/test/test.js

@@ -7,29 +7,42 @@ var trigger = function(target, type) {
    7     7 	target.dispatchEvent(new Event(type, {bubbles: true}));
    8     8 };
    9     9 
   10    -1 var setUp = function(url, fn) {
   -1    10 var setUpUser = function(browser, id) {
   -1    11 	var d = browser.contentDocument;
   -1    12 	var userName = d.querySelector('.user__name input');
   -1    13 	userName.value = id;
   -1    14 	trigger(userName, 'input');
   -1    15 };
   -1    16 
   -1    17 var setUp = function(topic, fn) {
   11    18 	var iframe = document.createElement('iframe');
   12    -1 	iframe.onload = function() {fn(iframe)};
   13    -1 	iframe.url = url;
   14    -1 	iframe.src = url;
   -1    19 	iframe.url = '/#test' + topic;
   -1    20 
   -1    21 	iframe.onload = function() {
   -1    22 		setUpUser(this, ID);
   -1    23 		fn(iframe);
   -1    24 	};
   15    25 
   16    26 	iframe.tearDown = function(done) {
   17    -1 		var self = this;
   18    -1 		self.contentWindow.socket.emit('testClear', function() {
   19    -1 			self.parentNode.removeChild(self);
   -1    27 		this.contentWindow.testClear(() => {
   -1    28 			this.parentNode.removeChild(this);
   20    29 			done();
   21    30 		});
   22    31 	};
   23    32 
   24    33 	iframe.reload = function(fn) {
   25    34 		this.onload = function() {
   26    -1 			this.onload = fn;
   -1    35 			this.onload = function() {
   -1    36 				setUpUser(this, ID);
   -1    37 				setTimeout(fn, TIMEOUT);
   -1    38 			};
   27    39 			this.src = this.url;
   28    40 		};
   29    41 		this.src = '';
   30    42 	};
   31    43 
   32    44 	document.getElementById('testarea').appendChild(iframe);
   -1    45 	iframe.src = iframe.url;
   33    46 };
   34    47 
   35    48 describe('load', function() {
@@ -37,7 +50,7 @@ describe('load', function() {
   37    50 	var browser;
   38    51 
   39    52 	before(function(done) {
   40    -1 		setUp('/test' + test + '/', function(b) {
   -1    53 		setUp(test, function(b) {
   41    54 			browser = b;
   42    55 			done();
   43    56 		});
@@ -56,59 +69,6 @@ describe('load', function() {
   56    69 	});
   57    70 });
   58    71 
   59    -1 describe('setName', function() {
   60    -1 	var test = 'setName';
   61    -1 	var name = 'testName';
   62    -1 	var browser;
   63    -1 	var d, userName, node, nodeName;
   64    -1 
   65    -1 	before(function(done) {
   66    -1 		setUp('/test' + test + '/' + ID, function(b) {
   67    -1 			browser = b;
   68    -1 			d = browser.contentDocument;
   69    -1 
   70    -1 			userName = d.querySelector('.user__name input');
   71    -1 			userName.value = name;
   72    -1 			trigger(userName, 'change');
   73    -1 
   74    -1 			setTimeout(done, TIMEOUT);
   75    -1 		});
   76    -1 	});
   77    -1 
   78    -1 	after(function(done) {
   79    -1 		browser.tearDown(done);
   80    -1 	});
   81    -1 
   82    -1 	it('should set user name', function() {
   83    -1 		expect(userName.value).to.equal(name);
   84    -1 	});
   85    -1 
   86    -1 	it('node sould exist', function() {
   87    -1 		node = d.getElementById('node-' + ID);
   88    -1 		expect(node).to.exist;
   89    -1 	});
   90    -1 
   91    -1 	it('should set node name', function() {
   92    -1 		node = d.getElementById('node-' + ID);
   93    -1 		nodeName = node.querySelector('.node__name').textContent;
   94    -1 		expect(nodeName).to.equal(name);
   95    -1 	});
   96    -1 
   97    -1 	it('should be permanent', function(done) {
   98    -1 		browser.reload(function() {
   99    -1 			d = browser.contentDocument;
  100    -1 			userName = d.querySelector('.user__name input').value;
  101    -1 			expect(userName).to.equal(name);
  102    -1 
  103    -1 			node = d.getElementById('node-' + ID);
  104    -1 			nodeName = node.querySelector('.node__name').textContent;
  105    -1 			expect(nodeName).to.equal(name);
  106    -1 
  107    -1 			done();
  108    -1 		});
  109    -1 	});
  110    -1 });
  111    -1 
  112    72 describe('setComment', function() {
  113    73 	var test = 'setComment';
  114    74 	var comment = 'testComment';
@@ -116,7 +76,7 @@ describe('setComment', function() {
  116    76 	var d, userComment, node, nodeComment, nodeExpand;
  117    77 
  118    78 	before(function(done) {
  119    -1 		setUp('/test' + test + '/' + ID, function(b) {
   -1    79 		setUp(test, function(b) {
  120    80 			browser = b;
  121    81 			d = browser.contentDocument;
  122    82 
@@ -177,18 +137,15 @@ describe('removeDelegate', function() {
  177   137 describe('remove', function() {
  178   138 	var test = 'remove';
  179   139 	var browser;
  180    -1 	var d, userName, userComment, userRemove;
   -1   140 	var d, userComment, userRemove;
  181   141 
  182   142 	before(function(done) {
  183    -1 		setUp('/test' + test + '/' + ID, function(b) {
   -1   143 		setUp(test, function(b) {
  184   144 			browser = b;
  185   145 			d = browser.contentDocument;
  186   146 			browser.contentWindow.confirm = () => true;
  187   147 
  188   148 			// create something to delete
  189    -1 			userName = d.querySelector('.user__name input');
  190    -1 			userName.value = 'testName';
  191    -1 			trigger(userName, 'change');
  192   149 			userComment = d.querySelector('.user__comment textarea');
  193   150 			userComment.value = 'testComment';
  194   151 			trigger(userComment, 'input');
@@ -209,11 +166,6 @@ describe('remove', function() {
  209   166 		expect(node).to.not.exist;
  210   167 	});
  211   168 
  212    -1 	it('should clear user name', function() {
  213    -1 		userName = d.querySelector('.user__name input').value;
  214    -1 		expect(userName).to.equal('');
  215    -1 	});
  216    -1 
  217   169 	it('should clear user comment', function() {
  218   170 		userComment = d.querySelector('.user__comment textarea').value;
  219   171 		expect(userComment).to.equal('');

diff --git a/tpl/markdown.html b/tpl/markdown.html

@@ -1,17 +0,0 @@
    1    -1 <!DOCTYPE html>
    2    -1 <html xmlns="http://www.w3.org/1999/xhtml">
    3    -1 
    4    -1 <head>
    5    -1 	<meta charset="utf-8">
    6    -1 	<title>voterunner</title>
    7    -1 	<meta name="viewport" content="width=device-width" />
    8    -1 	<link rel="shortcut icon" href="/favicon.ico"/>
    9    -1 	<link rel="stylesheet" type="text/css" href="/style.css" />
   10    -1 	<meta name="description" content="quick and dirty votes and discussions" />
   11    -1 </head>
   12    -1 
   13    -1 <body>
   14    -1 	{{{ markdown|markdown }}}
   15    -1 </body>
   16    -1 
   17    -1 </html>