ctucx.git: mqtt-webui

webui for mqtt, can be used to control/display data in mqtt-topics

commit 38d86cc30e8039e3c163cc61859bcad75835ffa0
parent b3ee2db4447dbfdd1b73486959e01d038754b5d5
Author: Leah (ctucx) <git@ctu.cx>
Date: Fri, 9 Dec 2022 21:55:37 +0100

some refactoring
7 files changed, 389 insertions(+), 496 deletions(-)
M
config.json
|
116
++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
M
package.json
|
1
-
M
src/webui.css
|
96
+++++++++++++++++++++++++++++--------------------------------------------------
M
src/webui.js
|
632
++++++++++++++++++++++++++++++++-----------------------------------------------
M
www/index.html
|
2
+-
M
www/sw.js
|
2
+-
M
yarn.lock
|
36
++++++++++++------------------------
diff --git a/config.json b/config.json
@@ -31,7 +31,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_hallway/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -47,7 +47,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_hallway/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -63,7 +63,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_hallway/set"
               },
               "transform": {
-                "get": "return value.color_temp",
+                "get": "return message.color_temp",
                 "set": "return JSON.stringify({color_temp: Number(input)})"
               },
               "type": "slider"

@@ -81,7 +81,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_kitchen/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -97,7 +97,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_kitchen/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -113,7 +113,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_kitchen/set"
               },
               "transform": {
-                "get": "return value.color_temp",
+                "get": "return message.color_temp",
                 "set": "return JSON.stringify({color_temp: Number(input)})"
               },
               "type": "slider"

@@ -131,7 +131,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_bathroom/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -147,7 +147,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_bathroom/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -163,13 +163,32 @@
                 "set": "zigbee2mqtt/ikea_lamp_bathroom/set"
               },
               "transform": {
-                "get": "return value.color_temp",
+                "get": "return message.color_temp",
                 "set": "return JSON.stringify({color_temp: Number(input)})"
               },
               "type": "slider"
             }
           ],
           "title": "Bathroom: Ceiling Light"
+        },
+        {
+          "items": [
+            {
+              "icon": "icons/temperature.png",
+              "title": "Fridge",
+              "topic": "lacrosse2mqtt/33",
+              "transform": "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C'",
+              "type": "text"
+            },
+            {
+              "icon": "icons/temperature.png",
+              "title": "Bathroom",
+              "topic": "lacrosse2mqtt/5",
+              "transform": "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C - ' + message.humidity + ' %'",
+              "type": "text"
+            }
+          ],
+          "title": "Temperature-Sensors"
         }
       ],
       "title": "Smart-Home"

@@ -188,7 +207,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_l/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -204,7 +223,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_l/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -220,7 +239,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_l/set"
               },
               "transform": {
-                "get": "return value.color_temp",
+                "get": "return message.color_temp",
                 "set": "return JSON.stringify({color_temp: Number(input)})"
               },
               "type": "slider"

@@ -238,7 +257,7 @@
                 "set": "zigbee2mqtt/led_stripe_desk/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -254,7 +273,7 @@
                 "set": "zigbee2mqtt/led_stripe_desk/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -272,7 +291,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_l_rgb/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -288,7 +307,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_l_rgb/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -304,7 +323,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_l_rgb/set"
               },
               "transform": {
-                "get": "return value.color_temp",
+                "get": "return message.color_temp",
                 "set": "return JSON.stringify({color_temp: Number(input)})"
               },
               "type": "slider"

@@ -331,7 +350,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_l_rgb/set"
               },
               "transform": {
-                "get": "return value.color.x + ','+value.color.y",
+                "get": "return message.color.x + ','+message.color.y",
                 "set": "return JSON.stringify({color: {x: input.split(',')[0], y: input.split(',')[1]}})"
               },
               "type": "select"

@@ -345,39 +364,51 @@
               "icon": "icons/power.png",
               "title": "Voltage",
               "topic": "sdm2mqtt/leah",
-              "transform": "return Math.round((value.voltage + Number.EPSILON) * 100) / 100 + ' V'",
+              "transform": "return Math.round((message.voltage + Number.EPSILON) * 100) / 100 + ' V'",
               "type": "text"
             },
             {
               "icon": "icons/power.png",
               "title": "Power",
               "topic": "sdm2mqtt/leah",
-              "transform": "return Math.round((value.power + Number.EPSILON) * 100) / 100 + ' W'",
+              "transform": "return Math.round((message.power + Number.EPSILON) * 100) / 100 + ' W'",
               "type": "text"
             },
             {
               "icon": "icons/power.png",
               "title": "Frequency",
               "topic": "sdm2mqtt/leah",
-              "transform": "return value.frequency + ' Hz'",
+              "transform": "return message.frequency + ' Hz'",
               "type": "text"
             },
             {
               "icon": "icons/power.png",
               "title": "cos φ",
               "topic": "sdm2mqtt/leah",
-              "transform": "return Math.round((value.cosphi + Number.EPSILON) * 100) / 100",
+              "transform": "return Math.round((message.cosphi + Number.EPSILON) * 100) / 100",
               "type": "text"
             },
             {
               "icon": "icons/power.png",
               "title": "Total Import",
               "topic": "sdm2mqtt/leah",
-              "transform": "return Math.round((value.import + Number.EPSILON) * 100) / 100 + ' kWh'",
+              "transform": "return Math.round((message.import + Number.EPSILON) * 100) / 100 + ' kWh'",
               "type": "text"
             }
           ],
           "title": "Power-Meter"
+        },
+        {
+          "items": [
+            {
+              "icon": "icons/temperature.png",
+              "title": "Temperature",
+              "topic": "lacrosse2mqtt/3a",
+              "transform": "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C'",
+              "type": "text"
+            }
+          ],
+          "title": "Temperature-Sensors"
         }
       ],
       "title": "Leah's room"

