Переработан веб-интерфейс. Сделаны всплывающие уведомления.
authorRoman Bazalevskiy <rvb@rvb.name>
Fri, 18 Nov 2022 09:49:20 +0000 (12:49 +0300)
committerRoman Bazalevskiy <rvb@rvb.name>
Fri, 18 Nov 2022 09:49:20 +0000 (12:49 +0300)
Clock.h
alarm.cpp
data/web/index.html
data/web/script.js
data/web/style.css
hardware.cpp
time.cpp
ui.yml
weather.cpp
web.cpp

diff --git a/Clock.h b/Clock.h
index f1cb6b598376310b7097a9d0cb55fc4de6988718..cd3620d9d7687c3f52eb84fa9d5a8989bfb545a0 100644 (file)
--- a/Clock.h
+++ b/Clock.h
@@ -197,4 +197,5 @@ extern bool isApEnabled;
 void setupWeb();
 void tickWeb();
 void reportChange(const __FlashStringHelper* name);
+void reportMessage(const __FlashStringHelper* msg);
 void sendWeather();
index e609dbaed71f95ccbe614dac210c02fc943407d5..2964418918831870efa798ab4748f4bfe922600a 100644 (file)
--- a/alarm.cpp
+++ b/alarm.cpp
@@ -24,6 +24,7 @@ void checkAlarm() {
   int alarm_silent_ms = cfg.getIntValue(F("alarm_silent_ms"));
   if ( enable_alarm && (hh == alarm_hour) && (mi == alarm_minute) && (alarm_days[dw?dw-1:6]!='0')) {
     beep(alarm_tone, alarm_length * 1000, alarm_beep_ms, alarm_silent_ms);
+    reportMessage(F("Будильник сработал!"));
   }
 }
 
index a2ec589e61a246f04bbd81cdbd21d6c988c3f5d0..0688a499102221424aa6ce6602d96944e46d07e3 100644 (file)
 <body>
 
 <div id="layout">
-    <!-- Menu toggle -->
-    <a href="#menu" id="menuLink" class="menu-link">
-        <!-- Hamburger icon -->
-        <span id="menuBtn"></span>
-    </a>
+  <!-- Menu toggle -->
+  <a href="#menu" id="menuLink" class="menu-link">
+    <!-- Hamburger icon -->
+    <span id="menuBtn"></span>
+  </a>
 
-    <div id="menu">
-        <div class="pure-menu">
-            <a id="_ui_menu_header" class="pure-menu-heading" href="/"></a>
-
-            <ul id="_ui_menu_list" class="pure-menu-list">
-            </ul>
-        </div>
-        <footer id="_ui_contacts">
-        </footer>
+  <div id="menu">
+    <div class="pure-menu">
+      <a id="_ui_menu_header" class="pure-menu-heading" href="/"></a>
+      <ul id="_ui_menu_list" class="pure-menu-list">
+      </ul>
     </div>
+    <footer id="_ui_contacts"></footer>
+  </div>
+
+  <div id="_ui_notification" class="notification" hidden></div>
+  <div class="message fadeout" id="_ui_message" hidden>
+    <div class="message-text" id="_ui_message_text"></div>
+    <div class="close-button" onclick="closeMsg()" title="Закрыть">Х</div>
+  </div>
 
-    <div id="main">
-        <div class="header">
-            <div id="_ui_notification" class="notification" hidden></div>
-            <h1 id="_ui_page_header"></h1>
-        </div>
-        <div class="content">
-          <form class="pure-form pure-form-stacked">
-            <fieldset>
-              <div class="pure-g" id="_ui_page_content">
-              </div>
-            </fieldset>
+  <div id="main">
+    <div class="header">
+      <h1 id="_ui_page_header"></h1>
+    </div>
+    <div class="content">
+      <form class="pure-form pure-form-stacked">
+        <fieldset>
+          <div class="pure-g" id="_ui_page_content">
           </div>
-        </div>
+        </fieldset>
+      </div>
     </div>
+  </div>
 </div>
 
 <script src="/script.js"></script>
index c40f7e1d010716da669c095e26581754d15f68ac..cb4fa7ef4852c80f21ea6bfe7194087041f290cc 100644 (file)
@@ -1,6 +1,7 @@
 var pages
 var parameters = {}
 var daynames = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'] 
