ctucx.git: mqtt-webui

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

commit a0d45c2a73b464fcda3e3d57b5be8cbd2f792745
parent 6ca202d045a168c37d55e10cd5d5257fe77959d9
Author: Leah (ctucx) <git@ctu.cx>
Date: Thu, 8 Dec 2022 13:02:10 +0100

use lit-html for templates
4 files changed, 165 insertions(+), 142 deletions(-)
M
package.json
|
6
++----
M
src/webui.js
|
197
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
M
www/index.html
|
87
-------------------------------------------------------------------------------
M
yarn.lock
|
17
++++++++++++-----
diff --git a/package.json b/package.json
@@ -1,5 +1,4 @@
 {
-
 	"type": "module",
 	"name": "mqtt-webui",
 	"homepage": "https://git.ctu.cx/mqtt-webui",

@@ -12,13 +11,13 @@
 	],
 
 	"scripts": {
- 		"build": "webpack-cli --config ./webpack.config.js"
+		"build": "webpack-cli --config ./webpack.config.js"
 	},
 
 	"dependencies": {
 		"webpack": "5.11.1",
+		"lit-html": "^2.4.0",
 		"shortid": "^2.2.16",
-		"mustache": "^4.1.0",
 		"mqtt": "^4.3.7",
 		"buffer": "^6.0.3",
 		"process": "^0.11.10",

@@ -33,5 +32,4 @@
 		"sass-loader": "^13.2.0",
 		"webpack-cli": "^4.3.0"
 	}
-
 }
diff --git a/src/webui.js b/src/webui.js
@@ -2,9 +2,12 @@
 
 import "./webui.css";
 