@@ -396,7 +427,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_i/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -412,7 +443,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_i/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -428,7 +459,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_i/set"
               },
               "transform": {
-                "get": "return value.color_temp",
+                "get": "return message.color_temp",
                 "set": "return JSON.stringify({color_temp: Number(input)})"
               },
               "type": "slider"

@@ -446,7 +477,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_i_rgb/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -462,7 +493,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_i_rgb/set"
               },
               "transform": {
-                "get": "return value.brightness",
+                "get": "return message.brightness",
                 "set": "return JSON.stringify({brightness: Number(input)})"
               },
               "type": "slider"

@@ -478,7 +509,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_i_rgb/set"
               },
               "transform": {
-                "get": "return value.color_temp",
+                "get": "return message.color_temp",
                 "set": "return JSON.stringify({color_temp: Number(input)})"
               },
               "type": "slider"

@@ -505,7 +536,7 @@
                 "set": "zigbee2mqtt/ikea_lamp_i_rgb/set"
               },
               "transform": {
-                "get": "return value.color.x + ','+value.color.y",
+                "get": "return message.color.x + ','+message.color.y",
                 "set": "return JSON.stringify({color: {x: input.split(',')[0], y: input.split(',')[1]}})"
               },
               "type": "select"

@@ -523,7 +554,7 @@
                 "set": "zigbee2mqtt/ikea_control_outlet_i_desk_l/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"

@@ -536,13 +567,32 @@
                 "set": "zigbee2mqtt/ikea_control_outlet_i_desk_r/set"
               },
               "transform": {
-                "get": "return (value.state == 'ON') ? true : false",
+                "get": "return (message.state == 'ON') ? true : false",
                 "set": "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})"
               },
               "type": "switch"
             }
           ],
-          "title": "Power-Meter"
+          "title": "Switches"
+        },
+        {
+          "items": [
+            {
+              "icon": "icons/temperature.png",
+              "title": "Temperature",
+              "topic": "lacrosse2mqtt/3c",
+              "transform": "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C'",
+              "type": "text"
+            },
+            {
+              "icon": "icons/thermostat.png",
+              "title": "Humidity",
+              "topic": "lacrosse2mqtt/3c",
+              "transform": "return message.humidity + ' %'",
+              "type": "text"
+            }
+          ],
+          "title": "Temperature-Sensors"
         }
       ],
       "title": "Isa's room"
