4af92e1b4c
Firmware 0.2.15: trend graphs use a Catmull-Rom curve with a soft fill drawn by the card's draw event; sun.sun gets a wide sun-path card with horizon, arc and the sun at its current position; page switches show the new page's card frames immediately and fill them from a one-shot timer; the navigation is one full-width bar without a divider; the CYD adopts the Guition light palette in styles and overlays. App 0.2.13: pointer-based drag & drop (mouse and touch) to reorder tiles in the mockup and to drop entities from the list, sticky save bar, a new header with explained actions and a three-step guide, clearer firmware dialog. New sun, weather and clock tiles start wide in their best view. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
960 lines
36 KiB
JavaScript
960 lines
36 KiB
JavaScript
"use strict";
|
||
const $ = (s) => document.querySelector(s);
|
||
let inventory = { screens: [], entities: [] },
|
||
selected = null,
|
||
layout = null,
|
||
dirty = false,
|
||
filter = "",
|
||
busy = false,
|
||
selectedTile = null;
|
||
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);
|
||
}
|
||
return response;
|
||
}
|
||
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;
|
||
selectedTile = null;
|
||
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();
|
||
renderSettings();
|
||
}
|
||
const settingDefinitions = [
|
||
["standby_enabled", "Automatisch standby", "check", true],
|
||
["standby_seconds", "Standby na", "minutes", 600, 1, 1440],
|
||
["brightness", "Helderheid normaal", "range", 100, 5, 100],
|
||
["standby_brightness", "Helderheid in standby", "range", 20, 0, 100],
|
||
["night_enabled", "Nachtstand gebruiken", "check", true],
|
||
["night_brightness", "Helderheid in nachtstand", "range", 10, 0, 100],
|
||
["night_start", "Nachtstand vanaf", "time", 1320],
|
||
["night_end", "Nachtstand tot", "time", 420],
|
||
["show_clock", "Klok tonen", "check", true],
|
||
["clock_24h", "24-uursklok (uit = 12 uur)", "check", true],
|
||
["home_on_standby", "Na standby terug naar pagina 1", "check", false],
|
||
["rotation", "Scherm draaien (met de klok mee)", "rotation", 0],
|
||
["swipe_pages", "Vegen tussen pagina’s (firmware 0.2.7+)", "check", false],
|
||
];
|
||
function renderSettings() {
|
||
const values = {
|
||
...Object.fromEntries(
|
||
settingDefinitions.map(([key, , , value]) => [key, value]),
|
||
),
|
||
...layout.settings,
|
||
};
|
||
const container = $("#settings-fields");
|
||
container.replaceChildren();
|
||
for (const [key, title, kind, , min, max] of settingDefinitions) {
|
||
if(key === "rotation" && inventory.screens.find(s => s.id === selected)?.board !== "guition") continue;
|
||
const label = node(
|
||
"label",
|
||
undefined,
|
||
`setting ${kind === "check" ? "setting-check" : ""}`,
|
||
);
|
||
const caption = node("span", title),
|
||
input = node(kind === "rotation" ? "select" : "input"),
|
||
output = node("output");
|
||
input.id = `setting-${key}`;
|
||
if(kind !== "rotation") input.type =
|
||
kind === "check" ? "checkbox" : kind === "minutes" ? "number" : kind;
|
||
input.setAttribute("aria-label", title);
|
||
input.required = kind === "minutes" || kind === "time";
|
||
if(kind === "rotation") {
|
||
for(const angle of [0,90,180,270]) { const option=node("option", `${angle}°`); option.value=angle; input.append(option); }
|
||
input.value=values[key];
|
||
}
|
||
else if (kind === "check") input.checked = values[key];
|
||
else if (kind === "time")
|
||
input.value = `${String(Math.floor(values[key] / 60)).padStart(2, "0")}:${String(values[key] % 60).padStart(2, "0")}`;
|
||
else {
|
||
input.min = min;
|
||
input.max = max;
|
||
input.step = 1;
|
||
input.value = kind === "minutes" ? values[key] / 60 : values[key];
|
||
}
|
||
const updateOutput = () =>
|
||
(output.textContent =
|
||
kind === "range"
|
||
? `${input.value}%`
|
||
: kind === "minutes"
|
||
? "minuten"
|
||
: "");
|
||
updateOutput();
|
||
input.oninput = () => {
|
||
if (!input.checkValidity() || input.value === "") return;
|
||
let value = kind === "check" ? input.checked : Number(input.value);
|
||
if (kind === "time") {
|
||
const [h, m] = input.value.split(":").map(Number);
|
||
value = h * 60 + m;
|
||
}
|
||
if (kind === "minutes") value *= 60;
|
||
layout.settings = { ...values, ...layout.settings, [key]: value };
|
||
// A lower normal brightness also lowers any higher standby levels.
|
||
if (key === "brightness")
|
||
for (const dim of ["standby_brightness", "night_brightness"]) {
|
||
layout.settings[dim] = Math.min(layout.settings[dim], value);
|
||
const other = $(`#setting-${dim}`);
|
||
other.value = layout.settings[dim];
|
||
other.parentElement.querySelector("output").textContent =
|
||
`${other.value}%`;
|
||
}
|
||
updateOutput();
|
||
markDirty();
|
||
};
|
||
label.append(caption, input, output);
|
||
container.append(label);
|
||
}
|
||
renderSettingsSupport();
|
||
}
|
||
function renderSettingsSupport() {
|
||
const version =
|
||
inventory.screens.find((s) => s.id === selected)?.firmware || "";
|
||
const parts = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(version);
|
||
const supported =
|
||
parts &&
|
||
(Number(parts[1]) > 0 ||
|
||
Number(parts[2]) > 1 ||
|
||
(Number(parts[2]) === 1 && Number(parts[3]) >= 2));
|
||
$("#settings-support").textContent = supported
|
||
? "Opslaan stuurt je instellingen direct naar dit scherm. Ze blijven ook na een herstart bewaard."
|
||
: "Eenmalig firmware 0.1.2 of nieuwer installeren via ESPHome. Je kunt de instellingen alvast bewaren; oudere firmware gebruikt ze nog niet.";
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
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();
|
||
}
|
||
const domains = {
|
||
light: ["Licht", "☀", "#ad7600", "#fff3d3"],
|
||
climate: ["Klimaat", "❄", "#c86620", "#ffebdc"],
|
||
vacuum: ["Stofzuiger", "◉", "#008577", "#def3ed"],
|
||
fan: ["Ventilator", "✣", "#008aab", "#def5fa"],
|
||
cover: ["Zonwering", "▤", "#8053af", "#eee5f8"],
|
||
media_player: ["Media", "▶", "#007cad", "#def2fc"],
|
||
sensor: ["Sensor", "⌁", "#3476b1", "#e5effa"],
|
||
binary_sensor: ["Status", "◈", "#ad7600", "#fff3d3"],
|
||
switch: ["Schakelaar", "⏻", "#ad7600", "#fff3d3"],
|
||
input_boolean: ["Schakelaar", "⏻", "#ad7600", "#fff3d3"],
|
||
scene: ["Scène", "✦", "#8053af", "#eee5f8"],
|
||
script: ["Script", "▷", "#8053af", "#eee5f8"],
|
||
weather: ["Weer", "☁", "#007cad", "#def2fc"],
|
||
number: ["Waarde", "±", "#008577", "#def3ed"],
|
||
input_number: ["Waarde", "±", "#008577", "#def3ed"],
|
||
select: ["Keuze", "≡", "#5862af", "#eaecfa"],
|
||
input_select: ["Keuze", "≡", "#5862af", "#eaecfa"],
|
||
button: ["Actie", "↗", "#5862af", "#eaecfa"],
|
||
screen: ["Klok", "◷", "#25282c", "#e9ecf1"],
|
||
sun: ["Zon", "☼", "#c86620", "#ffebdc"],
|
||
timer: ["Kookwekker", "⏱", "#008577", "#def3ed"],
|
||
person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"],
|
||
};
|
||
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok", sunpath: "zonnebaan" };
|
||
// New tiles start with the card that shows the entity best.
|
||
function defaultOptions(id) {
|
||
const domain = id.split(".")[0];
|
||
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
|
||
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
|
||
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
|
||
return {};
|
||
}
|
||
// Same packing as the firmware: wide tiles start in the left column and take a whole row.
|
||
function packTiles(tiles) {
|
||
let position = 0;
|
||
const placement = tiles.map((tile) => {
|
||
const wide = tile.options?.size === "wide";
|
||
if (wide && position % 2 === 1) position++;
|
||
const slot = { page: Math.floor(position / 6), slot: position % 6, wide };
|
||
position += wide ? 2 : 1;
|
||
return slot;
|
||
});
|
||
return { placement, pages: Math.max(1, Math.ceil(position / 6)) };
|
||
}
|
||
function supportsFirmware(major, minor, patch) {
|
||
const version = inventory.screens.find((s) => s.id === selected)?.firmware || "";
|
||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
||
if (!match) return false;
|
||
const [a, b, c] = match.slice(1).map(Number);
|
||
return a > major || (a === major && (b > minor || (b === minor && c >= patch)));
|
||
}
|
||
function domainBadge(id) {
|
||
const [title, symbol, color, background] = domains[id.split(".")[0]] || ["Entiteit", "◇", "#637184", "#edf0f4"];
|
||
const badge = node("span", symbol, "domain-icon");
|
||
badge.title = title;
|
||
badge.setAttribute("aria-label", title);
|
||
badge.style.color = color;
|
||
badge.style.background = background;
|
||
return badge;
|
||
}
|
||
function tileLimit() {
|
||
const version=inventory.screens.find(s=>s.id===selected)?.firmware || "";
|
||
const match=/^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
||
if(!match)return 10;
|
||
return Number(match[1])>0 || Number(match[2])>2 || Number(match[2])===2 && Number(match[3])>=7 ? 20 : 10;
|
||
}
|
||
function entityName(id) {
|
||
return inventory.entities.find((e) => e.id === id)?.name || inventory.builtin?.find((e) => e.id === id)?.name || id;
|
||
}
|
||
function renderPreview() {
|
||
const root = $("#layout-preview");
|
||
root.replaceChildren();
|
||
const { placement, pages } = packTiles(layout.tiles);
|
||
for (let page = 0; page < pages; page++) {
|
||
const frame = node("section", undefined, "screen-preview");
|
||
frame.append(node("small", `Pagina ${page + 1} · ${$("#title").value || "Thuis"}`, "preview-heading"));
|
||
const grid = node("div", undefined, "preview-grid");
|
||
let filled = 0;
|
||
layout.tiles.forEach((tile, index) => {
|
||
const place = placement[index];
|
||
if (place.page !== page) return;
|
||
// Keep the grid honest: an empty right column before a wide tile is a real gap on the screen.
|
||
for (; filled < place.slot; filled++) grid.append(node("span", "", "preview-gap"));
|
||
const card = node("button", undefined, "preview-tile");
|
||
const name = tile.name || entityName(tile.entity);
|
||
const background=inventory.backgrounds?.[tile.options?.background]?.color;
|
||
if(background)card.style.backgroundColor=background;
|
||
if (place.wide) card.classList.add("wide");
|
||
card.append(domainBadge(tile.entity), node("strong", name));
|
||
card.dataset.index = index;
|
||
enableDrag(card, { kind: "tile", index });
|
||
card.classList.toggle("chosen", selectedTile === tile.entity);
|
||
card.setAttribute("aria-label", `Tegel ${index + 1}: ${name}, instellen`);
|
||
card.onclick = () => {
|
||
selectedTile = tile.entity;
|
||
renderTiles();
|
||
document.querySelectorAll("#tiles > .tile")[index]?.scrollIntoView({behavior: "smooth", block: "center"});
|
||
};
|
||
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
|
||
const display = tile.options?.display;
|
||
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
|
||
grid.append(card);
|
||
filled += place.wide ? 2 : 1;
|
||
});
|
||
if (layout.tiles.length < tileLimit())
|
||
for (; filled < 6; filled++) {
|
||
const card = node("button", undefined, "preview-tile vacant");
|
||
card.append(node("span", "+"), node("small", "Tegel toevoegen"));
|
||
card.onclick = () => { $("#search").focus(); $("#search").scrollIntoView({behavior:"smooth", block:"center"}); };
|
||
grid.append(card);
|
||
}
|
||
frame.append(grid);
|
||
root.append(frame);
|
||
}
|
||
}
|
||
function renderTiles() {
|
||
renderPreview();
|
||
$("#tiles").replaceChildren();
|
||
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware voor 20":""}`;
|
||
$("#no-tiles").hidden = layout.tiles.length > 0;
|
||
const { placement } = packTiles(layout.tiles);
|
||
layout.tiles.forEach((tile, i) => {
|
||
if (i > 0 && placement[i].page !== placement[i - 1].page) $("#tiles").append(node("li", `Pagina ${placement[i].page + 1}`, "page-break"));
|
||
const li = node("li", undefined, "tile");
|
||
li.classList.toggle("selected-tile", selectedTile === tile.entity);
|
||
const content = node("div"),
|
||
input = node("input");
|
||
input.value = tile.name;
|
||
input.placeholder = entityName(tile.entity);
|
||
input.maxLength = 60;
|
||
input.setAttribute("aria-label", `Naam voor tegel ${i + 1}`);
|
||
input.oninput = () => {
|
||
tile.name = input.value;
|
||
markDirty();
|
||
renderPreview();
|
||
};
|
||
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);
|
||
}
|
||
const options = node("details", undefined, "tile-options");
|
||
options.append(node("summary", "Bediening & weergave instellen"));
|
||
options.open = selectedTile === tile.entity;
|
||
options.ontoggle = () => {
|
||
li.classList.toggle("selected-tile", options.open);
|
||
if (options.open) {
|
||
selectedTile = tile.entity;
|
||
renderPreview();
|
||
document.querySelectorAll(".tile-options").forEach((other) => {
|
||
if (other !== options) other.open = false;
|
||
});
|
||
}
|
||
};
|
||
li.onclick = (e) => {
|
||
if (!e.target.closest("input, select, button, summary, .tile-options"))
|
||
options.open = !options.open;
|
||
};
|
||
const palette = node("fieldset", undefined, "tile-palette");
|
||
palette.append(node("legend", "Pastel achtergrond"));
|
||
const swatches = node("div", undefined, "palette-swatches");
|
||
for(const [key, choice] of Object.entries(inventory.backgrounds || {})) {
|
||
const button=node("button", undefined, "palette-choice");
|
||
button.type="button";
|
||
button.setAttribute("aria-label", `Achtergrond: ${choice.label}`);
|
||
button.setAttribute("aria-pressed", String((tile.options?.background || "auto") === key));
|
||
const sample=node("span", undefined, "palette-sample");
|
||
if(choice.color)sample.style.backgroundColor=choice.color;
|
||
else sample.classList.add("palette-auto");
|
||
button.append(sample,node("span",choice.label));
|
||
button.onclick=()=>{
|
||
tile.options={...tile.options,background:key};
|
||
for(const other of swatches.children)other.setAttribute("aria-pressed",String(other===button));
|
||
markDirty();renderPreview();
|
||
};
|
||
swatches.append(button);
|
||
}
|
||
palette.append(swatches,node("small", "Donkere tekst blijft leesbaar. Kies Standaard voor de normale kleuren. Vereist schermfirmware 0.2.10+."));
|
||
options.append(palette);
|
||
const controls = {};
|
||
const domain = tile.entity.split(".")[0];
|
||
const displays = domain === "screen"
|
||
? [["digital", "Digitale klok"], ["analog", "Analoge klok"]]
|
||
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
|
||
if (domain === "weather") displays.push(["forecast", "Weersvoorspelling (dubbelbreed)"]);
|
||
if (domain === "sensor") displays.push(["graph", "Grafiek van de geschiedenis"]);
|
||
if (domain === "sun") displays.push(["sunpath", "Zonnebaan (dubbelbreed)"]);
|
||
const fields = [
|
||
[
|
||
"tap",
|
||
"Bij aantikken",
|
||
[
|
||
["auto", "Automatisch"],
|
||
["detail", "Bediening openen"],
|
||
["none", "Alleen bekijken"],
|
||
],
|
||
],
|
||
["display", "Weergave", displays],
|
||
["size", "Breedte (firmware 0.2.14+)", [["single", "Normaal"], ["wide", "Dubbelbreed"]]],
|
||
];
|
||
if (domain === "screen") fields.shift();
|
||
if (
|
||
["light", "switch", "input_boolean", "fan", "media_player"].includes(
|
||
domain,
|
||
)
|
||
)
|
||
fields[0][2].push(["toggle", "Aan / uit"]);
|
||
if (
|
||
[
|
||
"light",
|
||
"fan",
|
||
"cover",
|
||
"number",
|
||
"input_number",
|
||
"media_player",
|
||
].includes(domain)
|
||
)
|
||
fields.push([
|
||
"inline",
|
||
"Kleine slider op deze tegel?",
|
||
[
|
||
["none", "Nee"],
|
||
["slider", "Ja, direct bedienen"],
|
||
],
|
||
]);
|
||
if (domain === "sensor")
|
||
fields.push([
|
||
"history_hours",
|
||
"Geschiedenis",
|
||
[
|
||
[1, "1 uur"],
|
||
[6, "6 uur"],
|
||
[24, "24 uur"],
|
||
],
|
||
]);
|
||
for (const [key, title, choices] of fields) {
|
||
const label = node("label", title),
|
||
input = node("select");
|
||
for (const [value, text] of choices) {
|
||
const option = node("option", text);
|
||
option.value = value;
|
||
input.append(option);
|
||
}
|
||
input.value =
|
||
tile.options?.[key] ??
|
||
(key === "tap"
|
||
? "auto"
|
||
: key === "display"
|
||
? (domain === "screen" ? "digital" : "standard")
|
||
: key === "history_hours"
|
||
? 24
|
||
: key === "size"
|
||
? "single"
|
||
: "none");
|
||
controls[key] = input;
|
||
input.onchange = () => {
|
||
tile.options = {
|
||
...tile.options,
|
||
[key]: key === "history_hours" ? Number(input.value) : input.value,
|
||
};
|
||
if (key === "display" && input.value === "watch")
|
||
tile.options.inline = "none";
|
||
if (key === "display" && ["forecast", "sunpath"].includes(input.value))
|
||
tile.options.size = "wide";
|
||
if (key === "inline" && input.value === "slider")
|
||
tile.options.display = "standard";
|
||
for (const [field, control] of Object.entries(controls)) {
|
||
if (tile.options[field] !== undefined)
|
||
control.value = tile.options[field];
|
||
}
|
||
markDirty();
|
||
renderPreview();
|
||
};
|
||
label.append(input);
|
||
options.append(label);
|
||
}
|
||
if (domain !== "screen") {
|
||
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
|
||
inspectTile.onclick = () => inspect(tile.entity);
|
||
options.append(inspectTile);
|
||
}
|
||
content.append(options);
|
||
li.append(domainBadge(tile.entity), content, actions);
|
||
$("#tiles").append(li);
|
||
});
|
||
}
|
||
function addTile(id, at) {
|
||
if (layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit()) return;
|
||
layout.tiles.splice(Math.min(at, layout.tiles.length), 0, { entity: id, name: "", ...defaultOptions(id) });
|
||
selectedTile = id;
|
||
markDirty();
|
||
renderTiles();
|
||
renderResults();
|
||
}
|
||
// Pointer-based drag & drop: works with mouse and touch, from the picker into
|
||
// the mockup and between tiles. Touch starts after a short hold so the page
|
||
// still scrolls; a finished drag never doubles as a click.
|
||
const drag = { active: false, source: null, element: null, ghost: null, target: null, timer: 0, start: null, offset: null, pointerId: null, suppressUntil: 0, last: null, scroller: 0 };
|
||
function enableDrag(element, source) {
|
||
element.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0 || element.disabled) return;
|
||
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false });
|
||
clearTimeout(drag.timer);
|
||
if (e.pointerType === "touch") drag.timer = setTimeout(() => beginDrag(e), 260);
|
||
});
|
||
element.addEventListener("pointermove", (e) => {
|
||
if (!drag.start || drag.element !== element) return;
|
||
const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y);
|
||
if (!drag.active) {
|
||
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
|
||
if (distance < 6) return;
|
||
beginDrag(e);
|
||
}
|
||
moveDrag(e);
|
||
});
|
||
const finish = (e) => {
|
||
if (drag.element !== element) return;
|
||
clearTimeout(drag.timer);
|
||
if (drag.active) endDrag(e.type === "pointerup");
|
||
drag.start = null;
|
||
};
|
||
element.addEventListener("pointerup", finish);
|
||
element.addEventListener("pointercancel", finish);
|
||
}
|
||
function beginDrag(e) {
|
||
if (drag.active || !drag.start) return;
|
||
drag.active = true;
|
||
try { drag.element.setPointerCapture(drag.pointerId); } catch {}
|
||
const rect = drag.element.getBoundingClientRect();
|
||
const ghost = drag.element.cloneNode(true);
|
||
ghost.classList.add("drag-ghost");
|
||
ghost.style.width = `${rect.width}px`;
|
||
drag.offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
document.body.append(ghost);
|
||
drag.ghost = ghost;
|
||
document.body.classList.add("dragging");
|
||
document.addEventListener("touchmove", blockScroll, { passive: false });
|
||
// Near the viewport edges the page scrolls along, so the mockup can be reached on small screens.
|
||
drag.scroller = setInterval(() => {
|
||
if (!drag.last) return;
|
||
const step = drag.last.y < 70 ? -12 : drag.last.y > innerHeight - 70 ? 12 : 0;
|
||
if (step) { window.scrollBy(0, step); setTarget(document.elementFromPoint(drag.last.x, drag.last.y)?.closest(".preview-tile, .preview-gap, .preview-grid") || null); }
|
||
}, 16);
|
||
moveDrag(e);
|
||
}
|
||
function blockScroll(e) { if (drag.active) e.preventDefault(); }
|
||
function moveDrag(e) {
|
||
if (!drag.ghost) return;
|
||
drag.last = { x: e.clientX, y: e.clientY };
|
||
drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`;
|
||
const under = document.elementFromPoint(e.clientX, e.clientY);
|
||
setTarget(under?.closest(".preview-tile, .preview-gap, .preview-grid") || null);
|
||
}
|
||
function setTarget(target) {
|
||
if (drag.target === target) return;
|
||
drag.target?.classList.remove("drop-target");
|
||
drag.target = target;
|
||
drag.target?.classList.add("drop-target");
|
||
}
|
||
function endDrag(drop) {
|
||
const { target, source } = drag;
|
||
setTarget(null);
|
||
drag.ghost?.remove();
|
||
drag.ghost = null;
|
||
drag.active = false;
|
||
drag.suppressUntil = Date.now() + 400;
|
||
clearInterval(drag.scroller);
|
||
drag.last = null;
|
||
document.body.classList.remove("dragging");
|
||
document.removeEventListener("touchmove", blockScroll);
|
||
try { drag.element.releasePointerCapture(drag.pointerId); } catch {}
|
||
if (!drop || !target) return;
|
||
const to = target.dataset.index !== undefined ? Number(target.dataset.index) : layout.tiles.length;
|
||
if (source.kind === "tile") { if (to !== source.index) move(source.index, to); }
|
||
else addTile(source.id, to);
|
||
}
|
||
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
|
||
function renderResults() {
|
||
if (!layout) return;
|
||
const query = $("#search").value.toLocaleLowerCase();
|
||
const chosen = new Set(layout.tiles.map((t) => t.entity));
|
||
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
|
||
(e) =>
|
||
(!filter || e.id.startsWith(filter + ".") ||
|
||
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
|
||
`${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("strong", entity.name),
|
||
node("small", domains[entity.id.split(".")[0]]?.[0] || entity.id.split(".")[0], "domain-label"),
|
||
node("small", [entity.area, entity.device].filter(Boolean).join(" · ")),
|
||
node("small", entity.id),
|
||
);
|
||
b.append(
|
||
domainBadge(entity.id),
|
||
description,
|
||
node("span", chosen.has(entity.id) ? "✓" : "+", "plus"),
|
||
);
|
||
b.disabled = chosen.has(entity.id) || layout.tiles.length >= tileLimit();
|
||
b.onclick = () => addTile(entity.id, layout.tiles.length);
|
||
if (!b.disabled) enableDrag(b, { kind: "entity", id: entity.id });
|
||
$("#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",
|
||
),
|
||
);
|
||
}
|
||
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) renderSettingsSupport();
|
||
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
|
||
} catch {
|
||
$("#connection").textContent =
|
||
"Beheerpagina niet bereikbaar · opnieuw proberen…";
|
||
}
|
||
}
|
||
$("#save").onclick = async () => {
|
||
if (busy) return;
|
||
for (const input of document.querySelectorAll("#settings-fields input")) {
|
||
if (!input.reportValidity()) 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), (selectedTile = null));
|
||
$("#save").disabled = false;
|
||
}
|
||
};
|
||
$("#title").oninput = () => { markDirty(); renderPreview(); };
|
||
$("#search").oninput = renderResults;
|
||
$("#refresh").onclick = refresh;
|
||
for (const [value, label] of [
|
||
["", "Alles"],
|
||
["light", "Lampen"],
|
||
["climate", "Klimaat"],
|
||
["switch", "Schakelaars"],
|
||
["binary_sensor", "Status"],
|
||
["button", "Acties"],
|
||
["script", "Scripts"],
|
||
["fan", "Ventilatoren"],
|
||
["cover", "Zonwering"],
|
||
["scene", "Scènes"],
|
||
["vacuum", "Vacuum"],
|
||
["sensor", "Sensoren"],
|
||
["media_player", "Media"],
|
||
["weather", "Weer"],
|
||
["number", "Waarden"],
|
||
["select", "Keuzelijsten"],
|
||
["person", "Personen"],
|
||
["timer", "Kookwekkers"],
|
||
["screen", "Klok"],
|
||
]) {
|
||
const b = node("button", label, value === "" ? "active" : "");
|
||
if (value) b.prepend(domainBadge(value + "."));
|
||
b.onclick = () => {
|
||
filter = value;
|
||
$("#filters")
|
||
.querySelectorAll("button")
|
||
.forEach((n) => n.classList.remove("active"));
|
||
b.classList.add("active");
|
||
renderResults();
|
||
};
|
||
$("#filters").append(b);
|
||
}
|
||
async function checkWifiSecrets() {
|
||
const fields = $("#wifi-fields"), status = $("#wifi-status");
|
||
fields.hidden = true;
|
||
fields.disabled = true;
|
||
$("#create-profile").disabled = true;
|
||
status.textContent = "ESPHome wifi-instellingen controleren…";
|
||
try {
|
||
const {wifi} = await (await api("firmware")).json();
|
||
if (wifi?.state === "ready") {
|
||
status.textContent = "✓ Bestaande ESPHome-wifi gevonden. Dit scherm gebruikt automatisch wifi_ssid en wifi_password uit je secrets.yaml.";
|
||
$("#create-profile").disabled = false;
|
||
} else if (wifi?.state === "new") {
|
||
status.textContent = "Nog geen ESPHome-secrets gevonden. Vul wifi één keer in; volgende schermen gebruiken deze gegevens automatisch.";
|
||
fields.hidden = false;
|
||
fields.disabled = false;
|
||
$("#create-profile").disabled = false;
|
||
} else {
|
||
status.textContent = "Controleer wifi_ssid en wifi_password in ESPHome secrets.yaml. Je bestaande bestand blijft behouden. Sluit en open deze wizard opnieuw na aanpassen.";
|
||
}
|
||
} catch {
|
||
status.textContent = "ESPHome-secrets konden niet worden gecontroleerd. Sluit en open de wizard opnieuw om het nogmaals te proberen.";
|
||
}
|
||
}
|
||
$("#new-screen").onclick = $("#start").onclick = () => {
|
||
$("#installer").showModal();
|
||
checkWifiSecrets();
|
||
};
|
||
$("#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);
|
||
|
||
// Shared firmware workspace; always select a concrete profile and upload target.
|
||
let firmwarePoll;
|
||
async function firmwareRefresh(initial = false) {
|
||
try {
|
||
const data = await (await api("firmware")).json();
|
||
if (initial) {
|
||
$("#firmware-file").replaceChildren(
|
||
...data.profiles.map((p) => {
|
||
const o = node("option", p.file);
|
||
o.value = p.file;
|
||
return o;
|
||
}),
|
||
);
|
||
$("#firmware-port").replaceChildren(
|
||
...[["ota", "Wifi / OTA"], ...data.ports.map((p) => [p, p])].map(
|
||
([value, text]) => {
|
||
const o = node("option", text);
|
||
o.value = value;
|
||
return o;
|
||
},
|
||
),
|
||
);
|
||
}
|
||
const running = data.job?.state === "running";
|
||
for (const id of ["validate", "build", "install"])
|
||
$("#firmware-" + id).disabled =
|
||
running || !data.available || !data.profiles.length;
|
||
$("#firmware-status").textContent = !data.available
|
||
? "ESPHome CLI ontbreekt. Werk de app bij naar 0.2.0."
|
||
: data.job
|
||
? `${data.job.file} · ${data.job.action} · ${data.job.state}`
|
||
: "Kies het bedoelde profiel en een USB-poort of IP-adres.";
|
||
$("#firmware-log").textContent = data.logs.join("\n");
|
||
} catch (e) {
|
||
toast(e.message);
|
||
}
|
||
}
|
||
$("#open-firmware").onclick = () => {
|
||
$("#firmware-dialog").showModal();
|
||
firmwareRefresh(true);
|
||
clearInterval(firmwarePoll);
|
||
firmwarePoll = setInterval(() => firmwareRefresh(), 3000);
|
||
};
|
||
$("#close-firmware").onclick = () => {
|
||
$("#firmware-dialog").close();
|
||
clearInterval(firmwarePoll);
|
||
};
|
||
$("#firmware-dialog").addEventListener("close", () =>
|
||
clearInterval(firmwarePoll),
|
||
);
|
||
$("#firmware-port").onchange = () =>
|
||
($("#firmware-host-label").hidden = $("#firmware-port").value !== "ota");
|
||
$("#firmware-file").onchange = () => {
|
||
$("#firmware-host").placeholder = $("#firmware-file").value.replace(
|
||
/\.yaml$/,
|
||
".local",
|
||
);
|
||
};
|
||
for (const action of ["validate", "build", "install"])
|
||
$("#firmware-" + action).onclick = async () => {
|
||
try {
|
||
await api("firmware/jobs", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
file: $("#firmware-file").value,
|
||
action:
|
||
action === "validate"
|
||
? "validate"
|
||
: action === "build"
|
||
? "build"
|
||
: "install",
|
||
target:
|
||
$("#firmware-port").value === "ota"
|
||
? $("#firmware-host").value.trim()
|
||
: $("#firmware-port").value,
|
||
}),
|
||
});
|
||
await firmwareRefresh();
|
||
} catch (e) {
|
||
toast(e.message);
|
||
}
|
||
};
|
||
$("#create-profile").onclick = async () => {
|
||
const form = $("#install-form");
|
||
if (!form.reportValidity()) return;
|
||
try {
|
||
const data = await (
|
||
await api("firmware/profiles", {
|
||
method: "POST",
|
||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||
})
|
||
).json();
|
||
generatedName = data.file.replace(/\.yaml$/, " ").trim();
|
||
$("#yaml-output").value = data.yaml;
|
||
$("#yaml-result").hidden = false;
|
||
$("#install-status").textContent =
|
||
`${data.file} bewaard. Open Firmware & USB om te installeren.`;
|
||
form.elements.wifi_password.value = "";
|
||
} catch (e) {
|
||
toast(e.message);
|
||
}
|
||
};
|
||
function openSection(id) {
|
||
const section = $(id);
|
||
if (section.tagName === "DETAILS") section.open = true;
|
||
section.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
$("#nav-tiles").onclick = () => openSection("#tile-section");
|
||
$("#open-help").onclick = () => openSection("#help");
|
||
$("#nav-settings").onclick = () => openSection("#general-settings");
|
||
$("#nav-inspector").onclick = () => inspect();
|
||
async function inspect(entity) {
|
||
if (!selected) return;
|
||
openSection("#inspector-section");
|
||
$("#inspection-summary").textContent = "Actuele HA-status ophalen...";
|
||
try {
|
||
const data = await (
|
||
await api(`screens/${encodeURIComponent(selected)}/inspect`)
|
||
).json();
|
||
const tiles = entity
|
||
? data.tiles.filter((t) => t.entity === entity)
|
||
: data.tiles;
|
||
$("#inspection-summary").replaceChildren();
|
||
for (const tile of tiles) {
|
||
const card = node("article", undefined, "inspection-tile");
|
||
card.append(
|
||
node("strong", tile.entity),
|
||
node("p", `Status: ${tile.state}`),
|
||
);
|
||
const options =
|
||
layout.tiles.find((t) => t.entity === tile.entity)?.options || {};
|
||
card.append(
|
||
node(
|
||
"small",
|
||
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${displayNames[options.display || "standard"] || options.display} · Breedte: ${options.size === "wide" ? "dubbel" : "normaal"} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
|
||
),
|
||
);
|
||
$("#inspection-summary").append(card);
|
||
}
|
||
$("#inspection").textContent = JSON.stringify(
|
||
entity ? tiles[0] : data,
|
||
null,
|
||
2,
|
||
);
|
||
} catch (e) {
|
||
$("#inspection-summary").textContent = e.message;
|
||
toast(e.message);
|
||
}
|
||
}
|
||
$("#inspect").onclick = () => inspect();
|