-import Mustache from 'mustache';
-import shortid  from 'shortid';
-import { connect } from "mqtt";
+import { html, render } from 'lit-html';
+import { map }          from 'lit-html/directives/map.js';
+import {ifDefined}      from 'lit-html/directives/if-defined.js';
+
+import shortid               from 'shortid';
+import { connect }           from "mqtt";
 
 const goToPage = (pageHash) => {
 	if (typeof pageHash === 'object') pageHash = location.hash || '#mainpage';

@@ -57,6 +60,136 @@ const getConfig = async () => {
 	return config;
 };
 
+const renderItem = (itemData) => {
+	itemData.id = itemData.type + '_' + shortid.generate();
+
+	if (typeof itemData.topic === 'string') {
+		const temporary = itemData.topic;
+		itemData.topic = {};
+		itemData.topic.get = temporary;
+		itemData.topic.set = null;
+	}
+
+	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';
+		}
+
+		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>
+				`;
+				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;
+
+			// untested
+			case 'button':
+				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>
+				`;
+				break;
+
+			case 'slider':
+				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>
+				`;
+				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>
+				`;
+				break;
+
+			case 'select':
+				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>
+				`;
+				break;
+		}
+
+		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 class="title">${itemData.title}</div>
+				${element}
+			</div>
+		`;
+	} else {
+		return html`<div id="${itemData.id}" data-mqtt-topic="${itemData.topic.get}" data-meta="${JSON.stringify(itemData)}">${itemData.html}</div>`;
+	}
+}
+
+const renderSection = (sectionData) => {
+	const title = (sectionData.title) ? html`<div class="list-group-item seperator">${sectionData.title}</div>` : html``
+	return html`
+		<div class="row list-group">
+			${title}
+			${map(sectionData.items, (itemData) => renderItem(itemData))}			
+		</div>
+	`;
+}
+
+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>`;
+
+
+	return  html`
+		<div id="${pageData.id}" class="page">
+			<nav class="row">
+				${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>
+			</nav>
+			${map(pageData.sections, (sectionData) => renderSection(sectionData))}
+		</div>
+	`;
+};
+
+const renderPages = (config) => html`${map(config.pages, (pageData) => renderPage(pageData))}`;
+
 
 window.addEventListener('hashchange', goToPage);
 window.addEventListener('DOMContentLoaded', async (event) => {

@@ -65,50 +198,22 @@ window.addEventListener('DOMContentLoaded', async (event) => {
 
 	// Get config
 	const data   = await getConfig();
-	const topics = new Set('time');
+	const topics = new Set();
 
-	// Preflight data
+	// Get all topics
 	for (const [i, page] of Object.entries(data.pages)) {
-		data.pages[i].mainpage = (page.pageid === 'mainpage');
-
 		for (const [j, section] of Object.entries(page.sections)) {
 			for (const [k, item] of Object.entries(section.items)) {
-				// Create boolean values for Mustache
-				data.pages[i].sections[j].items[k]['itemtype_' + item.type] = true;
-
-				// Type specific changes
-				data.pages[i].sections[j].items[k][item.type + 'Id'] = item.type + '_' + shortid.generate();
-				if (item.type === 'slider') {
-					data.pages[i].sections[j].items[k].sliderMinValue = ('sliderMinValue' in item) ? item.sliderMinValue : 0;
-					data.pages[i].sections[j].items[k].sliderMaxValue = ('sliderMaxValue' in item) ? item.sliderMaxValue : 1;
-					data.pages[i].sections[j].items[k].sliderStepValue = ('sliderStepValue' in item) ? item.sliderStepValue : 'any';
-				}
-
-				// Handle meta-data
-				if (typeof item.topic === 'string') {
-					const temporary = item.topic;
-					item.topic = {};
-					if (/\/{2}/.test(temporary)) { // Foo//bar
-						item.topic.get = temporary.replace('//', '/status/');
-						item.topic.set = temporary.replace('//', '/set/');
-					} else {
-						item.topic.get = temporary;
-						item.topic.set = null;
-					}
-				}
-
-				data.pages[i].sections[j].items[k].meta = JSON.stringify(item);
-
-				if ('topic' in item) {
-					topics.add(item.topic.get);
+				if (item.topic !== undefined) {
+					if (typeof item.topic === 'string')      topics.add(item.topic);
+					if (item.topic.get !== undefined) topics.add(item.topic.get);
 				}
 			}
 		}
 	}
 
-	// Mustache create UI
-	const template = document.getElementById('pageTemplate').innerHTML;
-	document.body.insertAdjacentHTML('afterbegin', Mustache.render(template, data));
+	// create UI
+	render(renderPages(data), document.body);
 
 	document.querySelectorAll('[data-link]').forEach((element) => {
 		element.style.cursor = 'pointer';

@@ -224,7 +329,7 @@ window.addEventListener('DOMContentLoaded', async (event) => {
 						return;
 					}
 
-					document.getElementById(meta.switchId).checked = usedValue;
+					document.getElementById(meta.id).checked = usedValue;
 					break;
 
 				case 'button':

@@ -250,9 +355,9 @@ window.addEventListener('DOMContentLoaded', async (event) => {
 						return;
 					}
 
-					document.getElementById(meta.sliderId).value                 = usedValue;
-					document.getElementById(meta.sliderId).dataset.lastMqttValue = usedValue;
-					document.getElementById(meta.sliderId).style.setProperty('--c', 0);
+					document.getElementById(meta.id).value                 = usedValue;
+					document.getElementById(meta.id).dataset.lastMqttValue = usedValue;
+					document.getElementById(meta.id).style.setProperty('--c', 0);
 					break;
 
 				case 'select':

@@ -265,9 +370,9 @@ window.addEventListener('DOMContentLoaded', async (event) => {
 						return;
 					}
 
-					document.getElementById(meta.selectId).value                 = usedValue;
-					document.getElementById(meta.selectId).dataset.lastMqttValue = usedValue;
-					document.getElementById(meta.selectId + '_loader').classList.remove('loader');
+					document.getElementById(meta.id).value                 = usedValue;
+					document.getElementById(meta.id).dataset.lastMqttValue = usedValue;
+					document.getElementById(meta.id + '_loader').classList.remove('loader');
 					break;
 
 				case 'number':

@@ -315,7 +420,7 @@ window.addEventListener('DOMContentLoaded', async (event) => {
 
 		element.addEventListener('input', () => {
 			const value = (element.dataset.lastMqttValue - element.value) / (element.max - element.min) * (element.getBoundingClientRect().width - 20)
-			document.getElementById(meta.sliderId).style.setProperty('--c', value + 'px');
+			document.getElementById(meta.id).style.setProperty('--c', value + 'px');
 		});
 
 		element.addEventListener('change', () => {
diff --git a/www/index.html b/www/index.html
@@ -12,93 +12,6 @@
 		<title>Smart-Home</title>
 	</head>
 	<body>
-		<script id="pageTemplate" type="text/html">
-		{{#pages}}
-			<div id="{{pageid}}" class="page">
-				<nav class="row">
-					{{#mainpage}}
-						<span class="icon"></span>
-					{{/mainpage}}
-					{{^mainpage}}
-						<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>
-					{{/mainpage}}
-					<a href="#">{{#pageicon}}<img src="{{pageicon}}" width="30" height="30" class="d-inline-block align-top" alt="">{{/pageicon}} {{pagetitle}}</a>
-					<button class="icon connectionStatus" onClick="window.location.reload(true)"><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>
-				</nav>
-				{{#sections}}
-				<div class="row list-group">
-					{{#sectiontitle}}<div class="list-group-item seperator">{{sectiontitle}}</div>{{/sectiontitle}}
-					{{#items}}
-					<div class="list-group-item" {{#page}}data-page="{{page}}"{{/page}} {{#link}}data-link="{{link}}"{{/link}}>
-					{{^itemtype_html}}
-						<div><img src="{{icon}}" class="icon {{^icon}}no-icon{{/icon}}"></div>
-						<div class="title">{{title}}</div>
-
-						{{#itemtype_text}}
-						{{#topic}}<div class="left" data-mqtt-topic="{{topic.get}}" data-meta="{{meta}}">undef.</div>{{/topic}}
-					  	{{/itemtype_text}}
-
-						{{#itemtype_switch}}
-						<div class="left">
-							<div class="toggle">
-								<input type="checkbox" id="{{switchId}}" data-mqtt-topic="{{topic.get}}" data-meta="{{meta}}">
-								<label for="{{switchId}}">&nbsp;</label>
-							</div>
-						</div>
-						{{/itemtype_switch}}
-
-						{{#itemtype_button}}
-						<div class="left">
-							<div class="btn-group" role="group">
-								{{#buttons}}
-								<button id="{{buttonId}}" type="button" class="btn btn-secondary" data-mqtt-value="{{value}}" data-mqtt-topic="{{topic.get}}" data-meta="{{meta}}">{{label}}</button>
-								{{/buttons}}
-							</div>
-						</div>
-						{{/itemtype_button}}
-
-						{{#itemtype_slider}}
-						<div class="left">
-							<input id="{{sliderId}}" type="range" class="slider" min="{{sliderMinValue}}" max="{{sliderMaxValue}}" step="{{sliderStepValue}}" data-mqtt-topic="{{topic.get}}" data-meta="{{meta}}">
-						</div>
-						{{/itemtype_slider}}
-
-						{{#itemtype_number}}
-						<div id="{{numberId}}" class="left sh-value" data-mqtt-topic="{{topic.get}}" data-meta="{{meta}}">
-							<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>
-						{{/itemtype_number}}
-
-						{{#itemtype_select}}
-						<div class="left">
-							<div id="{{selectId}}_loader"></div>
-							<select id="{{selectId}}" class="custom-select custom-select-md bg-secondary text-light revert-width" data-mqtt-topic="{{topic.get}}" data-meta="{{meta}}">
-								{{#selectOptions}}
-								<option value="{{value}}">{{label}}</option>
-								{{/selectOptions}}
-							</select>
-						</div>
-						{{/itemtype_select}}
-					{{/itemtype_html}}
-
-					{{#itemtype_html}}
-						<div id="{{htmlId}}" data-mqtt-topic="{{topic.get}}" data-meta="{{meta}}">{{{html}}}</div>
-					{{/itemtype_html}}
-					</div>
-					{{/items}}
-				</div>
-				{{/sections}}
-			</div>
-		{{/pages}}
-		</script>
 		<script src="bundle.js" type="module"></script>
 	</body>
 </html>
diff --git a/yarn.lock b/yarn.lock
@@ -131,6 +131,11 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.11.tgz#1d455ac0211549a8409d3cdb371cd55cc971e8dc"
   integrity sha512-KJ021B1nlQUBLopzZmPBVuGU9un7WJd/W4ya7Ih02B4Uwky5Nja0yGYav2EfYIk0RR2Q9oVhf60S2XR1BCWJ2g==
 
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
+
 "@types/yargs-parser@*":
   version "21.0.0"
   resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"

@@ -1060,6 +1065,13 @@ lilconfig@^2.0.3:
   resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
   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==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
 loader-runner@^4.1.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1"

@@ -1174,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==
 
-mustache@^4.1.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
-  integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
-
 nanoid@^2.1.0:
   version "2.1.11"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"