diff --git a/package.json b/package.json
@@ -17,7 +17,6 @@
 	"dependencies": {
 		"webpack": "5.11.1",
 		"lit-html": "^2.4.0",
-		"shortid": "^2.2.16",
 		"mqtt": "^4.3.7",
 		"buffer": "^6.0.3",
 		"process": "^0.11.10",
diff --git a/src/webui.css b/src/webui.css
@@ -4,7 +4,7 @@
 
 body {
 	margin: 0;
-	font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+	font-family: system-ui;
 	font-size: 1rem;
 	font-weight: 400;
 	line-height: 1.5;

@@ -28,6 +28,9 @@ img {
 }
 
 nav {
+	margin-left: auto;
+	margin-right: auto;
+	max-width: 50em;
 	background-color: #f8f9fa;
 	position: fixed;
 	top: 0;

@@ -65,22 +68,30 @@ button .icon {
 }
 
 button .icon svg {
+	color: #212529;
 	height: 31px;
 	width: 31px;
 }
 
-.row {
+.icon {
+	height: 32px;
+	width: 32px;
+	margin: 4px;
+}
+
+
+.page {
  	margin-left: auto;
 	margin-right: auto;
 	max-width: 50em;
-	margin-bottom: 10px;
 }
 
-.list-group {
+section {
 	border-radius: 0.25rem;
+	margin-bottom: 10px;
 }
 
-.list-group-item {
+section > div {
 	background-color: #fff;
 	padding: 0.25rem;
 	border: 1px solid rgba(0, 0, 0, 0.125);

@@ -89,72 +100,50 @@ button .icon svg {
 	align-self: center !important;
 }
 
-.list-group-item:first-child {
+section > div:first-child {
+	background-color: #f8f9fa;
+	padding-left: 1em;
 	border-top-left-radius: inherit;
 	border-top-right-radius: inherit;
 }
 
-.list-group-item:last-child {
+section > div:last-child {
 	border-bottom-right-radius: inherit;
 	border-bottom-left-radius: inherit;
 	border-bottom: 1px solid rgba(0, 0, 0, 0.125);
 }
 
-.list-group-item * {
+section > div * {
 	align-self: center !important;
 }
 
-.list-group-item .left {
+section > div .left {
 	margin-left: auto;
 	font-size: 100%;
 	margin-right: 1em;
 }
 
-.list-group-item .title {
+section > div .title{
 	padding: 0.5rem;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
 }
 
 @media screen and (max-width: 700px) {
-	.row {
-		margin-bottom: 0;
-	}
-
-	.list-group {
+	section {
 		border-radius: 0;
-	}
-	.list-group-item {
-		border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+		margin-bottom: 0px;
 	}
 
-	.list-group-item {
-		 border-width: 0 0 1px;
+	section > div {
+		border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+		border-width: 0 0 1px;
 	}
 }
 
-.icon {
-	height: 32px;
-	width: 32px;
-	margin: 4px;
-}
 
-.no-icon {
-	display: none;
-}
-
-.title {
-	white-space: nowrap;
-	overflow: hidden;
-	text-overflow: ellipsis;
-}
-
-.seperator {
-	background-color: #f8f9fa;
-	padding-left: 1em!important;
-}
-
-.page {
-	width: 100%;
-}
+/* Switch Styling*/
 
 .toggle {
 	font-family: sans-serif;

@@ -188,8 +177,6 @@ button .icon svg {
 /* Slider Styling*/
 .slider {
 	width: 100%;
-}
-.slider {
 	-webkit-appearance: none;
 	height: 10px;
 	border-radius: 5px;   

@@ -205,7 +192,7 @@ button .icon svg {
 	border-radius: 50%; 
 	background: #6c757d;
 	cursor: pointer;
-	box-shadow:var(--c,0) 0 0 #ccc; /*https://stackoverflow.com/a/51571070/1997890*/
+	box-shadow:var(--c,0) 0 0 #ccc;
 }
 
 .slider::-moz-range-thumb {

@@ -214,9 +201,10 @@ button .icon svg {
 	border-radius: 50%;
 	background: #6c757d;
 	cursor: pointer;
-	box-shadow:var(--c,0) 0 0 #ccc; /*https://stackoverflow.com/a/51571070/1997890*/
+	box-shadow:var(--c,0) 0 0 #ccc;
 }
 
+
 .loader {
 	border: 4px solid #ccc; /* Pick even numbers here */
 	border-top: 4px solid #6c757d; /* Pick even numbers here */

@@ -233,20 +221,6 @@ button .icon svg {
 	100% { transform: rotate(360deg); }
 }
 
-.sh-input-number {
-	width: 4em!important;
-	background-color: lightgray;
-	text-align: center;
-	border: none;
-}
-
-.sh-input-number:focus {
-	border-color: inherit;
-	box-shadow: inherit;
-	background-color: inherit;
-}
-
-/* Animation during processing */
 .working {
 	background-color: lightgray;
 	animation-name: color;
diff --git a/src/webui.js b/src/webui.js
@@ -4,32 +4,34 @@ import "./webui.css";
 
 import { html, render } from 'lit-html';
 import { map }          from 'lit-html/directives/map.js';
-import {ifDefined}      from 'lit-html/directives/if-defined.js';
+import { ifDefined }    from 'lit-html/directives/if-defined.js';
+import { unsafeSVG }    from 'lit-html/directives/unsafe-svg.js';
 
-import shortid               from 'shortid';
-import { connect }           from "mqtt";
+import { connect }      from "mqtt";
+
+var client;
+
+const connectedSvg    = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wifi"><path d="M5 12.55a11 11 0 0 1 14.08 0"></path><path d="M1.42 9a16 16 0 0 1 21.16 0"></path><path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path><line x1="12" y1="20" x2="12.01" y2="20"></line></svg>';
+const disconnectedSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wifi-off"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path><path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path><path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path><line x1="12" y1="20" x2="12.01" y2="20"></line></svg>';
+const backButtonSvg   = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>';
 
 const goToPage = (pageHash) => {
-	if (typeof pageHash === 'object') pageHash = location.hash || '#mainpage';
+	if (typeof pageHash !== 'string') pageHash = location.hash || '#mainpage';
 
 	console.log('goToPage', pageHash);
 	document.querySelectorAll(".page").forEach(element => {
-		if ('#'+element.id !== pageHash) {
-			element.style.display = 'none';
-		} else {
-			element.style.display = '';
-		}
+		element.style.display = ('#'+element.id !== pageHash) ? 'none' : '';
 	});
 }
 
 const transformMessage = (input, meta) => {
-	if (!meta.transform) return;
-	if (typeof meta.transform !== 'object') return;
+	if (typeof meta.transform !== 'object') return input;
 
-	if ('set' in meta.transform) {
+	if (typeof meta.transform.set === 'string') {
 		try {
 			return new Function('input', meta.transform.set)(input);
-		} catch {
+		} catch (exception){
+			console.log(exception);
 			return;
 		}
 	}

@@ -37,19 +39,86 @@ const transformMessage = (input, meta) => {
 	return input;
 }
 
+const onMessage = (topic, message) => {
+	message = JSON.parse(message.toString());
+
+	document.querySelectorAll('[data-mqtt-topic="' + topic + '"]').forEach((element) => {
+		let value = null;
+
+		if (typeof element.dataset.transform !== 'undefined') {
+			try {
+				value = new Function('topic', 'message', element.dataset.transform)(topic, message);
+			} catch (exception){
+				console.log(exception);
+				return;
+			}
+		}
+
+		if (value === null) value = message;
+
+		switch (element.dataset.type) {
+			case 'text':
+				if (typeof value === 'object') return;
+
+				element.textContent = value;
+				break;
+
+			case 'switch':
+				if (typeof value !== 'boolean') return;
+
+				element.checked = value;
+				break;
+
+			case 'slider':
+				if (isNaN(value)) return;
+
+				element.value                 = value;
+				element.dataset.lastMqttValue = value;
+				element.style.setProperty('--c', 0);
+				break;
+
+			case 'select':
+				if (typeof value === 'object') return;
+
+				element.value                 = value;
+				element.dataset.lastMqttValue = value;
+				document.getElementById(element.id + '_loader').classList.remove('loader');
+				break;
+
+			// untested
+			case 'button':
+				if (typeof value === 'object') return;
+
+				if (element.dataset.mqttValue === value) {
+					element.classList.add('active');
+				} else {
+					element.classList.remove('active');
+				}
+
+				break;
+
+			// untested
+			case 'number':
+				if (isNaN(value)) return;
+
+				element.value = value;
+				element.classList.remove('working');
+				break;
+		}
+	});
+};
+
 const getConfig = async () => {
 	const response = await fetch('/config.json');
 
 	if (!response.ok) {
-		if (!localStorage.getItem('config')) {
-			return {
-				pages: [{
-					pageid: "mainpage",
-					pagetitle: "No Config!",
-					sections: []
-				}]
-			};
-		}
+		if (!localStorage.getItem('config')) return {
+			pages: [{
+				pageid: "mainpage",
+				pagetitle: "No Config!",
+				sections: []
+			}]
+		};
 
 		return JSON.parse(localStorage.getItem('config'));
 	}

@@ -61,439 +130,252 @@ const getConfig = async () => {
 };
 
 const renderItem = (itemData) => {
-	itemData.id = itemData.type + '_' + shortid.generate();
-
 	if (typeof itemData.topic === 'string') {
 		const temporary = itemData.topic;
-		itemData.topic = {};
+		itemData.topic     = {};
 		itemData.topic.get = temporary;
 		itemData.topic.set = null;
 	}
 
+	let transform = null;
+
+	if (typeof itemData.transform !== 'undefined') {
+		if (typeof itemData.transform     === 'string') transform = itemData.transform;
+		if (typeof itemData.transform.get === 'string') transform = itemData.transform.get;
+	};
+
 	if (itemData.type !== 'html') {
 		let element = html``;
 
-		if (itemData.type === 'slider') {
-			itemData.sliderMinValue  = (itemData.sliderMinValue  !== undefined) ? itemData.sliderMinValue  : 0;
-			itemData.sliderMaxValue  = (itemData.sliderMaxValue  !== undefined) ? itemData.sliderMaxValue  : 1;
-			itemData.sliderStepValue = (itemData.sliderStepValue !== undefined) ? itemData.sliderStepValue : 'any';
+		if (itemData.type == 'switch' || itemData.type == 'select') {
+			itemData.id = Math.random().toString(36).slice(2);
 		}
 
 		switch (itemData.type) {
 			case 'text':
-				if (itemData.topic === undefined) break;
-				element = html`
-					<div class="left" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">undef.</div>
-				`;
+				if (typeof itemData.topic === 'undefined') break;
+
+				element = html`<span data-type="${itemData.type}" data-mqtt-topic="${itemData.topic.get}" data-transform="${ifDefined(transform)}">undef.</span>`;
 				break;
 
 			case 'switch':
-				element = html`
-					<div class="left">
-						<div class="toggle">
-							<input type="checkbox" id="${itemData.id}" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">
-							<label for="${itemData.id}">&nbsp;</label>
-						</div>
-					</div>
-				`;
-				break;
+				const switchChangeHandler = (event) => {
+					if (!client.connected)           return false;
+					if (itemData.topic.set === null) return false;
 
-			// untested
-			case 'button':
+					client.publish(itemData.topic.set, transformMessage(event.target.checked, itemData), itemData.mqtt);
+					return false;
+				}
+			
 				element = html`
-					<div class="left">
-						<div class="btn-group" role="group">
-							${map(itemData.buttons, (button) => html`<button id="${itemData.id}" type="button" class="btn btn-secondary" data-mqtt-value="${button.value}" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">${button.label}</button>`)}
-						</div>
+					<div class="toggle">
+						<input id="${itemData.id}" type="checkbox" @change="${switchChangeHandler}" data-type="${itemData.type}" data-mqtt-topic="${itemData.topic.get}" data-transform="${ifDefined(transform)}">
+						<label for="${itemData.id}">&nbsp;</label>
 					</div>
 				`;
 				break;
 
 			case 'slider':
+				const sliderChangeHandler = (event) => {
+					if (!client.connected)           return false;
+					if (itemData.topic.set === null) return false;
+
+					client.publish(itemData.topic.set, transformMessage(event.target.value, itemData), itemData.mqtt);
+				}
+
+				const sliderInputHandler = (event) => {
+					if (!client.connected)           return false;
+					if (itemData.topic.set === null) return false;
+
+					const element = event.target;
+					const value   = (element.dataset.lastMqttValue - element.value) / (element.max - element.min) * (element.getBoundingClientRect().width - 20);
+
+					event.target.style.setProperty('--c', value + 'px');	
+				}
+
+				itemData.sliderMinValue  ??= 0;
+				itemData.sliderMaxValue  ??= 1;
+				itemData.sliderStepValue ??= 'any';
+			
+				element = html`<input class="slider" type="range" @change="${sliderChangeHandler}" @input="${sliderInputHandler}" min="${itemData.sliderMinValue}" max="${itemData.sliderMaxValue}" step="${itemData.sliderStepValue}" data-type="${itemData.type}" data-mqtt-topic="${itemData.topic.get}" data-transform="${ifDefined(transform)}">`;
+				break;
+
+			case 'select':
+				const selectChangeHandler = (event) => {
+					if (!client.connected)           return false;
+					if (itemData.topic.set === null) return false
+					const element = event.target;
+
+					client.publish(itemData.topic.set, transformMessage(element.value, itemData), itemData.mqtt);
+
+					// Reset to last known state
+					element.value = element.dataset.lastMqttValue
+					document.getElementById(element.id + '_loader').classList.add('loader');
+				}
+
 				element = html`
-					<div class="left">
-						<input id="${itemData.id}" type="range" class="slider" min="${itemData.sliderMinValue}" max="${itemData.sliderMaxValue}" step="${itemData.sliderStepValue}" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">
-					</div>
+					<div id="${itemData.id}_loader"></div>
+					<select id="${itemData.id}" @change="${selectChangeHandler}" data-type="${itemData.type}" data-mqtt-topic="${itemData.topic.get}" data-transform="${ifDefined(transform)}">
+						${map(itemData.selectOptions, (option) => html`<option value="${option.value}">${option.label}</option>`)}
+					</select>
 				`;
 				break;
 
 			// untested
-			case 'number':
-				element = html`
-					<div id="${itemData.id}" class="left" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">
-						<div class="input-group">
-							<div class="input-group-prepend">
-								<button class="btn btn-secondary sh-number-left" type="button">-</button>
-							</div>
-							<input type="text" inputmode="decimal" class="sh-input-number form-control">
-							<div class="input-group-append">
-								<button class="btn btn-secondary sh-number-right" type="button">+</button>
-							</div>
-						</div>
-					</div>
-				`;
+			case 'button':
+				const buttonClickHandler = (event) => {
+					if (!client.connected)           return false;
+					if (itemData.topic.set === null) return false;
+
+					client.publish(itemData.topic.set, transformMessage(event.target.dataset.mqttValue, meta), itemData.mqtt);
+					return false;
+				}
+
+				const buttonElement = (button) => html`<button class="button" type="button" @click="${buttonClickHandler}" data-type="${itemData.type}" data-mqtt-value="${button.value}" data-mqtt-topic="${itemData.topic.get}" data-transform="${ifDefined(transform)}">${button.label}</button>`;
+
+				element = html`<div class="btn-group" role="group">${map(itemData.buttons, buttonElement)}</div>`;
 				break;
 
-			case 'select':
+			// untested
+			case 'number':
+
+				const numberHandlder = (event) => {
+					if (!client.connected)           return false;
+					if (itemData.topic.set === null) return false;
+
+					const input            = event.target.parentNode.querySelector('input');
+					let   inputValue       = Number(input.value.replace(',', '.'));
+
+					if (element.target.textContent === '-') inputValue = inputValue - itemData.decrement ?? 1;
+					if (element.target.textContent === '+') inputValue = inputValue - itemData.increment ?? 1;
+
+					const transformedValue = transformMessage(inputValue, meta);
+
+					client.publish(topic, transformedValue, itemData.mqtt);
+					input.classList.add('working');
+				}
+
 				element = html`
-					<div class="left">
-						<div id="${itemData.id}_loader"></div>
-						<select id="${itemData.id}" class="" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">
-							${map(itemData.selectOptions, (option) => html`<option value="${option.value}">${option.label}</option>`)}
-						</select>
+					<div class="number-group">
+						<button type="button" click="${numberHandlder}">-</button>
+						<input type="text" inputmode="decimal" @change="${numberHandlder}" data-type="${itemData.type}" data-mqtt-topic="${itemData.topic.get}">
+						<button type="button" @click="${numberHandlder}">+</button>
 					</div>
 				`;
 				break;
+
 		}
 
+		const clickHandler = (itemData.link) ? (event) => { window.location = itemData.link; } : null;
+		const cursorStyle  = (itemData.link) ? 'cursor: pointer;' : null;
+
 		return html`
-			<div class="list-group-item" data-page="${ifDefined(itemData.page)}" data-link="${ifDefined(itemData.link)}">
-				<div><img src="${ifDefined(itemData.icon)}" class="icon"></div>
+			<div @click="${ifDefined(clickHandler)}" style="${ifDefined(cursorStyle)}">
+				<img class="icon" src="${ifDefined(itemData.icon)}">
 				<div class="title">${itemData.title}</div>
-				${element}
+				<div class="left">${element}</div>
 			</div>
 		`;
 	} else {
-		return html`<div id="${itemData.id}" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">${itemData.html}</div>`;
+		return html`<div data-mqtt-topic="${itemData.topic.get}" data-transform="${ifDefined(transform)}">${itemData.html}</div>`;
 	}
 }
 
-const renderSection = (sectionData) => {
-	const title = (sectionData.title) ? html`<div class="list-group-item seperator">${sectionData.title}</div>` : html``
+const renderSection = (sectionConfig) => {
+	const title = (sectionConfig.title) ? html`<div>${sectionConfig.title}</div>` : html``
 	return html`
-		<div class="row list-group">
+		<section>
 			${title}
-			${map(sectionData.items, (itemData) => renderItem(itemData))}			
-		</div>
+			${map(sectionConfig.items, (itemData) => renderItem(itemData))}			
+		</section>
 	`;
 }
 
-const renderPage  = (pageData) => {
-	const reloadHandler = () => window.location.reload(true);
-	const backButton = 
-			(pageData.id === 'mainpage') ?
-			html `<span class="icon"></span>` :
-			html `<button class="icon" onClick="window.history.back();"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></button>`;
-
+const renderPage = (pageConfig) => {
+	const backButton = (pageConfig.id !== 'mainpage') ? html `<button class="icon" onClick="window.history.back();">${unsafeSVG(backButtonSvg)}</button>` : html `<span class="icon"></span>`;
 
 	return  html`
-		<div id="${pageData.id}" class="page">
-			<nav class="row">
+		<div id="${pageConfig.id}" class="page">
+			<nav>
 				${backButton}
-				<a href="#"><img src="${ifDefined(pageData.icon)}" width="30" height="30" class="d-inline-block align-top" alt=""> ${pageData.title}</a>
-				<button class="icon connectionStatus" @click="${reloadHandler}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wifi-off"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path><path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path><path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path><line x1="12" y1="20" x2="12.01" y2="20"></line></svg></button>
+				<a href="#"><img src="${ifDefined(pageConfig.icon)}" width="30" height="30"> ${pageConfig.title}</a>
+				<button class="icon connectionStatus" @click="${() => window.location.reload(true)}">${unsafeSVG(disconnectedSvg)}</button>
 			</nav>
-			${map(pageData.sections, (sectionData) => renderSection(sectionData))}
+			${map(pageConfig.sections, (sectionConfig) => renderSection(sectionConfig))}
 		</div>
 	`;
 };
 
-const renderPages = (config) => html`${map(config.pages, (pageData) => renderPage(pageData))}`;
-
-
-const sw = navigator.serviceWorker;
-export let registration;
-if (sw) {
-	sw.register('sw.js', {
-		scope: './'
-	}).then(function(reg) {
-		console.log('serviceWorker registration succeeded.');
-		registration = reg;
-	});
-}
-
+const renderPages = (config) => html`${map(config.pages, (pageConfig) => renderPage(pageConfig))}`;
 
 window.addEventListener('hashchange', goToPage);
 window.addEventListener('DOMContentLoaded', async (event) => {
-	const instanceId = localStorage.getItem('instanceId') || shortid.generate();
-	localStorage.setItem('instanceId', instanceId);
+	const instanceId = localStorage.getItem('instanceId') ?? Math.random().toString(36).slice(2);
+	const username   = localStorage.getItem('username')   ?? prompt("userame:");
+	const password   = localStorage.getItem('password')   ?? prompt("password:");
+	const config     = await getConfig();
+	const topics     = new Set();
 
-	// Get config
-	const data   = await getConfig();
-	const topics = new Set();
-
-	// Get all topics
-	for (const [i, page] of Object.entries(data.pages)) {
-		for (const [j, section] of Object.entries(page.sections)) {
-			for (const [k, item] of Object.entries(section.items)) {
-				if (item.topic !== undefined) {
-					if (typeof item.topic === 'string')      topics.add(item.topic);
-					if (item.topic.get !== undefined) topics.add(item.topic.get);
-				}
-			}
-		}
-	}
-
-	// create UI
-	render(renderPages(data), document.body);
-
-	document.querySelectorAll('[data-link]').forEach((element) => {
-		element.style.cursor = 'pointer';
-		element.addEventListener('click', () => {
-			window.location = element.dataset.link;
-		});
-	});
-
-	document.querySelectorAll('[data-page]').forEach((element) => {
-		element.style.cursor = 'pointer';
-		element.addEventListener('click', () => {
-			window.location.hash = '#'+element.dataset.page;
+	localStorage.setItem('instanceId', instanceId);
+	localStorage.setItem('username',   username);
+	localStorage.setItem('password',   password);
+
+	if (navigator.serviceWorker) {
+		navigator.serviceWorker.register('sw.js', {
+			scope: './'
+		}).then(function(reg) {
+			console.log('serviceWorker registration succeeded.');
 		});
-	});
+	}
 
-	goToPage(window.location.hash || '#mainpage');
+	config.pages.forEach((page) => {
+		page.sections.forEach((section) => {
+			section.items.forEach((item) => {
+				if (typeof item.topic     === 'undefined') return;
+				if (typeof item.topic     === 'string') topics.add(item.topic);
+				if (typeof item.topic.get === 'string') topics.add(item.topic.get);
+			})
+		})
+	})
 
+	// create UI
+	render(renderPages(config), document.body);
+	goToPage();
 
 	// MQTT
-	if (!localStorage.getItem('username')) localStorage.setItem('username', prompt("userame:"))
-	if (!localStorage.getItem('password')) localStorage.setItem('password', prompt("password:"))
-
 	const mqttUrl = 'ws' + (location.protocol === 'https:' ? 's' : '') + '://' + location.hostname + ((location.port === '') ? '' : ':' + location.port) + '/mqtt';
-	console.log('MQTT conencting to', mqttUrl);
 
-	const client = connect(mqttUrl, {
+	console.log('MQTT conencting to', mqttUrl);
+	client = connect(mqttUrl, {
 		clientId: 'webui_' + instanceId,
-		logger: console,
-		username: localStorage.getItem('username'),
-		password: localStorage.getItem('password')
+		username: username,
+		password: password
 	});
 
 	client.on('connect', () => {
-	    console.log("Connected to server!")
-
-		// Handle online/offline Button
-		document.querySelectorAll('.connectionStatus').forEach((element) => {
-			element.classList.remove('btn-outline-secondary');
-			element.classList.add('btn-outline-success');
-			element.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wifi"><path d="M5 12.55a11 11 0 0 1 14.08 0"></path><path d="M1.42 9a16 16 0 0 1 21.16 0"></path><path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path><line x1="12" y1="20" x2="12.01" y2="20"></line></svg>';
-		});
+	    console.log('Connected to mqtt-server!')
+		document.querySelectorAll('.connectionStatus').forEach((element) => element.innerHTML = connectedSvg);
 	});
 
-	client.on('offline', () => {
-	    console.log("Disconnected to server!")
+	client.on('reconnect', () => console.log('Trying reconnect to mqtt-server!'));
 
-		// Handle online/offline Button
-		document.querySelectorAll('.connectionStatus').forEach((element) => {
-			element.classList.remove('btn-outline-success');
-			element.classList.add('btn-outline-secondary');
-			element.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wifi-off"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path><path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path><path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path><line x1="12" y1="20" x2="12.01" y2="20"></line></svg>';
-		});
-	});
-
-	client.on('error', () => {
-		localStorage.setItem('username', prompt("username:"))
-		localStorage.setItem('password', prompt("password:"))
-		location.reload()
-	});
-
-	client.subscribe([...topics])
-	client.on('message', (topic, message) => {
-		message = JSON.parse(message.toString());
-
-		const value = (typeof message === 'object' && message !== null && 'val' in message) ? message.val : message;
-
-		document.querySelectorAll('[data-mqtt-topic="' + topic + '"]').forEach((element) => {
-			const meta = JSON.parse(element.dataset.meta);
-
-			let valueTransformed;
-			if ('transform' in meta) {
-				if (typeof meta.transform === 'string') {
-					try {
-						valueTransformed = new Function('topic', 'message', 'value', meta.transform)(topic, message, value);
-					} catch {
-						return;
-					}
-				}
-
-				if (typeof meta.transform === 'object') {
-					if ('get' in meta.transform) {
-						try {
-							valueTransformed = new Function('topic', 'message', 'value', meta.transform.get)(topic, message, value);
-						} catch {
-							return;
-						}
-					}
-				}
-			}
-
-			if (valueTransformed === null) {
-				return;
-			}
-
-			const usedValue = (valueTransformed === undefined) ? value : valueTransformed;
-
-			switch (meta.type) {
-				case 'text':
-					if (!(
-						typeof usedValue !== 'object' ||
-							usedValue instanceof String ||
-							usedValue instanceof Number ||
-							usedValue instanceof Boolean)
-					) {
-						return;
-					}
-
-					element.textContent = usedValue;
-					break;
-
-				case 'switch':
-					if (!(typeof usedValue === 'boolean' || usedValue instanceof Boolean)) {
-						return;
-					}
-
-					document.getElementById(meta.id).checked = usedValue;
-					break;
-
-				case 'button':
-					if (!(
-						typeof usedValue !== 'object' ||
-							usedValue instanceof String ||
-							usedValue instanceof Number ||
-							usedValue instanceof Boolean)
-					) {
-						return;
-					}
-
-					if (element.dataset.mqttValue === usedValue) {
-						element.classList.add('active');
-					} else {
-						element.classList.remove('active');
-					}
-
-					break;
-
-				case 'slider':
-					if (Number.isNaN(usedValue)) {
-						return;
-					}
-
-					document.getElementById(meta.id).value                 = usedValue;
-					document.getElementById(meta.id).dataset.lastMqttValue = usedValue;
-					document.getElementById(meta.id).style.setProperty('--c', 0);
-					break;
-
-				case 'select':
-					if (!(
-						typeof usedValue !== 'object' ||
-							usedValue instanceof String ||
-							usedValue instanceof Number ||
-							usedValue instanceof Boolean)
-					) {
-						return;
-					}
-
-					document.getElementById(meta.id).value                 = usedValue;
-					document.getElementById(meta.id).dataset.lastMqttValue = usedValue;
-					document.getElementById(meta.id + '_loader').classList.remove('loader');
-					break;
-
-				case 'number':
-					if (Number.isNaN(usedValue)) {
-						return;
-					}
-
-					element.querySelector('input').value = usedValue;
-					element.querySelector('input').classList.remove('working');
-					break;
-
-				default:
-					// Do nothing
-			}
-		});
-	});
-
-	// Assign user-action events
-	document.querySelectorAll('[id^=switch]').forEach((element) => {
-		element.addEventListener('click', () => {
-			const meta  = JSON.parse(element.dataset.meta);
-			const topic = meta.topic.set;
-
-			if (topic === null) return false;
-
-			client.publish(topic, transformMessage(element.checked, meta), meta.options?.mqtt);
-			return false;
-		});
-	});
-
-	document.querySelectorAll('[id^=button]').forEach((element) => {
-		element.addEventListener('click', () => {
-			const meta  = JSON.parse(element.dataset.meta);
-			const topic = meta.topic.set;
-
-			if (topic === null) return false;
-
-			client.publish(topic, transformMessage(element.dataset.mqttValue, meta), meta.options?.mqtt);
-		});
-	});
-
-	document.querySelectorAll('[id^=slider]').forEach((element) => {
-		const meta  = JSON.parse(element.dataset.meta);
-		const topic = meta.topic.set;
-
-		element.addEventListener('input', () => {
-			const value = (element.dataset.lastMqttValue - element.value) / (element.max - element.min) * (element.getBoundingClientRect().width - 20)
-			document.getElementById(meta.id).style.setProperty('--c', value + 'px');
-		});
-
-		element.addEventListener('change', () => {
-			if (topic === null) return false;
-
-			client.publish(topic, transformMessage(element.value, meta), meta.options?.mqtt);
-		});
-	});
-
-	document.querySelectorAll('[id^=select]').forEach((element) => {
-		element.addEventListener('click', () => {
-			const meta  = JSON.parse(element.dataset.meta);
-			const topic = meta.topic.set;
-
-			if (topic === null) return false;
-	
-			client.publish(topic, transformMessage(element.value, meta), meta.options?.mqtt);
-
-			element.value = element.dataset.lastMqttValue
-			document.getElementById(element.id + '_loader').classList.add('loader');
-		});
+	client.on('offline', () => {
+	    console.log('Disconnected from mqtt-server!')
+		document.querySelectorAll('.connectionStatus').forEach((element) => element.innerHTML = disconnectedSvg);
 	});
 
-	document.querySelectorAll('[id^=number]').forEach((element) => {
-		const meta  = JSON.parse(element.dataset.meta);
-		const topic = meta.topic.set;
-
-		if (topic === null) return false;
-
-		const input    = element.querySelector('input');
-		const btnLeft  = element.querySelector('button.sh-number-left');
-		const btnRight = element.querySelector('button.sh-number-right');
-
-		function getInputValue() {
-			return Number(input.value.replace(',', '.'));
+	client.on('error', (error) => {
+		if (error.message === 'Connection refused: Not authorized') {
+			alert('Authentication failed!');
+			localStorage.setItem('username', prompt('username:') ?? localStorage.getItem('username'));
+			localStorage.setItem('password', prompt('password:') ?? localStorage.getItem('password'));
 		}
 
-		element.addEventListener('change', () => {
-			const inputValue = getInputValue();
-			const transformedValue = transformMessage(inputValue, meta);
-
-			client.publish(topic, transformedValue, meta.options?.mqtt);
-			input.classList.add('working');
-		});
-
-		btnLeft.addEventListener('click', () => {
-			const increment        = meta.options?.['left-increment'] ?? -1;
-			const inputValue       = getInputValue();
-			const transformedValue = transformMessage(inputValue + increment, meta);
+		location.reload()
+	});
 
-			client.publish(topic, transformedValue, meta.options?.mqtt);
-			input.classList.add('working');
-		});
+	client.on('message', onMessage);
 
-		btnRight.addEventListener('click', () => {
-			const increment        = meta.options?.['right-increment'] ?? 1;
-			const inputValue       = getInputValue();
-			const transformedValue = transformMessage(inputValue + increment, meta);
+	client.subscribe([...topics])
 
-			client.publish(topic, transformedValue, meta.options?.mqtt);
-			input.classList.add('working');
-		});
-	});
 });
diff --git a/www/index.html b/www/index.html
@@ -2,7 +2,7 @@
 <html lang="en">
 	<head>
 		<meta charset="utf-8">
-		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+		<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
 		<meta name="apple-mobile-web-app-capable" content="yes">
 		<meta name="theme-color" content="#f8f9fa">
 		<link rel="apple-touch-icon" href="./favicon-512x512.png">
diff --git a/www/sw.js b/www/sw.js
@@ -50,7 +50,7 @@ let preCache = [
 	'./icons/weather.png'
 ];
 
-const CACHE = 'cache-v1';
+const CACHE = 'cache-v2';
 
 self.addEventListener('install', function (evt) {
 	self.skipWaiting();
diff --git a/yarn.lock b/yarn.lock
@@ -127,9 +127,9 @@
   integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
 
 "@types/node@*":
-  version "18.11.11"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.11.tgz#1d455ac0211549a8409d3cdb371cd55cc971e8dc"
-  integrity sha512-KJ021B1nlQUBLopzZmPBVuGU9un7WJd/W4ya7Ih02B4Uwky5Nja0yGYav2EfYIk0RR2Q9oVhf60S2XR1BCWJ2g==
+  version "18.11.12"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.12.tgz#89e7f8aa8c88abf432f9bd594888144d7dba10aa"
+  integrity sha512-FgD3NtTAKvyMmD44T07zz2fEf+OKwutgBCEVM8GcvMGVGaDktiLNTDvPwC/LUe3PinMW+X6CuLOF2Ui1mAlSXg==
 
 "@types/trusted-types@^2.0.2":
   version "2.0.2"

@@ -465,9 +465,9 @@ caniuse-api@^3.0.0:
     lodash.uniq "^4.5.0"
 
 caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001400:
-  version "1.0.30001436"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001436.tgz#22d7cbdbbbb60cdc4ca1030ccd6dea9f5de4848b"
-  integrity sha512-ZmWkKsnC2ifEPoWUvSAIGyOYwT+keAaaWPHiQ9DfMqS1t6tfuyFYoWR78TeZtznkEQ64+vGXH9cZrElwR2Mrxg==
+  version "1.0.30001439"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001439.tgz#ab7371faeb4adff4b74dad1718a6fd122e45d9cb"
+  integrity sha512-1MgUzEkoMO6gKfXflStpYgZDlFM7M/ck/bgfVCACO5vnAf0fXoNVHdWtqGU+MYca+4bL9Z5bpOVmR33cWW9G2A==
 
 chalk@^4.0.0:
   version "4.1.2"

@@ -1066,9 +1066,9 @@ lilconfig@^2.0.3:
   integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==
 
 lit-html@^2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.4.0.tgz#b510430f39a56ec959167ed1187241a4e3ab1574"
-  integrity sha512-G6qXu4JNUpY6aaF2VMfaszhO9hlWw0hOTRFDmuMheg/nDYGB+2RztUSOyrzALAbr8Nh0Y7qjhYkReh3rPnplVg==
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.5.0.tgz#9d4c0bb3652a6b10bc4ccdb627dfa9eff1215474"
+  integrity sha512-bLHosg1XL3JRUcKdSVI0sLCs0y1wWrj2sqqAN3cZ7bDDPNgmDHH29RV48x6Wz3ZmkxIupaE+z7uXSZ/pXWAO1g==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 

@@ -1186,11 +1186,6 @@ ms@2.1.2:
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
-nanoid@^2.1.0:
-  version "2.1.11"
-  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
-  integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
-
 nanoid@^3.3.4:
   version "3.3.4"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"

@@ -1675,9 +1670,9 @@ sass-loader@^13.2.0:
     neo-async "^2.6.2"
 
 sass@^1.56.1:
-  version "1.56.1"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.56.1.tgz#94d3910cd468fd075fa87f5bb17437a0b617d8a7"
-  integrity sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==
+  version "1.56.2"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.56.2.tgz#9433b345ab3872996c82a53a58c014fd244fd095"
+  integrity sha512-ciEJhnyCRwzlBCB+h5cCPM6ie/6f8HrhZMQOf5vlU60Y1bI1rx5Zb0vlDZvaycHsg/MqFfF1Eq2eokAa32iw8w==
   dependencies:
     chokidar ">=3.0.0 <4.0.0"
     immutable "^4.0.0"

@@ -1735,13 +1730,6 @@ shebang-regex@^3.0.0:
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
-shortid@^2.2.16:
-  version "2.2.16"
-  resolved "https://registry.yarnpkg.com/shortid/-/shortid-2.2.16.tgz#b742b8f0cb96406fd391c76bfc18a67a57fe5608"
-  integrity sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==
-  dependencies:
-    nanoid "^2.1.0"
-
 source-list-map@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"