Improve installer readability and validation feedback

This commit is contained in:
Max Gramser
2026-09-12 12:23:53 +02:00
parent a40cc58108
commit 747ec4cceb
6 changed files with 1115 additions and 71 deletions
+317 -46
View File
@@ -1,64 +1,335 @@
'use strict';
const $ = s => document.querySelector(s);
let inventory = {screens: [], entities: []}, selected = null, layout = null, dirty = false, filter = '', dragIndex = -1, busy = false;
const node = (tag, text, cls) => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; };
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 5000); }
"use strict";
const $ = (s) => document.querySelector(s);
let inventory = { screens: [], entities: [] },
selected = null,
layout = null,
dirty = false,
filter = "",
dragIndex = -1,
busy = false;
const node = (tag, text, cls) => {
const n = document.createElement(tag);
if (text !== undefined) n.textContent = text;
if (cls) n.className = cls;
return n;
};
function toast(message) {
$("#toast").textContent = message;
$("#toast").hidden = false;
clearTimeout(toast.timer);
toast.timer = setTimeout(() => ($("#toast").hidden = true), 5000);
}
async function api(path, options = {}) {
const response = await fetch(`api/${path}`, {...options, headers: {'Content-Type': 'application/json', 'X-Screen-CSRF': inventory.csrf || '', ...options.headers}});
if (!response.ok) { let message = 'Dit lukte niet. Vernieuw de pagina en probeer opnieuw.'; try { message = (await response.json()).error || message; } catch {} throw new Error(message); }
const response = await fetch(`api/${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"X-Screen-CSRF": inventory.csrf || "",
...options.headers,
},
});
if (!response.ok) {
let message = "Dit lukte niet. Vernieuw de pagina en probeer opnieuw.";
try {
message = (await response.json()).error || message;
} catch {}
throw new Error(message);
}
return response;
}
function markDirty() { dirty = true; $('#dirty').textContent = 'Niet-opgeslagen wijzigingen'; $('#save-detail').textContent = 'Klik op opslaan om je scherm bij te werken.'; }
function markDirty() {
dirty = true;
$("#dirty").textContent = "Niet-opgeslagen wijzigingen";
$("#save-detail").textContent = "Klik op opslaan om je scherm bij te werken.";
}
function select(id) {
if (id !== selected && dirty && !confirm('Je hebt niet-opgeslagen wijzigingen. Toch een ander scherm openen?')) return;
selected = id; const screen = inventory.screens.find(s => s.id === id); if (!screen) return;
layout = structuredClone(screen.layout); dirty = false; $('#title').value = layout.title; $('#screen-name').textContent = screen.name;
$('#editor').hidden = false; $('#empty').hidden = true; $('#dirty').textContent = 'Alles opgeslagen'; $('#save-detail').textContent = 'Aanpassen kan zonder opnieuw flashen.';
renderScreens(); renderTiles(); renderResults();
if (
id !== selected &&
dirty &&
!confirm(
"Je hebt niet-opgeslagen wijzigingen. Toch een ander scherm openen?",
)
)
return;
selected = id;
const screen = inventory.screens.find((s) => s.id === id);
if (!screen) return;
layout = structuredClone(screen.layout);
dirty = false;
$("#title").value = layout.title;
$("#screen-name").textContent = screen.name;
$("#editor").hidden = false;
$("#empty").hidden = true;
$("#dirty").textContent = "Alles opgeslagen";
$("#save-detail").textContent = "Aanpassen kan zonder opnieuw flashen.";
renderScreens();
renderTiles();
renderResults();
}
function renderScreens() {
$('#screens').replaceChildren();
for (const screen of inventory.screens) { const b = node('button', undefined, `screen ${screen.id === selected ? 'selected' : ''}`); b.append(node('strong', screen.name), node('small', `${screen.online ? '● Online' : '○ Offline'}${screen.area ? ' · '+screen.area : ''} · firmware ${screen.firmware || 'onbekend'}`)); b.onclick = () => select(screen.id); $('#screens').append(b); }
const screen = inventory.screens.find(s => s.id === selected);
if (screen) { $('#delivery').textContent = screen.online ? `${screen.delivery} · ${screen.status}` : 'Offline · wijzigingen worden bewaard'; $('#delivery').classList.toggle('online', screen.online); }
$("#screens").replaceChildren();
for (const screen of inventory.screens) {
const b = node(
"button",
undefined,
`screen ${screen.id === selected ? "selected" : ""}`,
);
b.append(
node("strong", screen.name),
node(
"small",
`${screen.online ? "● Online" : "○ Offline"}${screen.area ? " · " + screen.area : ""} · firmware ${screen.firmware || "onbekend"}`,
),
);
b.onclick = () => select(screen.id);
$("#screens").append(b);
}
const screen = inventory.screens.find((s) => s.id === selected);
if (screen) {
$("#delivery").textContent = screen.online
? `${screen.delivery} · ${screen.status}`
: "Offline · wijzigingen worden bewaard";
$("#delivery").classList.toggle("online", screen.online);
}
}
function move(from, to) {
if (to < 0 || to >= layout.tiles.length) return;
const [tile] = layout.tiles.splice(from, 1);
layout.tiles.splice(to, 0, tile);
markDirty();
renderTiles();
}
function move(from, to) { if (to < 0 || to >= layout.tiles.length) return; const [tile] = layout.tiles.splice(from, 1); layout.tiles.splice(to, 0, tile); markDirty(); renderTiles(); }
function renderTiles() {
$('#tiles').replaceChildren(); $('#count').textContent = `${layout.tiles.length} / 10`; $('#no-tiles').hidden = layout.tiles.length > 0;
$("#tiles").replaceChildren();
$("#count").textContent = `${layout.tiles.length} / 10`;
$("#no-tiles").hidden = layout.tiles.length > 0;
layout.tiles.forEach((tile, i) => {
if (i === 6) $('#tiles').append(node('li', 'Pagina 2', 'page-break'));
const li = node('li', undefined, 'tile'); li.draggable = true;
li.ondragstart = e => { if (e.target instanceof HTMLInputElement) { e.preventDefault(); return; } dragIndex = i; e.dataTransfer.setData('text/plain', String(i)); li.classList.add('dragging'); };
li.ondragend = () => { dragIndex = -1; li.classList.remove('dragging'); };
li.ondragover = e => { if (dragIndex >= 0) e.preventDefault(); };
li.ondrop = e => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, i); };
const content = node('div'), input = node('input'); input.value = tile.name; input.placeholder = inventory.entities.find(e => e.id === tile.entity)?.name || tile.entity; input.maxLength = 60; input.setAttribute('aria-label', `Naam voor tegel ${i+1}`); input.oninput = () => { tile.name = input.value; markDirty(); }; content.append(input, node('small', tile.entity));
const actions = node('div', undefined, 'tile-actions'); for (const [label, title, action, disabled] of [['↑','Omhoog',()=>move(i,i-1),i===0],['↓','Omlaag',()=>move(i,i+1),i===layout.tiles.length-1],['✕','Verwijderen',()=>{layout.tiles.splice(i,1);markDirty();renderTiles();renderResults();},false]]) { const b = node('button',label); b.title = title; b.setAttribute('aria-label',title); b.disabled = disabled; b.onclick = action; actions.append(b); }
li.append(node('span','⠿','grip'), content, actions); $('#tiles').append(li);
if (i === 6) $("#tiles").append(node("li", "Pagina 2", "page-break"));
const li = node("li", undefined, "tile");
li.draggable = true;
li.ondragstart = (e) => {
if (e.target instanceof HTMLInputElement) {
e.preventDefault();
return;
}
dragIndex = i;
e.dataTransfer.setData("text/plain", String(i));
li.classList.add("dragging");
};
li.ondragend = () => {
dragIndex = -1;
li.classList.remove("dragging");
};
li.ondragover = (e) => {
if (dragIndex >= 0) e.preventDefault();
};
li.ondrop = (e) => {
e.preventDefault();
if (dragIndex >= 0) move(dragIndex, i);
};
const content = node("div"),
input = node("input");
input.value = tile.name;
input.placeholder =
inventory.entities.find((e) => e.id === tile.entity)?.name || tile.entity;
input.maxLength = 60;
input.setAttribute("aria-label", `Naam voor tegel ${i + 1}`);
input.oninput = () => {
tile.name = input.value;
markDirty();
};
content.append(input, node("small", tile.entity));
const actions = node("div", undefined, "tile-actions");
for (const [label, title, action, disabled] of [
["↑", "Omhoog", () => move(i, i - 1), i === 0],
["↓", "Omlaag", () => move(i, i + 1), i === layout.tiles.length - 1],
[
"✕",
"Verwijderen",
() => {
layout.tiles.splice(i, 1);
markDirty();
renderTiles();
renderResults();
},
false,
],
]) {
const b = node("button", label);
b.title = title;
b.setAttribute("aria-label", title);
b.disabled = disabled;
b.onclick = action;
actions.append(b);
}
li.append(node("span", "⠿", "grip"), content, actions);
$("#tiles").append(li);
});
}
function renderResults() {
if (!layout) return;
const query = $('#search').value.toLocaleLowerCase(); const chosen = new Set(layout.tiles.map(t => t.entity));
const matches = inventory.entities.filter(e => (!filter || e.id.startsWith(filter+'.')) && `${e.name} ${e.id} ${e.device} ${e.area}`.toLocaleLowerCase().includes(query));
$('#results').replaceChildren();
const query = $("#search").value.toLocaleLowerCase();
const chosen = new Set(layout.tiles.map((t) => t.entity));
const matches = inventory.entities.filter(
(e) =>
(!filter || e.id.startsWith(filter + ".")) &&
`${e.name} ${e.id} ${e.device} ${e.area}`
.toLocaleLowerCase()
.includes(query),
);
$("#results").replaceChildren();
for (const entity of matches.slice(0, 80)) {
const b = node('button', undefined, 'result'), description = node('span'); description.append(node('span',entity.name), node('small',[entity.area,entity.device].filter(Boolean).join(' · ')), node('small',entity.id)); b.append(description,node('span',chosen.has(entity.id)?'✓':'+','plus')); b.disabled = chosen.has(entity.id) || layout.tiles.length >= 10; b.onclick = () => { layout.tiles.push({entity:entity.id,name:''}); markDirty();renderTiles();renderResults(); }; $('#results').append(b);
const b = node("button", undefined, "result"),
description = node("span");
description.append(
node("span", entity.name),
node("small", [entity.area, entity.device].filter(Boolean).join(" · ")),
node("small", entity.id),
);
b.append(
description,
node("span", chosen.has(entity.id) ? "✓" : "+", "plus"),
);
b.disabled = chosen.has(entity.id) || layout.tiles.length >= 10;
b.onclick = () => {
layout.tiles.push({ entity: entity.id, name: "" });
markDirty();
renderTiles();
renderResults();
};
$("#results").append(b);
}
if (!matches.length) $('#results').append(node('p','Geen entiteiten gevonden. Probeer een andere naam.','hint'));
if (matches.length > 80) $('#results').append(node('p',`${matches.length} resultaten. Typ verder om te verfijnen.`,'hint'));
if (!matches.length)
$("#results").append(
node("p", "Geen entiteiten gevonden. Probeer een andere naam.", "hint"),
);
if (matches.length > 80)
$("#results").append(
node(
"p",
`${matches.length} resultaten. Typ verder om te verfijnen.`,
"hint",
),
);
}
async function refresh() {
try { inventory = await (await api('inventory')).json(); $('#connection').textContent = inventory.connected ? '● Home Assistant verbonden' : 'Verbinding met Home Assistant herstellen…'; $('#connection').classList.toggle('online',inventory.connected); renderScreens(); if (!selected && inventory.screens.length) select(inventory.screens[0].id); }
catch { $('#connection').textContent = 'Beheerpagina niet bereikbaar · opnieuw proberen…'; }
try {
inventory = await (await api("inventory")).json();
$("#connection").textContent = inventory.connected
? "● Home Assistant verbonden"
: "Verbinding met Home Assistant herstellen…";
$("#connection").classList.toggle("online", inventory.connected);
renderScreens();
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
} catch {
$("#connection").textContent =
"Beheerpagina niet bereikbaar · opnieuw proberen…";
}
}
$('#save').onclick = async () => { if (busy) return; busy = true; $('#save').disabled = true; try { layout.title = $('#title').value; await api(`screens/${encodeURIComponent(selected)}`, {method:'PUT',body:JSON.stringify(layout)}); dirty = false; $('#dirty').textContent = 'Opgeslagen'; $('#save-detail').textContent = 'Synchronisatie volgt automatisch, ook na opnieuw verbinden.'; toast('Opgeslagen. Je scherm wordt bijgewerkt.'); await refresh(); } catch(e) { toast(e.message); } finally { busy = false; $('#save').disabled = false; } };
$('#title').oninput = markDirty; $('#search').oninput = renderResults; $('#refresh').onclick = refresh;
for (const [value,label] of [['','Alles'],['light','Lampen'],['climate','Klimaat'],['scene','Scènes'],['vacuum','Vacuum'],['sensor','Sensoren']]) { const b = node('button',label,value===''?'active':''); b.onclick=()=>{filter=value;$('#filters').querySelectorAll('button').forEach(n=>n.classList.remove('active'));b.classList.add('active');renderResults();};$('#filters').append(b); }
$('#new-screen').onclick = $('#start').onclick = () => $('#installer').showModal(); $('#close-install').onclick = () => $('#installer').close();
let generatedName = '';
$('#install-form').onsubmit = async e => { e.preventDefault(); $('#download').disabled = true; try { const data = Object.fromEntries(new FormData(e.target)); const response = await api('install',{method:'POST',body:JSON.stringify(data)}); $('#yaml-output').value = await response.text(); generatedName = data.name; $('#yaml-result').hidden = false; $('#install-status').textContent='Je YAML is klaar. Kopieer hem naar ESPHome en bewaar het bestand met sleutels.'; } catch(e) { $('#install-status').textContent=e.message; } finally { $('#download').disabled=false; } };
$('#copy-yaml').onclick = async () => { try { if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText($('#yaml-output').value); else { $('#yaml-output').select(); if (!document.execCommand('copy')) throw new Error(); } $('#install-status').textContent='Gekopieerd. Plak dit bij Edit in ESPHome Device Builder.'; } catch { $('#yaml-output').select(); $('#install-status').textContent='De tekst is geselecteerd. Kopieer met Ctrl+C of Command+C.'; } };
$('#download-yaml').onclick = () => { const url = URL.createObjectURL(new Blob([$('#yaml-output').value],{type:'text/plain'})); const a=node('a');a.href=url;a.download=`${generatedName}.yaml`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);$('#install-status').textContent='Download gestart. Blokkeert je browser deze? Gebruik Kopieer YAML.'; };
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
refresh(); setInterval(refresh, 10000);
$("#save").onclick = async () => {
if (busy) return;
busy = true;
$("#save").disabled = true;
try {
layout.title = $("#title").value;
await api(`screens/${encodeURIComponent(selected)}`, {
method: "PUT",
body: JSON.stringify(layout),
});
dirty = false;
$("#dirty").textContent = "Opgeslagen";
$("#save-detail").textContent =
"Synchronisatie volgt automatisch, ook na opnieuw verbinden.";
toast("Opgeslagen. Je scherm wordt bijgewerkt.");
await refresh();
} catch (e) {
toast(e.message);
} finally {
busy = false;
$("#save").disabled = false;
}
};
$("#title").oninput = markDirty;
$("#search").oninput = renderResults;
$("#refresh").onclick = refresh;
for (const [value, label] of [
["", "Alles"],
["light", "Lampen"],
["climate", "Klimaat"],
["scene", "Scènes"],
["vacuum", "Vacuum"],
["sensor", "Sensoren"],
]) {
const b = node("button", label, value === "" ? "active" : "");
b.onclick = () => {
filter = value;
$("#filters")
.querySelectorAll("button")
.forEach((n) => n.classList.remove("active"));
b.classList.add("active");
renderResults();
};
$("#filters").append(b);
}
$("#new-screen").onclick = $("#start").onclick = () =>
$("#installer").showModal();
$("#close-install").onclick = () => $("#installer").close();
let generatedName = "";
$("#install-form").onsubmit = async (e) => {
e.preventDefault();
$("#download").disabled = true;
try {
const data = Object.fromEntries(new FormData(e.target));
const response = await api("install", {
method: "POST",
body: JSON.stringify(data),
});
$("#yaml-output").value = await response.text();
generatedName = data.name;
$("#yaml-result").hidden = false;
$("#install-status").textContent =
"Je YAML is klaar. Kopieer hem naar ESPHome en bewaar het bestand met sleutels.";
} catch (e) {
$("#install-status").textContent = e.message;
} finally {
$("#download").disabled = false;
}
};
$("#copy-yaml").onclick = async () => {
try {
if (navigator.clipboard && window.isSecureContext)
await navigator.clipboard.writeText($("#yaml-output").value);
else {
$("#yaml-output").select();
if (!document.execCommand("copy")) throw new Error();
}
$("#install-status").textContent =
"Gekopieerd. Plak dit bij Edit in ESPHome Device Builder.";
} catch {
$("#yaml-output").select();
$("#install-status").textContent =
"De tekst is geselecteerd. Kopieer met Ctrl+C of Command+C.";
}
};
$("#download-yaml").onclick = () => {
const url = URL.createObjectURL(
new Blob([$("#yaml-output").value], { type: "text/plain" }),
);
const a = node("a");
a.href = url;
a.download = `${generatedName}.yaml`;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
$("#install-status").textContent =
"Download gestart. Blokkeert je browser deze? Gebruik Kopieer YAML.";
};
window.addEventListener("beforeunload", (e) => {
if (dirty) {
e.preventDefault();
e.returnValue = "";
}
});
refresh();
setInterval(refresh, 10000);