commit a0d45c2a73b464fcda3e3d57b5be8cbd2f792745
parent 6ca202d045a168c37d55e10cd5d5257fe77959d9
Author: Leah (ctucx) <git@ctu.cx>
Date: Thu, 8 Dec 2022 13:02:10 +0100
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(-)
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}"> </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}}"> </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"