diff --git a/html/ui.js b/html/ui.js
new file mode 100644
index 0000000..5de2f4c
--- /dev/null
+++ b/html/ui.js
@@ -0,0 +1,358 @@
+//===== Collection of small utilities
+
+/*
+ * Bind/Unbind events
+ *
+ * Usage:
+ * var el = document.getElementyById('#container');
+ * bnd(el, 'click', function() {
+ * console.log('clicked');
+ * });
+ */
+
+var bnd = function(
+ d, // a DOM element
+ e, // an event name such as "click"
+ f // a handler function
+){
+ d.addEventListener(e, f, false);
+}
+
+/*
+ * Create DOM element
+ *
+ * Usage:
+ * var el = m('
Hello
');
+ * document.body.appendChild(el);
+ *
+ * Copyright (C) 2011 Jed Schmidt - WTFPL
+ * More: https://gist.github.com/966233
+ */
+
+var m = function(
+ a, // an HTML string
+ b, // placeholder
+ c // placeholder
+){
+ b = document; // get the document,
+ c = b.createElement("p"); // create a container element,
+ c.innerHTML = a; // write the HTML to it, and
+ a = b.createDocumentFragment(); // create a fragment.
+
+ while ( // while
+ b = c.firstChild // the container element has a first child
+ ) a.appendChild(b); // append the child to the fragment,
+
+ return a // and then return the fragment.
+}
+
+/*
+ * DOM selector
+ *
+ * Usage:
+ * $('div');
+ * $('#name');
+ * $('.name');
+ *
+ * Copyright (C) 2011 Jed Schmidt - WTFPL
+ * More: https://gist.github.com/991057
+ */
+
+var $ = function(
+ a, // take a simple selector like "name", "#name", or ".name", and
+ b // an optional context, and
+){
+ a = a.match(/^(\W)?(.*)/); // split the selector into name and symbol.
+ return( // return an element or list, from within the scope of
+ b // the passed context
+ || document // or document,
+ )[
+ "getElement" + ( // obtained by the appropriate method calculated by
+ a[1]
+ ? a[1] == "#"
+ ? "ById" // the node by ID,
+ : "sByClassName" // the nodes by class name, or
+ : "sByTagName" // the nodes by tag name,
+ )
+ ](
+ a[2] // called with the name.
+ )
+}
+
+/*
+ * Get cross browser xhr object
+ *
+ * Copyright (C) 2011 Jed Schmidt
+ * More: https://gist.github.com/993585
+ */
+
+var j = function(
+ a // cursor placeholder
+){
+ for( // for all a
+ a=0; // from 0
+ a<4; // to 4,
+ a++ // incrementing
+ ) try { // try
+ return a // returning
+ ? new ActiveXObject( // a new ActiveXObject
+ [ // reflecting
+ , // (elided)
+ "Msxml2", // the various
+ "Msxml3", // working
+ "Microsoft" // options
+ ][a] + // for Microsoft implementations, and
+ ".XMLHTTP" // the appropriate suffix,
+ ) // but make sure to
+ : new XMLHttpRequest // try the w3c standard first, and
+ }
+
+ catch(e){} // ignore when it fails.
+}
+
+// createElement short-hand
+
+e = function(a) { return document.createElement(a); }
+
+// chain onload handlers
+
+function onLoad(f) {
+ var old = window.onload;
+ if (typeof old != 'function') {
+ window.onload = f;
+ } else {
+ window.onload = function() {
+ old();
+ f();
+ }
+ }
+}
+
+//===== helpers to add/remove/toggle HTML element classes
+
+function addClass(el, cl) {
+ el.className += ' ' + cl;
+}
+function removeClass(el, cl) {
+ var cls = el.className.split(/\s+/),
+ l = cls.length;
+ for (var i=0; i= 200 && xhr.status < 300) {
+ console.log("XHR done:", method, url, "->", xhr.status);
+ ok_cb(xhr.responseText);
+ } else {
+ console.log("XHR ERR :", method, url, "->", xhr.status, xhr.responseText, xhr);
+ err_cb(xhr.status, xhr.responseText);
+ }
+ }
+ console.log("XHR send:", method, url);
+ try {
+ xhr.send();
+ } catch(err) {
+ console.log("XHR EXC :", method, url, "->", err);
+ err_cb(599, err);
+ }
+}
+
+function dispatchJson(resp, ok_cb, err_cb) {
+ var j;
+ try { j = JSON.parse(resp); }
+ catch(err) {
+ console.log("JSON parse error: " + err + ". In: " + resp);
+ err_cb(500, "JSON parse error: " + err);
+ return;
+ }
+ ok_cb(j);
+}
+
+function ajaxJson(method, url, ok_cb, err_cb) {
+ ajaxReq(method, url, function(resp) { dispatchJson(resp, ok_cb, err_cb); }, err_cb);
+}
+
+function ajaxSpin(method, url, ok_cb, err_cb) {
+ $("#spinner").removeAttribute('hidden');
+ ajaxReq(method, url, function(resp) {
+ $("#spinner").setAttribute('hidden', '');
+ ok_cb(resp);
+ }, function(status, statusText) {
+ $("#spinner").setAttribute('hidden', '');
+ //showWarning("Error: " + statusText);
+ err_cb(status, statusText);
+ });
+}
+
+function ajaxJsonSpin(method, url, ok_cb, err_cb) {
+ ajaxSpin(method, url, function(resp) { dispatchJson(resp, ok_cb, err_cb); }, err_cb);
+}
+
+//===== main menu, header spinner and notification boxes
+
+onLoad(function() {
+ var l = $("#layout");
+ var o = l.childNodes[0];
+ // spinner
+ l.insertBefore(m(''), o);
+ // notification boxes
+ l.insertBefore(m(
+ ''), o);
+ // menu hamburger button
+ l.insertBefore(m(''), o);
+ // menu left-pane
+ var mm = m(
+ '\
+ ');
+ l.insertBefore(mm, o);
+
+ // make hamburger button pull out menu
+ var ml = $('#menuLink'), mm = $('#menu');
+ bnd(ml, 'click', function (e) {
+ console.log("hamburger time");
+ var active = 'active';
+ e.preventDefault();
+ toggleClass(l, active);
+ toggleClass(mm, active);
+ toggleClass(ml, active);
+ });
+
+ // populate menu via ajax call
+ var getMenu = function() {
+ ajaxJson("GET", "/menu", function(data) {
+ var html = "", path = window.location.pathname;
+ for (var i=0; i