+var msgT
 
 function toggleMenu(e) {
   active = (document.getElementById('menuLink').className.indexOf('active') !== -1)
@@ -28,22 +29,22 @@ function encode(r){
 }
 
 function getAnchor() {
-    return window.location.hash;
+    return window.location.hash.slice(1);
 }
 
-function DrawHeader(project) {
+function drawHeader(project) {
   var menu_header = document.getElementById('_ui_menu_header')
   menu_header.innerHTML = project.name
   document.title = project.name + '/' + project.version
 }
 
-function ParseJsonQ(url, callback) {
+function parseJsonQ(url, callback) {
   var req = new XMLHttpRequest();
 
   req.onreadystatechange = function () {
     if (this.readyState != 4) return; 
     if (this.status != 200 && this.status != 500 && this.status != 404) {
-      setTimeout(ParseJsonQ(url, callback),30000);
+      setTimeout(parseJsonQ(url, callback),30000);
       return;
     }
     var json = JSON.parse(this.responseText)
@@ -55,7 +56,7 @@ function ParseJsonQ(url, callback) {
 
 }
 
-function UpdateElement(id, value) {
+function updateElement(id, value) {
   var element = document.getElementById("_ui_element_"+id)
   if (!element) return;
   var ui_class = element.dataset.ui_class;
@@ -87,17 +88,17 @@ function UpdateElement(id, value) {
   }  
 }
 
-function UpdateValues(json) {
+function updateValues(json) {
   for (var key in json) {
     var obj = document.getElementById("_ui_element_"+key)
     if (obj) {
-      UpdateElement(key, json[key])
+      updateElement(key, json[key])
     }
     parameters[key] = json[key]
   }
   var notification = document.getElementById('_ui_notification');
   if (parameters['_changed']) {
-    notification.innerHTML = '<input style="margin:1em .5em 0;" type="button" id="save" value="Сохранить внесенные изменения" class="pure-button pure-button-primary" onclick="sendAction(\'save\')">'
+    notification.innerHTML = '<input type="button" id="save" value="Сохранить" class="pure-button" onclick="sendAction(\'save\')">'
     notification.removeAttribute('hidden')
   } else {
     notification.innerHTML = ''
@@ -114,35 +115,39 @@ function sendUpdate(id) {
     case 'number':
     case 'range':
       if (input.checkValidity() && input.value != parameters[id]) {
-        ParseJsonQ('/config/set?name=' + id + '&value=' + encodeURIComponent(input.value), function(json) {
-          UpdateValues(json)
+        parseJsonQ('/config/set?name=' + id + '&value=' + encodeURIComponent(input.value), function(json) {
+          updateValues(json)
         })
       }
       break
     case 'select':
-      ParseJsonQ('/config/set?name=' + id + '&value=' + encodeURIComponent(input.selectedOptions[0].value), function(json) {
-        UpdateValues(json)
+      parseJsonQ('/config/set?name=' + id + '&value=' + encodeURIComponent(input.selectedOptions[0].value), function(json) {
+        updateValues(json)
       })
       break;
     case 'checkbox':
-      ParseJsonQ('/config/set?name=' + id + '&value=' + (input.checked?'true':'false'), function(json) {
-        UpdateValues(json)
+      parseJsonQ('/config/set?name=' + id + '&value=' + (input.checked?'true':'false'), function(json) {
+        updateValues(json)
       })      
       break;
     case 'week':        
-      ParseJsonQ('/config/set?name=' + id + '&value=' + input.dataset.value, function(json) {
-        UpdateValues(json)
+      parseJsonQ('/config/set?name=' + id + '&value=' + input.dataset.value, function(json) {
+        updateValues(json)
       })      
       break;
   }
 }
 
-function sendAction(name) {
-  ParseJsonQ('/action?name=' + name, function(json) {
+function sendAction(name, params = {}) {
+  var url = '/action?name=' + name
+  for (var param in params) {
+    url += '&'+param+'='+encodeURIComponent(params[param])
+  }
+  parseJsonQ(url, function(json) {
     if (json.result == 'FAILED') {
       alert(json.message)
       if (json.page) {
-        DrawPage(json.page)
+        drawPage(json.page)
       }
     } else {
       location.reload()
@@ -150,7 +155,7 @@ function sendAction(name) {
   })
 }
 
-function ShowPwd(id) {
+function showPwd(id) {
   var x = document.getElementById('_ui_element_' + id)
   if (x.type === "password") {
     x.type = "text";
@@ -159,20 +164,41 @@ function ShowPwd(id) {
   }
 }
 
-function OpenSelect(id) {
+function openSelect(id) {
   var selector = document.getElementById('_ui_elemmodal_'+id);
   selector.removeAttribute("hidden")
 }
 
-function CloseSelect(id) {
+function closeSelect(id) {
   var selector = document.getElementById('_ui_elemmodal_'+id);
   selector.hidden = true
 }
 
-function SelectWiFi(id, ssid) {
-  CloseSelect(id);
+function closeMsg() {
+  document.getElementById("_ui_message").hidden = true;
+}
+
+function fadeMsg() {
+  var msg = document.getElementById('_ui_message');
+  msg.classList.add("fadeout")
+  msgT = setTimeout(()=> { closeMsg() }, 5000);
+}
+
+function openMsg(msgText) {
+  document.getElementById("_ui_message_text").innerText = msgText;
+  document.getElementById("_ui_message").classList.remove("fadeout"); 
+  document.getElementById("_ui_message").removeAttribute('hidden');
+  
+  if (msgT) { 
+    window.clearTimeout(msgT); 
+  }
+  msgT = setTimeout(()=> { fadeMsg(); }, 5000);
+}
+
+function selectWiFi(id, ssid) {
+  closeSelect(id);
   var x = document.getElementById('_ui_element_' + id)
-  UpdateElement(x,ssid);
+  updateElement(x,ssid);
   sendUpdate(id)
 }
 
@@ -188,13 +214,13 @@ function getWiFi(id) {
       return;
     }
     var json = JSON.parse(this.responseText)
-    var table = '<table cellpadding="5" border="0" align="center"><thead class="table-header"><tr><td style="padding: 1rem">SSID</td><td style="padding: 1rem">BSSID</td><td style="padding: 1rem">RSSI</td><td style="padding: 1rem">Канал</td><td style="padding: 1rem">Защита</td></tr></thead></tbody style="border-bottom: lightgrey 1px solid">'
+    var table = '<table cellpadding="5" border="0" align="center"><thead class="table-header"><tr><td>SSID</td><td>BSSID</td><td>RSSI</td><td>Канал</td><td>Защита</td></tr></thead><tbody>'
     if (!json.length) {
       setTimeout(getWiFi(id),5000);
     }
     for (idx in json) {
       var encryption = json[idx].secure == 2? "TKIP" : json[idx].secure == 5? "WEP" : json[idx].secure == 4? "CCMP" : json[idx].secure == 7? "нет" : json[idx].secure == 8? "Автоматически" : "Не определено";
-      table += '<tr onclick="SelectWiFi(\''+id+'\',\''+json[idx].ssid+'\')"><td style="padding: 1rem">'+json[idx].ssid+'</td><td style="padding: 1rem">'+json[idx].bssid+'</td><td style="padding: 1rem">'+json[idx].rssi+'</td><td style="padding: 1rem">'+json[idx].channel+'</td><td style="padding: 1rem">'+encryption+'</td></tr>'
+      table += '<tr onclick="selectWiFi(\''+id+'\',\''+json[idx].ssid+'\')"><td>'+json[idx].ssid+'</td><td>'+json[idx].bssid+'</td><td>'+json[idx].rssi+'</td><td>'+json[idx].channel+'</td><td>'+encryption+'</td></tr>'
     }
     
     table += '</tbody></table>'
@@ -203,10 +229,9 @@ function getWiFi(id) {
 
   req.open("GET", "/wifi/scan", true);
   req.send()
-
 }
 
-function ClickDay(id, day) {
+function clickDay(id, day) {
   value = parameters[id].split('')
   day_value = value[day]
   day_value = (day_value=='0')?'1':'0'
@@ -217,7 +242,16 @@ function ClickDay(id, day) {
   sendUpdate(id);
 }
 
-function ElementHTML(element) {
+function sendTime(id) {
+  var value = document.getElementById('_ui_element_' + id).value
+  if (value) {
+    var date = new Date(value)
+    var timestamp = Math.floor(date.getTime()/1000);
+    sendAction('time',{"timestamp":timestamp})
+  }
+}
+
+function elementHTML(element) {
   var value
   if (parameters[element.id] || !isNaN(parameters[element.id])) {
     value = parameters[element.id]
@@ -230,13 +264,13 @@ function ElementHTML(element) {
     case 'hr':
       return '<div class="pure-u-1 pure-u-md-1-3"><hr></div>'
     case 'button':
-      return '<div class="pure-u-1 pure-u-md-1-3"><div align="center"><input style="margin:1em .5em 0;" type="button" id="' 
-        + element.id + '" value="' + encode(element.label) + '" class="pure-button pure-button-primary" onclick="sendAction(\'' + element.id + '\')" /></div></div>'
+      return '<div class="pure-u-1 pure-u-md-1-3"><div align="center"><input type="button" id="' 
+        + element.id + '" value="' + encode(element.label) + '" class="pure-button" onclick="sendAction(\'' + element.id + '\')" /></div></div>'
     case 'password':
       return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
-        + '<input data-ui_class="password" type="password" id="_ui_element_' + element.id + '" value="' + encode(value) 
-        + '" class="pure-u-1" style="display:inline-block" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" />'
-        + '<div class="hint" onclick="ShowPwd(\'' + element.id + '\')">&#61550;</div></div>'
+        + '<div class="hinted"><input data-ui_class="password" type="password" id="_ui_element_' + element.id + '" value="' + encode(value) 
+        + '" class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" />'
+        + '<z class="hint" onclick="showPwd(\'' + element.id + '\')">&#61550;</z></div></div>'
     case 'input':
       var pattern = ""
       if (element.pattern) {
@@ -251,15 +285,15 @@ function ElementHTML(element) {
         pattern = ' pattern="' + encode(element.pattern) + '"'
       }
       return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
-        + '<input data-ui_class="input" type="text" id="_ui_element_' + element.id + '" value="' + encode(value) +'"'+ pattern
-        + ' class="pure-u-1" style="display:inline-block" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" />' 
-        + '<div class="hint" onclick="OpenSelect(\'' + element.id+ '\'); getWiFi(\'' + element.id + '\')">&#61931;</div>'
+        + '<div class="hinted"><input data-ui_class="input" type="text" id="_ui_element_' + element.id + '" value="' + encode(value) +'"'+ pattern
+        + ' class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" />' 
+        + '<z class="hint" onclick="openSelect(\'' + element.id+ '\'); getWiFi(\'' + element.id + '\')">&#61931;</z></div>'
         + '<div class="modal" id="_ui_elemmodal_' + element.id + '" hidden>' 
         + '<div class="modal-content">'
         + '<div id="_ui_elemselect_' + element.id + '"></div>'
-        + '<div class="pure-u-1 pure-u-md-1-3"><div align="center"><input style="margin:1em .5em 0;" type="button" id="_ui_button_' 
-        + element.id + '" value="Закрыть" class="pure-button pure-button-primary" onclick="CloseSelect(\'' + element.id + '\')"></div>'
-        + '</div></div></div>'
+        + '<div class="pure-u-1 pure-u-md-1-3"><div align="center"><input type="button" id="_ui_button_' 
+        + element.id + '" value="Закрыть" class="pure-button" onclick="closeSelect(\'' + element.id + '\')"></div>'
+        + '</div></div></div></div>'
     case 'checkbox':
       return '<div class="pure-u-1 pure-u-md-1-3"><label class="switch socket" for="_ui_element_' + element.id + '">'
         + '<input class="switch" data-ui_class="checkbox" type="checkbox" id="_ui_element_' + element.id + '"' + (parameters[element.id]?' checked':'') + ' onchange="sendUpdate(\'' + element.id + '\')" />'
@@ -283,10 +317,17 @@ function ElementHTML(element) {
         + '<table data-ui_class="week" data-value="' + value + '" id="_ui_element_' + element.id + '" cellpadding="5" border="0" class="week"><tbody><tr>'
       for (i=0; i<7; i++) {
         a_enabled = (value[i] == "1")
-        days = days + '<td><div class="weekday' + (a_enabled?"-selected":"") + '" id="_ui_elpart_'+i+'_'+element.id+'" onclick="ClickDay(\'' + element.id + '\', ' + i + ')">'+ daynames[i] + '</div></td>'
+        days = days + '<td><div class="weekday' + (a_enabled?"-selected":"") + '" id="_ui_elpart_'+i+'_'+element.id+'" onclick="clickDay(\'' + element.id + '\', ' + i + ')">'+ daynames[i] + '</div></td>'
       }
       days += '</tr></tbody></table></div>'
       return days
+    case 'timeset':
+      var now = new Date()
+      now.setMinutes(now.getMinutes() - now.getTimezoneOffset())
+      value = now.toISOString().slice(0, -1);
+      return '<div class="pure-u-1 pure-u-md-1-3"><label for="_ui_element_' + element.id + '">' + encode(element.label) + '</label>'
+        +'<div class="timesetter"><input id="_ui_element_'+element.id+'" data-ui_class="timeset" class="inline-input" type="datetime-local" value="'+value+'">'
+        + '<div class="send-button" onclick="sendTime(\''+element.id+'\')">-></div></div></div>'
     case 'text':
       return '<div class="pure-u-1 pure-u-md-1-3"><h2 id="_ui_element_'+ element.id +'" ' + (element.color?'style="color:'+ element.color+'" ':'')+ '>' + encode(value) + '</h2></div>'
     case 'number':
@@ -301,13 +342,13 @@ function ElementHTML(element) {
         + ' class="pure-u-1" maxlength="99" oninput="sendUpdate(\'' + element.id + '\')" /></div>'
     case 'table':
     default:
-      return '<div class="pure-u-1 pure-u-md-1-3"><table cellpadding="5" border="0" align="center"><tbody><tr><td style="padding-right: 10px; width: 50%;" align="right"><pre>' 
-        + encode(element.label)+ '</pre></td><td style="padding-left: 10px;" id="cT"><pre id="_ui_element_' 
-        + element.id + '" data-ui_class="table" ' + (element.color?'style="color:'+ element.color+'" ':'')+ '>' + encode(value) + '</pre></td></tr></tbody></table></div>'
+      return '<div class="pure-u-1 pure-u-md-1-3"><table class="texttable" cellpadding="5" border="0" align="center"><tbody><tr><td class="value-name" align="right">' 
+        + encode(element.label)+ '</td><td id="_ui_element_' 
+        + element.id + '" data-ui_class="table" ' + (element.color?'style="color:'+ element.color+'" ':'')+ '>' + encode(value) + '</td></tr></tbody></table></div>'
   }
 }
 
-function DrawPage(id) {
+function drawPage(id) {
   var idx =0, i=0
   for (const page of pages) {
     var menu_link = document.getElementById('_ui_pglink_' + page.id)  
@@ -324,25 +365,25 @@ function DrawPage(id) {
   var page_content = document.getElementById("_ui_page_content");
   var content = ''
   for (const element of pages[idx].elements) {
-    content = content + ElementHTML(element) + '\n'
+    content = content + elementHTML(element) + '\n'
   }
   page_content.innerHTML = content
   window.location.hash = id
 }
 
-function DrawNavigator(project, pages) {
+function drawNavigator(project, pages) {
   var menu = document.getElementById('_ui_menu_list');
   var list = ''
   for (const page of pages) {
     list = list + '<li id="_ui_pglink_' + page.id 
-      + '" class="pure-menu-item"><a class="pure-menu-link" onclick="DrawPage(\''+ page.id +'\')" href="#' + page.id + '">'
+      + '" class="pure-menu-item"><a class="pure-menu-link" onclick="drawPage(\''+ page.id +'\')" href="#' + page.id + '">'
       + (page.icon?'<span class="icon">'+page.icon+'</span>':'')
       + page.title+'</a></li>'
   }
   menu.innerHTML = list
 }
 
-function DrawContacts(contacts) {
+function drawContacts(contacts) {
   if (!contacts) return;
   var contact_list = '<hr><h4 class="pure-u1">Контакты</h4>'
   for (contact of contacts) {
@@ -369,21 +410,21 @@ function DrawContacts(contacts) {
   footer.innerHTML = contact_list
 }
 
-function DrawUI(ui) {
-  DrawHeader(ui.project)
+function drawUI(ui) {
+  drawHeader(ui.project)
   pages = ui.pages
-  DrawNavigator(ui.project, pages)
-  DrawContacts(ui.project.contacts)
+  drawNavigator(ui.project, pages)
+  drawContacts(ui.project.contacts)
   var anchor = getAnchor()
   if (anchor) {
-    DrawPage(anchor)
+    drawPage(anchor)
   } else {
-    DrawPage(pages[0].id)
+    drawPage(pages[0].id)
   }
 }
 
 function GetUI() {
-  ParseJsonQ("/ui", DrawUI);
+  parseJsonQ("/ui", drawUI);
 }
 
 GetUI()
@@ -398,24 +439,23 @@ function initES() {
       }
     };
         
-    source.addEventListener('keepalive', function(e) {
-      UpdateValues(JSON.parse(e.data));
-    }, false);
-
     source.addEventListener('update', function(e) {
-      UpdateValues(JSON.parse(e.data));
+      updateValues(JSON.parse(e.data));
+    }, false);
+    source.addEventListener('message', function(e) {
+      openMsg(e.data);
     }, false);
   }
 }
 
 initES();
 
-function DrawConfig(cfg) {
-  UpdateValues(cfg)
+function drawConfig(cfg) {
+  updateValues(cfg)
 }
 
 function GetCfg() {
-  ParseJsonQ("/config/get", DrawConfig);
+  parseJsonQ("/config/get", drawConfig);
 }
 
 GetCfg()
index 3bb931cf2389e77fc642c4cd3d7e370bfb9f66d1..fad4817c38812d44c9a80e4eef68b22072af8b45 100644 (file)
@@ -1,81 +1,54 @@
-/* pure-min  */
-
-.pure-button:focus,
-a:active,
-a:hover {
- outline:0
-}
-.pure-table,
-table {
- border-collapse:collapse;
- border-spacing:0
-}
-html {
- font-family:sans-serif;
- -ms-text-size-adjust:100%;
+/*!
+Pure v3.0.0
+Copyright 2013 Yahoo!
+Licensed under the BSD License.
+https://github.com/pure-css/pure/blob/master/LICENSE
+*/
+/*!
+normalize.css v | MIT License | https://necolas.github.io/normalize.css/
+Copyright (c) Nicolas Gallagher and Jonathan Neal
+*/
+/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html {
+ line-height:1.15;
  -webkit-text-size-adjust:100%
 }
 body {
  margin:0
 }
-pre {
- font-family:inherit;
- font-weight:700;
- display:contents;
- white-space:pre-wrap;
- margin:0
-}
-article,
-aside,
-details,
-figcaption,
-figure,
-footer,
-header,
-hgroup,
-main,
-menu,
-nav,
-section,
-summary {
+main {
  display:block
 }
-audio,
-canvas,
-progress,
-video {
- display:inline-block;
- vertical-align:baseline
+h1 {
+ font-size:2em;
+ margin:.67em 0
 }
-audio:not([controls]) {
- display:none;
- height:0
+hr {
+ box-sizing:content-box;
+ height:0;
+ overflow:visible
 }
-[hidden],
-template {
- display:none
+pre {
+ font-family:monospace,monospace;
+ font-size:1em
 }
 a {
  background-color:transparent
 }
 abbr[title] {
- border-bottom:1px dotted
+ border-bottom:none;
+ text-decoration:underline;
+ -webkit-text-decoration:underline dotted;
+ text-decoration:underline dotted
 }
 b,
-optgroup,
 strong {
- font-weight:700
-}
-dfn {
- font-style:italic
+ font-weight:bolder
 }
-h1 {
- font-size:2em;
- margin:.67em 0
-}
-mark {
- background:#ff0;
- color:#000
+code,
+kbd,
+samp {
+ font-family:monospace,monospace;
+ font-size:1em
 }
 small {
  font-size:80%
@@ -87,117 +60,103 @@ sup {
  position:relative;
  vertical-align:baseline
 }
-sup {
- top:-.5em
-}
 sub {
  bottom:-.25em
 }
-img {
- border:0
-}
-svg:not(:root) {
- overflow:hidden
-}
-figure {
- margin:1em 40px
-}
-hr {
- border:0;
- height:2px;
- width:80%;
- background-image:-webkit-linear-gradient(left,#292929,#1f8dd6,#292929);
- background-image:-moz-linear-gradient(left,#292929,#1f8dd6,#292929);
- background-image:-ms-linear-gradient(left,#292929,#1f8dd6,#292929);
- background-image:-o-linear-gradient(left,#292929,#1f8dd6,#292929)
-}
-textarea {
- overflow:auto
+sup {
+ top:-.5em
 }
-code,
-kbd,
-samp {
- font-family:monospace,monospace;
- font-size:1em
+img {
+ border-style:none
 }
 button,
 input,
 optgroup,
 select,
 textarea {
- color:inherit;
- font:inherit
+ font-family:inherit;
+ font-size:100%;
+ line-height:1.15;
+ margin:0
 }
-.pure-button,
+button,
 input {
- line-height:normal
-}
-button {
  overflow:visible
 }
 button,
 select {
  text-transform:none
 }
-button,
-html input[type=button],
-input[type=reset],
-input[type=submit] {
- -webkit-appearance:button;
- cursor:pointer;
- letter-spacing:.05em;
- background:transparent;
- text-transform:uppercase;
- color:#aaa;
- font-size:90%;
- border:2px solid #2497e3;
- border-radius:6px;
- box-shadow:inset 0 1px 11px 0 #1f8dd6
-}
-button[disabled],
-html input[disabled] {
- cursor:default
+[type=button],
+[type=reset],
+[type=submit],
+button {
+ -webkit-appearance:button
 }
-button::-moz-focus-inner,
-input::-moz-focus-inner {
- border:0;
+[type=button]::-moz-focus-inner,
+[type=reset]::-moz-focus-inner,
+[type=submit]::-moz-focus-inner,
+button::-moz-focus-inner {
+ border-style:none;
  padding:0
 }
-input[type=checkbox],
-input[type=radio] {
+[type=button]:-moz-focusring,
+[type=reset]:-moz-focusring,
+[type=submit]:-moz-focusring,
+button:-moz-focusring {
+ outline:1px dotted ButtonText
+}
+fieldset {
+ padding:.35em .75em .625em
+}
+legend {
+ box-sizing:border-box;
+ color:inherit;
+ display:table;
+ max-width:100%;
+ padding:0;
+ white-space:normal
+}
+progress {
+ vertical-align:baseline
+}
+textarea {
+ overflow:auto
+}
+[type=checkbox],
+[type=radio] {
  box-sizing:border-box;
- vertical-align:middle;
  padding:0
 }
-input[type=number]::-webkit-inner-spin-button,
-input[type=number]::-webkit-outer-spin-button {
+[type=number]::-webkit-inner-spin-button,
+[type=number]::-webkit-outer-spin-button {
  height:auto
 }
-input[type=search] {
+[type=search] {
  -webkit-appearance:textfield;
- box-sizing:content-box
-}
-.pure-button,
-.pure-form input:not([type]),
-.pure-menu {
- box-sizing:border-box
+ outline-offset:-2px
 }
-input[type=search]::-webkit-search-cancel-button,
-input[type=search]::-webkit-search-decoration {
+[type=search]::-webkit-search-decoration {
  -webkit-appearance:none
 }
-fieldset {
- border:1px solid silver;
- margin:0 2px;
- padding:.35em .625em .75em
+::-webkit-file-upload-button {
+ -webkit-appearance:button;
+ font:inherit
 }
-legend,
-td,
-th {
- padding:0
+details {
+ display:block
 }
-legend {
- border:0
+summary {
+ display:list-item
+}
+template {
+ display:none
+}
+[hidden] {
+ display:none
+}
+html {
+ font-family:sans-serif
 }
 .hidden,
 [hidden] {
@@ -209,30 +168,14 @@ legend {
  display:block
 }
 .pure-g {
- letter-spacing:-.31em;
- text-rendering:optimizespeed;
- font-family:FreeSans,Arimo,"Droid Sans",Helvetica,Arial,sans-serif;
- display:-webkit-box;
- display:-webkit-flex;
- display:-ms-flexbox;
  display:flex;
- -webkit-flex-flow:row wrap;
- -ms-flex-flow:row wrap;
  flex-flow:row wrap;
- -webkit-align-content:flex-start;
- -ms-flex-line-pack:start;
  align-content:flex-start
 }
-@media all and (-ms-high-contrast:none),(-ms-high-contrast:active) {
- table .pure-g {
-  display:block
- }
-}
-.opera-only :-o-prefocus,
-.pure-g {
- word-spacing:-.43em
+.pure-u {
+ display:inline-block;
+ vertical-align:top
 }
-.pure-u,
 .pure-u-1,
 .pure-u-1-1,
 .pure-u-1-12,
@@ -279,16 +222,11 @@ legend {
 .pure-u-7-8,
 .pure-u-8-24,
 .pure-u-9-24 {
+ display:inline-block;
  letter-spacing:normal;
  word-spacing:normal;
- vertical-align:text-top;
- text-rendering:auto;
- display:inline-block;
- zoom:1;
- margin:20px 0 0
-}
-.pure-g [class*=pure-u] {
- font-family:sans-serif
+ vertical-align:top;
+ text-rendering:auto
 }
 .pure-u-1-24 {
  width:4.1667%
@@ -394,16 +332,15 @@ legend {
 }
 .pure-button {
  display:inline-block;
zoom:1;
line-height:normal;
  white-space:nowrap;
  vertical-align:middle;
  text-align:center;
  cursor:pointer;
  -webkit-user-drag:none;
  -webkit-user-select:none;
- -moz-user-select:none;
- -ms-user-select:none;
- user-select:none
+ user-select:none;
+ box-sizing:border-box
 }
 .pure-button::-moz-focus-inner {
  padding:0;
@@ -415,32 +352,36 @@ legend {
 }
 .opera-only :-o-prefocus,
 .pure-button-group {
- word-spacing:-.43em
+ word-spacing:-0.43em
+}
+.pure-button-group .pure-button {
+ letter-spacing:normal;
+ word-spacing:normal;
+ vertical-align:top;
+ text-rendering:auto
 }
 .pure-button {
  font-family:inherit;
  font-size:100%;
  padding:.5em 1em;
- color:#444;
  color:rgba(0,0,0,.8);
- border:1px solid #999;
- border:transparent;
- background-color:#E6E6E6;
+ border:none transparent;
+ background-color:#e6e6e6;
  text-decoration:none;
  border-radius:2px
 }
 .pure-button-hover,
+.pure-button:focus,
 .pure-button:hover {
- border:2px solid #61c6ff;
- box-shadow:inset 0 1px 15px 0 #1f8dd6;
- filter:alpha(opacity=90);
- background-image:-webkit-linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1));
  background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))
 }
+.pure-button:focus {
+ outline:0
+}
 .pure-button-active,
 .pure-button:active {
  box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;
- border-color:#000\9
+ border-color:#000
 }
 .pure-button-disabled,
 .pure-button-disabled:active,
@@ -449,7 +390,6 @@ legend {
 .pure-button[disabled] {
  border:none;
  background-image:none;
- filter:alpha(opacity=40);
  opacity:.4;
  cursor:not-allowed;
  box-shadow:none;
@@ -462,17 +402,12 @@ legend {
 .pure-button-selected,
 a.pure-button-primary,
 a.pure-button-selected {
- background-color :#0078e7;
+ background-color:#0078e7;
  color:#fff
 }
 .pure-button-group .pure-button {
- letter-spacing:normal;
- word-spacing:normal;
- vertical-align:top;
- text-rendering:auto;
  margin:0;
  border-radius:0;
- border-right:1px solid #111;
  border-right:1px solid rgba(0,0,0,.2)
 }
 .pure-button-group .pure-button:first-child {
@@ -484,28 +419,27 @@ a.pure-button-selected {
  border-bottom-right-radius:2px;
  border-right:none
 }
-.pure-form input[type=password],
-.pure-form input[type=email],
-.pure-form input[type=url],
+.pure-form input[type=color],
 .pure-form input[type=date],
-.pure-form input[type=month],
-.pure-form input[type=time],
-.pure-form input[type=datetime],
 .pure-form input[type=datetime-local],
-.pure-form input[type=week],
-.pure-form input[type=tel],
-.pure-form input[type=color],
+.pure-form input[type=datetime],
+.pure-form input[type=email],
+.pure-form input[type=month],
 .pure-form input[type=number],
+.pure-form input[type=password],
 .pure-form input[type=search],
+.pure-form input[type=tel],
 .pure-form input[type=text],
+.pure-form input[type=time],
+.pure-form input[type=url],
+.pure-form input[type=week],
 .pure-form select,
 .pure-form textarea {
- background:#292828;
  padding:.5em .6em;
  display:inline-block;
  border:1px solid #ccc;
- box-shadow:inset 0 1px 10px #1f8dd6;
- border-radius:8px;
+ box-shadow:inset 0 1px 3px #ddd;
+ border-radius:4px;
  vertical-align:middle;
  box-sizing:border-box
 }
@@ -514,61 +448,71 @@ a.pure-button-selected {
  display:inline-block;
  border:1px solid #ccc;
  box-shadow:inset 0 1px 3px #ddd;
- border-radius:4px
+ border-radius:4px;
+ box-sizing:border-box
 }
 .pure-form input[type=color] {
- padding:.1em .4em
+ padding:.2em .5em
 }
-.pure-form input:not([type]):focus,
-.pure-form input[type=password]:focus,
-.pure-form input[type=email]:focus,
-.pure-form input[type=url]:focus,
+.pure-form input[type=color]:focus,
 .pure-form input[type=date]:focus,
-.pure-form input[type=month]:focus,
-.pure-form input[type=time]:focus,
-.pure-form input[type=datetime]:focus,
 .pure-form input[type=datetime-local]:focus,
-.pure-form input[type=week]:focus,
-.pure-form input[type=tel]:focus,
-.pure-form input[type=color]:focus,
+.pure-form input[type=datetime]:focus,
+.pure-form input[type=email]:focus,
+.pure-form input[type=month]:focus,
 .pure-form input[type=number]:focus,
+.pure-form input[type=password]:focus,
 .pure-form input[type=search]:focus,
+.pure-form input[type=tel]:focus,
 .pure-form input[type=text]:focus,
+.pure-form input[type=time]:focus,
+.pure-form input[type=url]:focus,
+.pure-form input[type=week]:focus,
 .pure-form select:focus,
 .pure-form textarea:focus {
  outline:0;
- border-color:#129FEA
+ border-color:#129fea
+}
+.pure-form input:not([type]):focus {
+ outline:0;
+ border-color:#129fea
 }
-.pure-form input[type=file]:focus,
 .pure-form input[type=checkbox]:focus,
+.pure-form input[type=file]:focus,
 .pure-form input[type=radio]:focus {
- outline:#129FEA auto 1px
+ outline:thin solid #129FEA;
+ outline:1px auto #129FEA
 }
 .pure-form .pure-checkbox,
 .pure-form .pure-radio {
+ margin:.5em 0;
  display:block
 }
-.pure-form input:not([type])[disabled],
-.pure-form input[type=password][disabled],
-.pure-form input[type=email][disabled],
-.pure-form input[type=url][disabled],
+.pure-form input[type=color][disabled],
 .pure-form input[type=date][disabled],
-.pure-form input[type=month][disabled],
-.pure-form input[type=time][disabled],
-.pure-form input[type=datetime][disabled],
 .pure-form input[type=datetime-local][disabled],
-.pure-form input[type=week][disabled],
-.pure-form input[type=tel][disabled],
-.pure-form input[type=color][disabled],
+.pure-form input[type=datetime][disabled],
+.pure-form input[type=email][disabled],
+.pure-form input[type=month][disabled],
 .pure-form input[type=number][disabled],
+.pure-form input[type=password][disabled],
 .pure-form input[type=search][disabled],
+.pure-form input[type=tel][disabled],
 .pure-form input[type=text][disabled],
+.pure-form input[type=time][disabled],
+.pure-form input[type=url][disabled],
+.pure-form input[type=week][disabled],
 .pure-form select[disabled],
 .pure-form textarea[disabled] {
  cursor:not-allowed;
  background-color:#eaeded;
  color:#cad2d3
 }
+.pure-form input:not([type])[disabled] {
+ cursor:not-allowed;
+ background-color:#eaeded;
+ color:#cad2d3
+}
 .pure-form input[readonly],
 .pure-form select[readonly],
 .pure-form textarea[readonly] {
@@ -582,15 +526,15 @@ a.pure-button-selected {
  color:#b94a48;
  border-color:#e9322d
 }
-.pure-form input[type=file]:focus:invalid:focus,
 .pure-form input[type=checkbox]:focus:invalid:focus,
+.pure-form input[type=file]:focus:invalid:focus,
 .pure-form input[type=radio]:focus:invalid:focus {
  outline-color:#e9322d
 }
 .pure-form select {
  height:2.25em;
  border:1px solid #ccc;
- background:#292929
+ background-color:#fff
 }
 .pure-form select[multiple] {
  height:auto
@@ -611,29 +555,31 @@ a.pure-button-selected {
  color:#333;
  border-bottom:1px solid #e5e5e5
 }
-.pure-form-stacked input:not([type]),
-.pure-form-stacked input[type=password],
-.pure-form-stacked input[type=email],
-.pure-form-stacked input[type=url],
+.pure-form-stacked input[type=color],
 .pure-form-stacked input[type=date],
-.pure-form-stacked input[type=month],
-.pure-form-stacked input[type=time],
-.pure-form-stacked input[type=datetime],
 .pure-form-stacked input[type=datetime-local],
-.pure-form-stacked input[type=week],
-.pure-form-stacked input[type=tel],
-.pure-form-stacked input[type=color],
+.pure-form-stacked input[type=datetime],
+.pure-form-stacked input[type=email],
 .pure-form-stacked input[type=file],
+.pure-form-stacked input[type=month],
 .pure-form-stacked input[type=number],
+.pure-form-stacked input[type=password],
 .pure-form-stacked input[type=search],
+.pure-form-stacked input[type=tel],
 .pure-form-stacked input[type=text],
+.pure-form-stacked input[type=time],
+.pure-form-stacked input[type=url],
+.pure-form-stacked input[type=week],
 .pure-form-stacked label,
 .pure-form-stacked select,
 .pure-form-stacked textarea {
  display:block;
- margin:0 0 5px
+ margin:.25em 0
+}
+.pure-form-stacked input:not([type]) {
+ display:block;
+ margin:.25em 0
 }
-.pure-form-aligned .pure-help-inline,
 .pure-form-aligned input,
 .pure-form-aligned select,
 .pure-form-aligned textarea,
@@ -717,7 +663,6 @@ a.pure-button-selected {
 .pure-form .pure-input-1-4 {
  width:25%
 }
-.pure-form .pure-help-inline,
 .pure-form-message-inline {
  display:inline-block;
  padding-left:.3em;
@@ -730,44 +675,44 @@ a.pure-button-selected {
  color:#666;
  font-size:.875em
 }
-@media only screen and (max-width : 480px) {
+@media only screen and (max-width :480px) {
  .pure-form button[type=submit] {
   margin:.7em 0 0
  }
  .pure-form input:not([type]),
- .pure-form input[type=password],
- .pure-form input[type=email],
- .pure-form input[type=url],
+ .pure-form input[type=color],
  .pure-form input[type=date],
- .pure-form input[type=month],
- .pure-form input[type=time],
- .pure-form input[type=datetime],
  .pure-form input[type=datetime-local],
- .pure-form input[type=week],
- .pure-form input[type=tel],
- .pure-form input[type=color],
+ .pure-form input[type=datetime],
+ .pure-form input[type=email],
+ .pure-form input[type=month],
  .pure-form input[type=number],
+ .pure-form input[type=password],
  .pure-form input[type=search],
+ .pure-form input[type=tel],
  .pure-form input[type=text],
+ .pure-form input[type=time],
+ .pure-form input[type=url],
+ .pure-form input[type=week],
  .pure-form label {
   margin-bottom:.3em;
   display:block
  }
  .pure-group input:not([type]),
- .pure-group input[type=password],
- .pure-group input[type=email],
- .pure-group input[type=url],
+ .pure-group input[type=color],
  .pure-group input[type=date],
- .pure-group input[type=month],
- .pure-group input[type=time],
- .pure-group input[type=datetime],
  .pure-group input[type=datetime-local],
- .pure-group input[type=week],
- .pure-group input[type=tel],
- .pure-group input[type=color],
+ .pure-group input[type=datetime],
+ .pure-group input[type=email],
+ .pure-group input[type=month],
  .pure-group input[type=number],
+ .pure-group input[type=password],
  .pure-group input[type=search],
- .pure-group input[type=text] {
+ .pure-group input[type=tel],
+ .pure-group input[type=text],
+ .pure-group input[type=time],
+ .pure-group input[type=url],
+ .pure-group input[type=week] {
   margin-bottom:0
  }
  .pure-form-aligned .pure-control-group label {
@@ -777,9 +722,8 @@ a.pure-button-selected {
   width:100%
  }
  .pure-form-aligned .pure-controls {
-  margin:1.5em 0 0
+  margin:1.5em 0 0 0
  }
- .pure-form .pure-help-inline,
  .pure-form-message,
  .pure-form-message-inline {
   display:block;
@@ -787,6 +731,9 @@ a.pure-button-selected {
   padding:.2em 0 .8em
  }
 }
+.pure-menu {
+ box-sizing:border-box
+}
 .pure-menu-fixed {
  position:fixed;
  left:0;
@@ -824,7 +771,6 @@ a.pure-button-selected {
 .pure-menu-horizontal .pure-menu-item,
 .pure-menu-horizontal .pure-menu-separator {
  display:inline-block;
- zoom:1;
  vertical-align:middle
 }
 .pure-menu-item .pure-menu-item {
@@ -871,13 +817,8 @@ a.pure-button-selected {
  white-space:nowrap;
  overflow-y:hidden;
  overflow-x:auto;
- -ms-overflow-style:none;
- -webkit-overflow-scrolling:touch;
  padding:.5em 0
 }
-.pure-menu-horizontal.pure-menu-scrollable::-webkit-scrollbar {
- display:none
-}
 .pure-menu-horizontal .pure-menu-children .pure-menu-separator,
 .pure-menu-separator {
  background-color:#ccc;
@@ -903,7 +844,6 @@ a.pure-button-selected {
 .pure-menu-children {
  background-color:#fff
 }
-.pure-menu-disabled,
 .pure-menu-heading,
 .pure-menu-link {
  padding:.5em 1em
@@ -912,18 +852,21 @@ a.pure-button-selected {
  opacity:.5
 }
 .pure-menu-disabled .pure-menu-link:hover {
- background-color:transparent
+ background-color:transparent;
+ cursor:default
 }
 .pure-menu-active>.pure-menu-link,
 .pure-menu-link:focus,
 .pure-menu-link:hover {
  background-color:#eee
 }
-.pure-menu-selected .pure-menu-link,
-.pure-menu-selected .pure-menu-link:visited {
+.pure-menu-selected>.pure-menu-link,
+.pure-menu-selected>.pure-menu-link:visited {
  color:#000
 }
 .pure-table {
+ border-collapse:collapse;
+ border-spacing:0;
  empty-cells:show;
  border:1px solid #cbcbcb
 }
@@ -942,10 +885,6 @@ a.pure-button-selected {
  overflow:visible;
  padding:.5em 1em
 }
-.pure-table td:first-child,
-.pure-table th:first-child {
- border-left-width:0
-}
 .pure-table thead {
  background-color:#e0e0e0;
  color:#000;
@@ -955,7 +894,9 @@ a.pure-button-selected {
 .pure-table td {
  background-color:transparent
 }
-.pure-table-odd td,
+.pure-table-odd td {
+ background-color:#f2f2f2
+}
 .pure-table-striped tr:nth-child(2n-1) td {
  background-color:#f2f2f2
 }
@@ -967,21 +908,26 @@ a.pure-button-selected {
 }
 .pure-table-horizontal td,
 .pure-table-horizontal th {
- border-width:0 0 1px;
+ border-width:0 0 1px 0;
  border-bottom:1px solid #cbcbcb
 }
 .pure-table-horizontal tbody>tr:last-child>td {
  border-bottom-width:0
 }
-:root {
- --slide:250px
-}
 
 /* custom styles */
 
+:root {
+ --slide: 25em;
+ --blue: #129fea;
+ --darkgray: #242424;
+ --lightgray: #cad2d3;
+ --darkergray: #191919;
+}
+
 body {
  color:#aaa;
- background:#242424;
+ background: var(--darkgray);
 }
 
 .pure-img-responsive {
@@ -1013,19 +959,20 @@ body {
 .content {
  margin:0 auto;
  padding:0 2em;
- max-width:800px;
- margin-bottom:50px;
+ max-width: 100em;
+ margin-bottom: 5em;
  line-height:1.6em
 }
 .header {
  margin:0;
- color:#333;
+ color: var(--darkgray);
  text-align:center
 }
 .header h1 {
- color:dodgerblue;
+ color: var(--blue);
  margin:.4em 0;
- font-size:2.4em;
+ padding-top: 0.5em;
+ font-size: 2em;
  font-weight: bold;
  font-variant-caps: small-caps
 }
@@ -1036,24 +983,25 @@ body {
  margin-top:0
 }
 .content-subhead {
- margin:50px 0 20px 0;
+ margin: 5em 0 2em 0;
  font-weight:300;
- color:#888
+ color: var(--lightgray)
 }
 #menu {
  margin-left:calc(var(--slide)* -1);
  width:var(--slide);
+ height: 100vh;
  position:fixed;
  top:0;
  left:0;
  bottom:0;
  z-index:1000;
- background:#181818;
+ background: var(--darkergray);
  overflow-y:auto;
  -webkit-overflow-scrolling:touch
 }
 #menu a {
- color:#888;
+ color: var(--lightgray);
  border:none;
  padding:.6em 0.3em .6em 0.6em
 }
@@ -1064,24 +1012,24 @@ body {
 }
 #menu .pure-menu ul,
 #menu .pure-menu .menu-item-divided {
- border-top:1px solid #333
+ border-top: 0.1em solid var(--darkgray)
 }
 #menu .pure-menu li a:hover,
 #menu .pure-menu li a:focus {
- background:#333;
- color:#aaa
+ background: var(--darkgray);
+ color: var(--lightgray);
 }
 #menu .pure-menu-selected,
 #menu .pure-menu-heading {
- background:dodgerblue
+ background: var(--blue);
 }
 #menu .pure-menu-selected a {
- color:#fff
+ color: white
 }
 #menu .pure-menu-heading {
  letter-spacing:.15em;
  text-transform:uppercase;
- color:#fff;
+ color: white;
  margin:0;
  text-align:center;
  padding:.6em 0.3em .6em 0.3em
@@ -1091,9 +1039,9 @@ body {
  display:block;
  top:0;
  left:0;
- background:#000;
+ background: black;
  background:rgba(0,0,0,0.42);
- font-size:10px;
+ font-size: 1em;
  z-index:10;
  width:2em;
  height:auto;
@@ -1101,7 +1049,7 @@ body {
 }
 .menu-link:hover,
 .menu-link:focus {
- background:#000
+ background: black;
 }
 .menu-link span {
  position:relative;
@@ -1110,7 +1058,7 @@ body {
 .menu-link span,
 .menu-link span:before,
 .menu-link span:after {
- background-color:dodgerblue;
+ background-color: var(--blue);
  width:100%;
  height:.2em
 }
@@ -1145,101 +1093,112 @@ body {
   left:var(--slide)
  }
 }
-@media(max-width:48em) {
- #layout.active {
-  position:relative;
-  left:var(--slide)
- }
-}
+
+/* Sliders */
+
 input[type=range] {
  -webkit-appearance:none;
  margin:0 0 0 0;
  width:100%;
- background:#292929
+ background: transparent;
 }
+
 input[type=range]:focus {
  outline:none
 }
+
 input[type=range]::-webkit-slider-runnable-track {
  width:100%;
- height:4px;
+ height: 0.4em;
  cursor:pointer;
  animate:0.2s;
- box-shadow:0 0 0px #000000;
- background:dodgerblue;
- border-radius:3px;
- border:0 solid #000000
+ box-shadow: none
+ background: var(--blue);
+ border-radius: 0.3em;
+ border: none
 }
+
 input[type=range]::-webkit-slider-thumb {
- box-shadow:0 0 0px #000000;
- border:1px solid rgb(148,148,148);
- height:20px;
- width:20px;
- border-radius:40px;
- background:#F0F0F0;
+ box-shadow: none;
+ border: 0.1em solid var(--lightgray)
+ height: 2em;
+ width: 2em;
+ border-radius: 2em;
+ background: var(--lightgray)
  cursor:pointer;
  -webkit-appearance:none;
- margin-top:-8.5px
+ margin-top: 0.85em
 }
+
 input[type=range]:focus::-webkit-slider-runnable-track {
- background:dodgerblue
+ background: var(--blue)
 }
+
 input[type=range]::-moz-range-track {
- width:100%;
- height:4px;
- cursor:pointer;
+ width: 100%;
+ height: 0.4em;
+ cursor: pointer;
  animate:0.2s;
- box-shadow:0 0 0px #000000;
- background:dodgerblue;
- border-radius:3px;
- border:0 solid #000000
+ box-shadow: none;
+ background: var(--blue);
+ border-radius: 0.3em;
+ border: none
 }
+
 input[type=range]::-moz-range-thumb {
- box-shadow:0 0 0px #000000;
- border:1px solid dodgerblue;
- height:20px;
- width:20px;
- border-radius:30px;
- background:lightskyblue;
+ box-shadow: none;
+ border: 0.1em solid var(--blue);
+ height: 2em;
+ width: 2em;
+ border-radius: 2em;
+ background: var(--lightgray);
  cursor:pointer
 }
+
 input[type=range]::-ms-track {
  width:100%;
- height:4px;
+ height: 0.4em;
  cursor:pointer;
  animate:0.2s;
  background:transparent;
  border-color:transparent;
  color:transparent
 }
+
 input[type=range]::-ms-fill-lower {
- background:dodgerblue;
- border:0 solid #000000;
- border-radius:6px;
- box-shadow:0 0 0px #000000
+ background: var(--lightgray);
+ border: none;
+ border-radius: 0.6em;
+ box-shadow: none
 }
+
 input[type=range]::-ms-fill-upper {
- background:dodgerblue;
- border:0 solid #000000;
- border-radius:6px;
- box-shadow:0 0 0px #000000
+ background: var(--lightgray);
+ border: none;
+ border-radius: 0.6em;
+ box-shadow: none
 }
+
 input[type=range]::-ms-thumb {
- margin-top:1px;
- box-shadow:0 0 0px #000000;
- border:1px solid dodgerblue;
- height:20px;
- width:20px;
- border-radius:40px;
- background:lightskyblue;
+ margin-top:0.1em;
+ box-shadow: nonel
+ border: 0.1em solid var(--blue);
+ height: 2em;
+ width: 2em;
+ border-radius: 2em;
+ background: var(--lightgray);
  cursor:pointer
 }
+
 input[type=range]:focus::-ms-fill-lower {
- background:dodgerblue
+ background: var(--lightgray);
 }
+
 input[type=range]:focus::-ms-fill-upper {
- background:dodgerblue
+ background: var(--lightgray);
 }
+
+
 .hide {
  display:none
 }
@@ -1257,12 +1216,69 @@ footer a {
  text-decoration: none
 }
 
-/* notification button/message */
+/* notification button */
 
 .notification {
+  position: fixed;
+  right: 0.5em;
+  top: 0.5em;
+  background-color: var(--darkgray);
+  z-index:200;
+  word-wrap: anywhere;
   float: right;
  }
 
+/* notification message */
+
+.message {
+  position: fixed;
+  right: 1em;
+  bottom: 0.5em;
+  z-index:200;
+  float: right;
+  color:#fff;
+}
+
+.message-text {
+  background-color: var(--darkgray);
+  border:0.2em solid var(--blue);
+  box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
+  word-wrap: anywhere;
+  font-weight: bolder;
+  font-size: 120%;
+  padding: 1em;
+  border-radius: 0.5em;
+  position: relative;
+}
+
+.close-button {
+    content: 'X';
+    position: absolute;
+    right: -1em;
+    top: -1em;
+    width: 1.2em;
+    padding: 0.1em 0.1em 0.1em 0.1em;
+    text-decoration: none;
+    text-shadow: none;
+    text-align: center;
+    font-weight: bold;
+    background: var(--darkgray);
+    color: white;
+    border: 0.2em solid var(--blue);
+    border-radius: 1em;
+    box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
+}
+
+.fadeout {
+    animation: fadeOut 5s ease;
+    opacity:0;
+}
+
+@keyframes fadeOut {
+  0% {opacity:1;}
+  100% {opacity:0;}
+}
+
 /* modal dialog */ 
  
 .modal {
@@ -1280,15 +1296,15 @@ footer a {
   cursor:pointer;
   letter-spacing:.05em;
   text-transform:uppercase;
-  color:#aaa;
+  color:var(--lightgray);
   font-size:90%;
-  border:2px solid dodgerblue;
+  border:0.2em solid var(--blue);
   border-radius:6px;
-  box-shadow:inset 0 1px 11px 0 steelblue
-  padding: 1rem;
+  box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
+  padding: 1em;
   width: max-content;
   min-width: 30%;
-  background-color: #333;
+  background-color: var(--darkgray);
   margin: 10% auto;
   padding: 1rem
 }
@@ -1296,23 +1312,33 @@ footer a {
   font-weight: bold;
   font-size: 120%;
   text-align: center;
-  border-bottom: lightgray solid 1px;
+  border-bottom: var(--blue) solid 0.1em;
 }
 
 /* overlapping hint button */
 
+.hinted {
+  position: relative;
+}
+
 .hint {
   cursor: pointer;
   width: 1rem;
   background: transparent;
-  color: #aaa;
-  font-size: 90%;
+  color: var(--darkgray);
+  padding: .5em .6em;
   border: none;
   box-shadow: none;
-  display: inline-block;
-  vertical-align: text-bottom;
-  margin-left: -2rem;
-  z-index:1;
+  position: absolute;
+  top: 0em;
+  right: 0em;
+  line-height: 1.15em;
+  font-size: 100%;
+}
+
+/* Spacing */
+fieldset .pure-u-1 {
+  margin-top: 1.5em
 }
 
 /* checkbox as a switch */
@@ -1323,7 +1349,7 @@ input[type=checkbox].switch {
 label.switch.socket {
     position: relative;
     width: auto;
-    text-indent: 2.5em;
+    padding-left: 4em;
 }
 span.switch.slider {
   display: inline-block;
@@ -1331,56 +1357,90 @@ span.switch.slider {
 label.switch.socket span.switch.slider:before {
     content: "";
     color: gray;
-    width: 4em;
-    height: 2em;
-    border-radius: 5em;
+    width: 3em;
+    height: 1.4em;
+    border-radius: 1em;
     position: absolute;
     left: 0;
-    border:2px solid dodgerblue;
-    box-shadow:inset 0 1px 11px 0 steelblue
+    top: 0;
+    border:0.2em solid var(--blue);
+    box-shadow:inset 0 0.1em 1.1em 0 var(--blue)
 }
 label.switch span.switch.slider:after {
     content: "";
-    width: 1.5rem;
-    height: 1.5rem;
-    background: dodgerblue;
-    border-radius: 5.5ex;
+    width: 1rem;
+    height: 1rem;
+    background: var(--blue);
+    border-radius: 1em;
     position: absolute;
     left: 0.4rem;
     top: 0.4rem;
     transition: 0.2s;
 }
 label.switch.socket input[type=checkbox]:checked + span.slider:before {
-    color: White;
-    background: darkslategray;
-    content: "";
-    background: D;
+    background: var(--lightgray);
 }
+
 label.switch.socket input[type=checkbox]:checked + span.slider:after {
     background: white;
-    left: 2.4em;
+    left: 1.9em;
+}
+
+/* Time setter */
+
+.timesetter {
+  display: inline;
+}
+
+.inline-input.inline-input.inline-input {
+  display: inline-block;
+  width: calc(100% - 5em)
+}
+
+.send-button {
+ display:inline-block;
+ font-family:inherit;
+ font-size:100%;
+ line-height: 1.15em;
+ padding: .5em 1em;
+ color:rgba(0,0,0,.8);
+ border:none transparent;
+ background-color:#e6e6e6;
+ text-decoration:none;
+ border-radius:2px;
+ vertical-align: middle;
+ margin-left: 1em;
+ width: 2em;
+ text-align: center;
 }
 
 /* weekdays */
 
 .week {
     width: 100%;
+    border-collapse: collapse;
+    border-spacing: 0;
+}
+.week td {
+  padding: 0
 }
 .weekday, .weekday-selected {
     font-size: min(4vw, 100%rem);
     text-align: center;
-    margin: 0.5vw;
-    padding: 0.4em;
+    margin: 0.2vw;
+    padding: 0.2em;
     border-radius: 5em;
-    border:2px solid dodgerblue;
-    box-shadow:inset 0 1px 11px 0 steelblue;
+    border:0.2em solid var(--blue);
+    box-shadow:inset 0 0.1em 1.1em 0 var(--blue);
     color: gray
 }
 .weekday-selected {
-    background: darkslategray;
-    color: white
+    background: var(--lightgray);
+    color: black
 }
 
+/* Hide arrows */
+
 input[type=number] {
   appearance: textfield;
 }
@@ -1391,6 +1451,23 @@ input[type=number] {
   min-height: 100vh; 
 }
 
+/* Text table */
+
+.texttable {
+  border-collapse: collapse;
+  border-spacing: 0;
+  width: 100%;
+}
+
+.texttable td {
+  padding: 0
+}
+
+.value-name.value-name {
+  width: 50%;
+  padding-right: 1em;
+}
+
 /* fonts */
 
 @font-face {
index c8dbaa2bbef2330a8a8ceb06034962c2873de9fe..015363216b8b550b34ab3f5edff8b54180f8042b 100644 (file)
@@ -30,9 +30,9 @@ void beep(int tone, int length, int beep, int silent) {
   beepToneRequested = tone;
   beepLengthRequested = length;
   if (!silent && beep>length) {
-    beepMs = length; 
-  } else { 
-    beepMs = beep; 
+    beepMs = length;
+  } else {
+    beepMs = beep;
   }
   silentMs = silent;
 }
@@ -69,6 +69,7 @@ void buttonHandler(Button2& btn) {
         beep(800,800,200,100);
         click_counter++;
         if (click_counter>2) {
+          reportMessage(F("Сбрасываю настройки"));
           tone(1200,1000);
           Serial.println(F("Три тройных нажатия - сбрасываю конфигурацию"));
           reset();
@@ -85,7 +86,7 @@ void buttonLongClickHandler(Button2& btn) {
     return;
   }
   if (millis() - first_click_millis < 6000) {
-    if (isApEnabled) {    
+    if (isApEnabled) {
       setupNet(false);
     } else {
       setupNet(true);
@@ -98,10 +99,12 @@ void buttonLongClickHandler(Button2& btn) {
   Serial.println(F("Alarm = ")); Serial.println(enable_alarm);
   if (enable_alarm) {
     message(F("Будильник включен"));
-    reportChange(F("enable_alarm"));    
+    reportChange(F("enable_alarm"));
+    reportMessage(F("Будильник включен"));
   } else {
     message(F("Будильник выключен"));
     reportChange(F("enable_alarm"));
+    reportMessage(F("Будильник выключен"));
   }
 
 }
@@ -119,7 +122,7 @@ void setupHardware() {
     RTC.begin();
     time_t rtc = RTC.now().unixtime();
     timeval tv = { rtc, 0 };
-    settimeofday(&tv, nullptr);  
+    settimeofday(&tv, nullptr);
     isRTCEnabled = true;
     Serial.println(F("Время установлено по встроенным часам"));
   }
@@ -140,7 +143,7 @@ void setupHardware() {
     isBuzzerEnabled = true;
     buzzer_pin = cfg.getIntValue(F("buzzer_pin"));
     pinMode(buzzer_pin,OUTPUT);
-    buzzer_passive = cfg.getBoolValue(F("buzzer_passive"));    
+    buzzer_passive = cfg.getBoolValue(F("buzzer_passive"));
   } else {
     isBuzzerEnabled = true;
   }
@@ -152,13 +155,13 @@ void doBeep(int note, int duration) {
 
 void tickHardware() {
   if (isButtonEnabled) {
-    btn.loop();    
+    btn.loop();
   }
   if (isBuzzerEnabled) {
     static unsigned long stopBeepMillis = 0;
     if (stopBeepMillis && millis() >= stopBeepMillis) {
       beepStateRequested = false;
-      stopBeepMillis = 0;      
+      stopBeepMillis = 0;
     }
     if (beepStateRequested != beepState && beepStateRequested) {
       stopBeepMillis = millis() + beepLengthRequested;
index e6f81c96246555b15362e69c11fbf66e6051cd17..4f361659753e1d1a971abfb5c3023a4dc2f6651c 100644 (file)
--- a/time.cpp
+++ b/time.cpp
@@ -25,6 +25,7 @@ void timeIsSet(bool ntp) {
   if (ntp) {
     Serial.println(F("Время синхронизировано"));
     message(F("Время синхронизировано"));
+    reportMessage(F("Время синхронизировано"));
     if (isRTCEnabled) {
       RTC.adjust(DateTime(now));
     }
diff --git a/ui.yml b/ui.yml
index fecd2bcb8c33235ba13922f6cbad5a310615fa9f..0bfef12531568393c7aa30f0aa960d7bf566b625 100644 (file)
--- a/ui.yml
+++ b/ui.yml
@@ -471,6 +471,9 @@ pages:
       - type: hr
       - type: text
         value: Синхронизация времени
+      - id: _timeset
+        label: Установить время вручную
+        type: timeset
       - id: ntp_server
         type: input
         label: NTP-сервер
index 999f35c28f6f158f27832d9dcf17b852c62743c6..dc11706e6ae2ae23c6e874639d2c3056d761c510 100644 (file)
@@ -89,6 +89,7 @@ void requestCB(void* optParm, AsyncHTTPRequest* request, int readyState) {
         Serial.print(F("Ошибка разбора ответа: "));
         Serial.println(error.c_str());
         Serial.println(weather_json);
+        reportMessage(F("Ошибка обновления погоды"));
         return;
       }
       weather.clear();
@@ -97,6 +98,7 @@ void requestCB(void* optParm, AsyncHTTPRequest* request, int readyState) {
       delete current_weather;
       processTemplates(weatherData,weather_template,weather,255);
       sendWeather();
+      reportMessage(F("Погода обновлена"));
       scroll(weatherData, !isNight());
     }
   }
diff --git a/web.cpp b/web.cpp
index 7b7f1bce0f9ea77f4362f8e9b6ef9cae8170abc9..7d017b5721771165d94993165ebde5511a7dd2a8 100644 (file)
--- a/web.cpp
+++ b/web.cpp
@@ -40,18 +40,24 @@ void reportChange(const __FlashStringHelper* name) {
   }
 }
 
+void reportMessage(const __FlashStringHelper* msg) {
+  char buf[256];
+  strcpy_P(buf, (PGM_P)msg);
+  events.send(buf,"message",millis());
+}
+
 void sendInitial(AsyncEventSourceClient *client) {
   String mac = WiFi.macAddress();
   char buf[256];
   sprintf(buf,"{\"_mac\":\"%s\",\"_weather\":\"%s\"}", mac.c_str(), weatherData);
-  client->send(buf,"keepalive",millis());
+  client->send(buf,"update",millis());
   mac = String();
 }
 
 void sendWeather() {
   char buf[256];
   sprintf(buf,"{\"_weather\":\"%s\"}",weatherData);
-  events.send(buf,"keepalive",millis());
+  events.send(buf,"update",millis());
 }
 
 void sendKeepalive() {
@@ -86,7 +92,7 @@ void sendKeepalive() {
   } else {
     sprintf(buf,"{\"_uptime\":\"%d м %d с\", \"_date\":\"%02d.%2d.%04d\", \"_time\":\"%02d:%02d\",\"_heap\":\"%d б\", \"_rssi\":\"%d\",  \"_last_sync\":\"%s\", \"_changed\":%s}", mins, uptime, dd, mm, yy, hh, mi, heap, rssi, sync, changed?"true":"false");
   }
-  events.send(buf,"keepalive",millis());
+  events.send(buf,"update",millis());
 }
 
 void apply(const char* name) {
@@ -170,7 +176,26 @@ void setupWeb() {
           millisScheduled = millis();
           actionScheduled = "save";
         }
+      } else if (strcmp(action,"time") == 0) {
+        if(request->hasParam("timestamp")) {
+          unsigned long timestamp = atoi(request->getParam("timestamp")->value().c_str());
+          if (timestamp) {
+            timeval tv = { timestamp, 0 };
+            settimeofday(&tv, nullptr);  
+            if (isRTCEnabled) {
+              Serial.println(F("Время установлено вручную"));
+              RTC.adjust(DateTime(timestamp));
+            }
+          }
+          request->send(200,"application/json", "{\"result\":\"OK\",\"message\":\"Устанавливаю время\"}");
+        } else {
+          request->send(500, "text/plain", "{\"result\":\"FAILED\",\"message\":\"Not all parameters set\"}");
+        }
+      } else {
+        request->send(500, "text/plain", "{\"result\":\"FAILED\",\"message\":\"Unsupported action\"}");
       }
+    } else {
+      request->send(500, "text/plain", "{\"result\":\"FAILED\",\"message\":\"Not all parameters set\"}");
     }
   });
 
@@ -348,6 +373,7 @@ void tickWeb() {
 
   if (!pendingWiFi && !pendingAuth && cfg.getTimestamp() && cfg.getTimestamp() < now - CFG_AUTOSAVE) {
     saveConfig();
+    reportMessage(F("Настройки сохранены"));
     Serial.println(F("Настройки сохранены"));
   }
 }