Added start of editable dashboard
This commit is contained in:
parent
1af3f59b1a
commit
c3fe5951d4
8 changed files with 502 additions and 92 deletions
|
|
@ -7,8 +7,8 @@
|
|||
viewBox="0 0 8.466666 8.4666661"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
||||
sodipodi:docname="widget_light_off.svg"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
sodipodi:docname="widget_light_red.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -25,11 +25,11 @@
|
|||
inkscape:document-units="px"
|
||||
showborder="false"
|
||||
inkscape:zoom="32"
|
||||
inkscape:cx="18.171875"
|
||||
inkscape:cy="16.25"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1161"
|
||||
inkscape:window-x="0"
|
||||
inkscape:cx="18.1875"
|
||||
inkscape:cy="16.265625"
|
||||
inkscape:window-width="1093"
|
||||
inkscape:window-height="1401"
|
||||
inkscape:window-x="2560"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<circle
|
||||
style="fill:#d41400;stroke:none;stroke-width:1.00012;fill-opacity:1"
|
||||
style="fill:#ff2a2a;stroke:none;stroke-width:0.79399899;fill-opacity:1"
|
||||
id="circle1"
|
||||
cx="4.2333331"
|
||||
cy="4.2333331"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,122 +1,408 @@
|
|||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||
|
||||
export class SmonDashboard extends CustomHTMLElement {
|
||||
static {
|
||||
export class SmonDashboard extends CustomHTMLElement {// {{{
|
||||
static {// {{{
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
--edit-border: 1px solid #888;
|
||||
|
||||
display: grid;
|
||||
}
|
||||
|
||||
:host(.edit) {
|
||||
.el-grid {
|
||||
grid-gap: unset;
|
||||
border-top: var(--edit-border);
|
||||
border-left: var(--edit-border);
|
||||
|
||||
& > * {
|
||||
border-right: var(--edit-border);
|
||||
border-bottom: var(--edit-border);
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
& > *:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
& > .empty {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.only-on-edit {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.el-error {
|
||||
display: none;
|
||||
margin-bottom: 16px;
|
||||
width: min-content;
|
||||
white-space: nowrap;
|
||||
background-color: #ff2a2a;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
padding: 16px 32px;
|
||||
border-radius: 8px;
|
||||
|
||||
&.show {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.el-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: min-content;
|
||||
grid-gap: 8px 16px;
|
||||
width: min-content;
|
||||
|
||||
.empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.only-on-edit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
width: min-content;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
input[type="text"] {
|
||||
padding: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
select {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button data-el="edit-dashboard">Edit dashboard</button>
|
||||
<button class="only-on-edit" data-el="add-columns">Add columns</button>
|
||||
<button class="only-on-edit" data-el="add-rows">Add rows</button>
|
||||
<button class="only-on-edit" data-el="update-dashboard">Update dashboard</button>
|
||||
</div>
|
||||
|
||||
<div data-el="error"></div>
|
||||
<div data-el="grid">
|
||||
</div>
|
||||
|
||||
<dialog data-el="edit">
|
||||
<div data-el="edit-components"></div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||||
<button data-el="edit-update">Update</button>
|
||||
<button data-el="edit-remove">Remove</button>
|
||||
</div>
|
||||
</dialog>
|
||||
`
|
||||
}
|
||||
constructor() {
|
||||
}// }}}
|
||||
constructor() {// {{{
|
||||
super(true)
|
||||
const script = this.querySelector('script')
|
||||
this.data = JSON.parse(script.textContent)
|
||||
globalThis.foo = this.data
|
||||
this.widgets = new Map() // keyed by datapoint ID, value is an array of widgets.
|
||||
this.addColumns = 0
|
||||
this.addRows = 0
|
||||
this.editedWidget = null
|
||||
|
||||
this.elEditDashboard.addEventListener('click', () => this.classList.toggle('edit'))
|
||||
this.elAddColumns.addEventListener('click', () => { this.addColumns += 5; this.render() })
|
||||
this.elAddRows.addEventListener('click', () => { this.addRows += 5; this.render() })
|
||||
this.elUpdateDashboard.addEventListener('click', () => this.saveDashboard())
|
||||
this.elEditUpdate.addEventListener('click', () => this.editUpdate())
|
||||
|
||||
this.render()
|
||||
}
|
||||
this.fetchData() // Retrieve one as fast as possible since setInterval doesn't run before an interval.
|
||||
setInterval(() => this.fetchData(), 1000)
|
||||
}// }}}
|
||||
|
||||
render() {
|
||||
render() {// {{{
|
||||
const widgets = []
|
||||
for (const wd of this.data.Widgets) {
|
||||
const widget = SmonWidget.create(wd.Type, wd)
|
||||
widgets.push(widget)
|
||||
}
|
||||
this.elGrid.replaceChildren(...widgets)
|
||||
}
|
||||
}
|
||||
this.widgets = new Map()
|
||||
|
||||
class SmonWidget extends CustomHTMLElement {
|
||||
static create(t, data) {
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
let occupiedCells = new Map()
|
||||
|
||||
for (const wd of this.data.Widgets) {
|
||||
if (!this.widgets.has(wd.DatapointID))
|
||||
this.widgets.set(wd.DatapointID, [])
|
||||
// widgetRefs modifies the array in the map directly.
|
||||
const widgetRefs = this.widgets.get(wd.DatapointID)
|
||||
const widget = SmonWidget.create(wd.Type, wd)
|
||||
widget.addEventListener('click', () => this.selectCell(widget))
|
||||
widgetRefs.push(widget)
|
||||
widgets.push(widget)
|
||||
|
||||
maxX = Math.max(maxX, wd.X)
|
||||
maxY = Math.max(maxY, wd.Y)
|
||||
|
||||
occupiedCells.set(`${wd.X}x${wd.Y}`, true)
|
||||
for (let x = 0; x <= wd.SpanX; x++) {
|
||||
occupiedCells.set(`${wd.X + x}x${wd.Y}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
maxX += this.addColumns
|
||||
maxY += this.addRows
|
||||
|
||||
// Empty elements are created to fill up the empty cells in the grid.
|
||||
// This is needed as placeholders for grid and dragdrop targets when editing.
|
||||
for (let x = 1; x <= maxX; x++)
|
||||
for (let y = 1; y <= maxY; y++) {
|
||||
if (occupiedCells.has(`${x}x${y}`))
|
||||
continue
|
||||
|
||||
const empty = new SmonWidgetEmpty(x, y)
|
||||
empty.addEventListener('click', () => this.selectCell(empty))
|
||||
widgets.push(empty)
|
||||
}
|
||||
|
||||
this.elGrid.replaceChildren(...widgets)
|
||||
}// }}}
|
||||
error(msg) {// {{{
|
||||
this.elError.innerText = msg
|
||||
if (msg === '')
|
||||
this.elError.classList.remove('show')
|
||||
else
|
||||
this.elError.classList.add('show')
|
||||
}// }}}
|
||||
saveDashboard() {// {{{
|
||||
}// }}}
|
||||
async editUpdate() {// {{{
|
||||
await this.editedWidget.editUpdate()
|
||||
this.elEdit.close()
|
||||
this.editedWidget = null
|
||||
this.render()
|
||||
}// }}}
|
||||
selectCell(el) {// {{{
|
||||
if (!this.classList.contains('edit'))
|
||||
return
|
||||
|
||||
this.editedWidget = el
|
||||
const components = el.edit()
|
||||
this.elEditComponents.replaceChildren(...components)
|
||||
this.elEdit.showModal()
|
||||
}// }}}
|
||||
async fetchData() {// {{{
|
||||
try {
|
||||
const res = await fetch(`/dashboard/values`)
|
||||
const json = await res.json()
|
||||
|
||||
if (!json.OK)
|
||||
throw new Error(json.Error)
|
||||
|
||||
for (const dp of json.Values) {
|
||||
const widgets = this.widgets.get(dp.ID) || []
|
||||
for (const w of widgets) {
|
||||
w.setValue(dp.Value)
|
||||
w.render()
|
||||
}
|
||||
}
|
||||
|
||||
this.error('')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error(e.message)
|
||||
|
||||
// All widgets are set in a NULL state to visualize the error.
|
||||
const allDpIDs = this.widgets.keys()
|
||||
for (const id of allDpIDs) {
|
||||
const widgets = this.widgets.get(id) || []
|
||||
for (const w of widgets) {
|
||||
w.setValue(null)
|
||||
w.render()
|
||||
}
|
||||
}
|
||||
}
|
||||
}// }}}
|
||||
}// }}}
|
||||
|
||||
class SmonWidget extends CustomHTMLElement {// {{{
|
||||
static create(t, data) {// {{{
|
||||
switch (t) {
|
||||
case 0: return new SmonWidgetLabel(data)
|
||||
case 1: return new SmonWidgetOnOff(data)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(data) {
|
||||
}// }}}
|
||||
constructor(data) {// {{{
|
||||
super(true)
|
||||
this.data = data
|
||||
}
|
||||
this.value = null
|
||||
this.editDialog = null
|
||||
}// }}}
|
||||
|
||||
render() {
|
||||
render() {// {{{
|
||||
this.style.gridColumn = this.data.X
|
||||
this.style.gridRow = this.data.Y
|
||||
}
|
||||
}
|
||||
|
||||
class SmonWidgetLabel extends SmonWidget {
|
||||
static {
|
||||
if (this.data.SpanX > 0)
|
||||
this.style.gridColumn = `${this.data.X} / ${this.data.X + this.data.SpanX + 1}`
|
||||
|
||||
if (this.data.Attributes.BorderBottom) {
|
||||
if (this.data.Attributes.Color)
|
||||
this.style.borderBottom = `1px solid ${this.data.Attributes.Color}`
|
||||
else
|
||||
this.style.borderBottom = `1px solid currentColor`
|
||||
}
|
||||
}// }}}
|
||||
setValue(v) {// {{{
|
||||
this.value = v
|
||||
}// }}}
|
||||
edit() {// {{{
|
||||
if (this.editDialog !== null)
|
||||
return
|
||||
|
||||
const components = this.editWidget()
|
||||
return components
|
||||
}// }}}
|
||||
|
||||
// Many widgets can have labels.
|
||||
// This function applies common attributes like size and color.
|
||||
applyLabelAttributes(el) {// {{{
|
||||
if (this.data.Attributes?.Size)
|
||||
el.style.fontSize = this.data.Attributes.Size
|
||||
|
||||
if (this.data.Attributes?.Color)
|
||||
el.style.color = this.data.Attributes.Color
|
||||
|
||||
if (this.data.Attributes?.Underline)
|
||||
if (this.data.Attributes?.Color)
|
||||
el.style.borderBottom = `1px solid ${this.data.Attributes.Color}`
|
||||
else
|
||||
el.style.borderBottom = `1px solid currentColor`
|
||||
}// }}}
|
||||
}// }}}
|
||||
class SmonWidgetEmpty extends SmonWidget {// {{{
|
||||
static {// {{{
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
}// }}}
|
||||
constructor(x, y) {// {{{
|
||||
super({
|
||||
Attributes: {},
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
|
||||
this.classList.add('empty')
|
||||
this.render()
|
||||
}// }}}
|
||||
render() {// {{{
|
||||
super.render()
|
||||
}// }}}
|
||||
editWidget() {// {{{
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = `
|
||||
<div>Add widget:</div>
|
||||
<select style="width: 100%; padding: 4px 4px">
|
||||
<option value="0">Label</option>
|
||||
<option value="1">On / off</option>
|
||||
</select>
|
||||
`
|
||||
return [div]
|
||||
}// }}}
|
||||
}// }}}
|
||||
class SmonWidgetLabel extends SmonWidget {// {{{
|
||||
static {// {{{
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<style>
|
||||
.el-label {
|
||||
font-weight: bold;
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
<div data-el="label"></div>
|
||||
`
|
||||
}
|
||||
|
||||
constructor(data) {
|
||||
}// }}}
|
||||
constructor(data) {// {{{
|
||||
super(data)
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
}// }}}
|
||||
render() {// {{{
|
||||
super.render()
|
||||
this.elLabel.innerText = this.data.Attributes?.Label || '[set Label]'
|
||||
}
|
||||
}
|
||||
|
||||
class SmonWidgetOnOff extends SmonWidget {
|
||||
static {
|
||||
this.applyLabelAttributes(this.elLabel)
|
||||
}// }}}
|
||||
editWidget() {// {{{
|
||||
this.editDiv = document.createElement('div')
|
||||
this.editDiv.innerHTML = `
|
||||
<div>Label:</div>
|
||||
<input type="text">
|
||||
`
|
||||
this.editDiv.querySelector('input').value = this.data.Attributes.Label
|
||||
return [this.editDiv]
|
||||
}// }}}
|
||||
async editUpdate() {// {{{
|
||||
console.log('hum')
|
||||
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
||||
}// }}}
|
||||
}// }}}
|
||||
class SmonWidgetOnOff extends SmonWidget {// {{{
|
||||
static {// {{{
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
align-items: center;
|
||||
grid-gap: 8px;
|
||||
:host {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
align-items: center;
|
||||
grid-gap: 8px;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
font-size: 1.25em;
|
||||
}
|
||||
</style>
|
||||
<img data-el="status">
|
||||
<div data-el="label"></div>
|
||||
`
|
||||
}
|
||||
|
||||
constructor(data) {
|
||||
}// }}}
|
||||
constructor(data) {// {{{
|
||||
super(data)
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
}// }}}
|
||||
render() {// {{{
|
||||
super.render()
|
||||
|
||||
let img
|
||||
switch (this.data.Attributes.Status) {
|
||||
switch (this.value) {
|
||||
case 1:
|
||||
case 'ON':
|
||||
img = 'widget_light_green.svg'
|
||||
break
|
||||
|
||||
case 0:
|
||||
case 'OFF':
|
||||
img = 'widget_light_red.svg'
|
||||
break
|
||||
|
|
@ -127,11 +413,25 @@ class SmonWidgetOnOff extends SmonWidget {
|
|||
|
||||
this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`)
|
||||
this.elLabel.innerText = this.data.Attributes.Label
|
||||
}
|
||||
}
|
||||
this.applyLabelAttributes(this.elLabel)
|
||||
}// }}}
|
||||
editWidget() {// {{{
|
||||
this.editDiv = document.createElement('div')
|
||||
this.editDiv.innerHTML = `
|
||||
<div>Label:</div>
|
||||
<input type="text">
|
||||
`
|
||||
this.editDiv.querySelector('input').value = this.data.Attributes.Label
|
||||
return [this.editDiv]
|
||||
}// }}}
|
||||
async editUpdate() {// {{{
|
||||
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
||||
}// }}}
|
||||
}// }}}
|
||||
|
||||
|
||||
customElements.define("smon-widget", SmonWidget)
|
||||
customElements.define("smon-widget-empty", SmonWidgetEmpty)
|
||||
customElements.define("smon-widget-label", SmonWidgetLabel)
|
||||
customElements.define("smon-widget-onoff", SmonWidgetOnOff)
|
||||
customElements.define("smon-dashboard", SmonDashboard)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue