Dashboard Studio 1.5.0: conditional widgets, appliance card, multi-player media
- Visibility conditions on every widget (is / is not / above / below, all or any); rows that only held hidden widgets close up in view mode - Appliance card for washer, dryer and dishwasher: status or power based, time left, ready time, progress and program; hides when off - Media card controls several players with a switcher and album art - Dutch translations and docs for the new features Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+110
-5
@@ -175,7 +175,7 @@
|
||||
const lights = first("light", 3);
|
||||
if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] });
|
||||
|
||||
const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging/.test(e)).slice(0, 2);
|
||||
const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress/.test(e)).slice(0, 2);
|
||||
sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e }));
|
||||
const tiles = [...lights.slice(1, 2), ...first("switch", 1)];
|
||||
tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e }));
|
||||
@@ -197,6 +197,22 @@
|
||||
if (sw.length) put("list", carLock ? 6 : 8, y, carLock ? 6 : 4, 3, { title: tr("Quick controls"), entities: sw });
|
||||
const graphSensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && ha.states[e].attributes.device_class === "temperature").slice(0, 2);
|
||||
if (graphSensors.length) put("graph", 0, carLock ? y + 3 : y, carLock ? 12 : 8, 3, { entities: graphSensors, title: tr("Temperature, last 24 hours") });
|
||||
const players = first("media_player", 4);
|
||||
const appliances = [["washer", /(washer|washing_machine|wasmachine)_(status|state|operation_state|program_state)$/, "Washing machine"],
|
||||
["dryer", /(dryer|droger|wasdroger)_(status|state|operation_state|program_state)$/, "Dryer"],
|
||||
["dishwasher", /(dishwasher|vaatwasser|vaatwasmachine)_(status|state|operation_state|program_state)$/, "Dishwasher"]]
|
||||
.map(([kind, re, name]) => ({ kind, name, status: ids.find((e) => domainOf(e) === "sensor" && re.test(e)) })).filter((a) => a.status);
|
||||
if (players.length || appliances.length) {
|
||||
let y2 = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)), x2 = 0;
|
||||
if (players.length) { put("media", 0, y2, 4, 3, { entities: players }); x2 = 4; }
|
||||
for (const a of appliances) {
|
||||
const base = a.status.replace(/_(status|state|operation_state|program_state)$/, "");
|
||||
const pick = (...suffixes) => suffixes.map((sfx) => `${base}_${sfx}`).find((e) => ha.states[e]) || "";
|
||||
if (x2 + 4 > 12) { x2 = 0; y2 += 3; }
|
||||
put("appliance", x2, y2, 4, 2, { name: tr(a.name), kind: a.kind, status: a.status, remaining: pick("remaining_time", "time_remaining", "remaining", "end_time", "finish_time"), progress: pick("progress", "program_progress"), program: pick("program", "selected_program", "active_program"), power: pick("power") });
|
||||
x2 += 4;
|
||||
}
|
||||
}
|
||||
if (ids.some((e) => e.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in ha.states[e].attributes)) {
|
||||
const bottom = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h));
|
||||
put("waste", 0, bottom, 4, 4, {});
|
||||
@@ -302,13 +318,40 @@
|
||||
}
|
||||
|
||||
// Compact widths: two columns in reading order. Small widgets take one column, the rest the full width.
|
||||
// ------------------------------------------------------------ visibility
|
||||
// A widget can carry conditions ("show only when the washer is running"); widgets may
|
||||
// also hide themselves (autoHide). Editing always shows everything.
|
||||
function ruleMatches(r) {
|
||||
const st = String(ha.states[r.entity]?.state ?? "unavailable").toLowerCase();
|
||||
const v = String(r.value ?? "").trim().toLowerCase();
|
||||
switch (r.op) {
|
||||
case "is not": return st !== v;
|
||||
case "above": return Number(st) > Number(v);
|
||||
case "below": return Number(st) < Number(v);
|
||||
default: return st === v;
|
||||
}
|
||||
}
|
||||
function isVisible(w) {
|
||||
if (Widgets[w.type]?.autoHide?.(w.cfg, ctxFor(w))) return false;
|
||||
const rules = (w.visibility?.rules || []).filter((r) => r.entity);
|
||||
if (!rules.length) return true;
|
||||
return w.visibility.mode === "any" ? rules.some(ruleMatches) : rules.every(ruleMatches);
|
||||
}
|
||||
const ruleEntities = (w) => (w.visibility?.rules || []).map((r) => r.entity).filter(Boolean);
|
||||
let lastVisible = {};
|
||||
|
||||
function positions() {
|
||||
const map = {};
|
||||
const shown = editMode ? tab().widgets : tab().widgets.filter(isVisible);
|
||||
if (!metrics.compact) {
|
||||
for (const w of tab().widgets) map[w.id] = w;
|
||||
if (editMode) { for (const w of shown) map[w.id] = w; return map; }
|
||||
// Rows that only hidden widgets used close up, so nothing leaves a hole.
|
||||
const rows = (list) => { const set = new Set(); for (const w of list) for (let r = w.y; r < w.y + w.h; r++) set.add(r); return set; };
|
||||
const vis = rows(shown), gone = [...rows(tab().widgets.filter((w) => !shown.includes(w)))].filter((r) => !vis.has(r));
|
||||
for (const w of shown) map[w.id] = { x: w.x, y: w.y - gone.filter((r) => r < w.y).length, w: w.w, h: w.h };
|
||||
return map;
|
||||
}
|
||||
const sorted = [...tab().widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const sorted = [...shown].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const small = (w) => w.w <= Math.floor(current.grid.cols / 4) && w.type !== "heading";
|
||||
let y = 0, halfOpen = null;
|
||||
for (const w of sorted) {
|
||||
@@ -395,7 +438,10 @@
|
||||
computeMetrics();
|
||||
const pos = positions();
|
||||
gridEl.innerHTML = "";
|
||||
lastVisible = {};
|
||||
for (const w of tab().widgets) {
|
||||
lastVisible[w.id] = isVisible(w);
|
||||
if (!pos[w.id]) continue;
|
||||
const el = document.createElement("div");
|
||||
el.className = "w";
|
||||
el.dataset.id = w.id;
|
||||
@@ -430,7 +476,10 @@
|
||||
}
|
||||
let html;
|
||||
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
|
||||
const hiddenNow = editMode && !isVisible(w);
|
||||
el.classList.toggle("cond-hidden", hiddenNow);
|
||||
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
|
||||
(hiddenNow ? `<div class="w-hidden">${Icons.svg("eye-off")}${tr("Hidden now")}</div>` : "") +
|
||||
(editMode ? `<div class="w-tools"><button data-tool="dup" aria-label="${tr("Duplicate")}" title="${tr("Duplicate")}">${Icons.svg("copy")}</button><button data-tool="del" class="del" aria-label="${tr("Delete")}" title="${tr("Delete")}">${Icons.svg("trash")}</button></div><div class="w-resize" aria-hidden="true"></div>` : "");
|
||||
if (editMode) el.querySelectorAll(".w-body button, .w-body input, .w-body iframe").forEach((n) => n.setAttribute("tabindex", "-1"));
|
||||
if (def.after) Promise.resolve(def.after(el, w.cfg, ctx)).catch(console.error);
|
||||
@@ -447,8 +496,9 @@
|
||||
function onStateChange(entityId) {
|
||||
if (!current) return;
|
||||
if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; }
|
||||
for (const w of tab().widgets) {
|
||||
if (!entitiesOf(w).includes(entityId)) continue;
|
||||
const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId));
|
||||
if (affected.some((w) => isVisible(w) !== lastVisible[w.id])) { renderGrid(); return; }
|
||||
for (const w of affected) {
|
||||
const el = gridEl.querySelector(`.w[data-id="${w.id}"]`);
|
||||
if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag
|
||||
const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null;
|
||||
@@ -565,6 +615,7 @@
|
||||
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
|
||||
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
|
||||
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}</div>
|
||||
${visibilityHTML(w)}
|
||||
<div class="insp-actions"><button class="btn" data-tool="dup">${Icons.svg("copy")}${tr("Duplicate")}</button><button class="btn danger" data-tool="del">${Icons.svg("trash")}${tr("Delete")}</button></div>`;
|
||||
fillAlbums(el);
|
||||
return;
|
||||
@@ -633,6 +684,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
function visibilityHTML(w) {
|
||||
const vis = w.visibility || { mode: "all", rules: [] };
|
||||
const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"];
|
||||
const rows = vis.rules.map((r, i) => {
|
||||
const s = ha.states[r.entity];
|
||||
const opts = [...new Set([s?.state, ...common].filter(Boolean))].map((o) => `<option value="${esc(o)}">`).join("");
|
||||
return `<div class="rule">
|
||||
<input class="input" type="text" list="dl-all" data-path="rule" data-idx="${i}" data-k="entity" value="${esc(r.entity || "")}" placeholder="${tr("Entity")}" aria-label="${tr("Entity")}" autocomplete="off" spellcheck="false">
|
||||
<div class="rule-row">
|
||||
<select class="input" data-path="rule" data-idx="${i}" data-k="op" aria-label="${tr("Condition")}">${["is", "is not", "above", "below"].map((o) => `<option value="${o}" ${o === (r.op || "is") ? "selected" : ""}>${esc(optLabel(o))}</option>`).join("")}</select>
|
||||
<input class="input" type="text" list="dl-rule-${i}" data-path="rule" data-idx="${i}" data-k="value" value="${esc(r.value ?? "")}" placeholder="${tr("Value")}" aria-label="${tr("Value")}" autocomplete="off">
|
||||
<datalist id="dl-rule-${i}">${opts}</datalist>
|
||||
<button class="btn icon" data-rule-del="${i}" aria-label="${tr("Remove condition")}">${Icons.svg("close")}</button>
|
||||
</div>
|
||||
${s ? `<div class="ent-hint">${tr("Now")}: ${esc(s.state)}</div>` : ""}
|
||||
</div>`;
|
||||
}).join("");
|
||||
const status = vis.rules.some((r) => r.entity) || Widgets[w.type]?.autoHide
|
||||
? `<p class="small vis-status">${isVisible(w) ? Icons.svg("eye") + tr("Visible right now") : Icons.svg("eye-off") + tr("Hidden right now")}</p>` : "";
|
||||
return `<div class="section"><div class="section-title">${tr("Visibility")}</div>
|
||||
<p class="muted small" style="margin:0 2px 10px">${tr("Show this widget only when the conditions below are met. Leave empty to always show it.")}</p>
|
||||
${vis.rules.length > 1 ? fieldHTML({ k: "mode", t: "seg", l: "Show when", o: ["all", "any"] }, vis.mode || "all", "vis") : ""}
|
||||
${rows}${status}
|
||||
<button class="btn" data-rule-add style="width:100%">${Icons.svg("plus")}${tr("Add condition")}</button></div>`;
|
||||
}
|
||||
|
||||
function applyField(t, v, isChange) {
|
||||
const path = t.dataset.path, k = t.dataset.k, idx = t.dataset.idx;
|
||||
if (path === "tab") {
|
||||
@@ -642,6 +719,16 @@
|
||||
if (isChange && k === "icon") renderInspector();
|
||||
return;
|
||||
}
|
||||
if (path === "vis" || path === "rule") {
|
||||
const w = tab().widgets.find((x) => x.id === selectedId);
|
||||
mutate(() => {
|
||||
w.visibility ||= { mode: "all", rules: [] };
|
||||
if (path === "vis") w.visibility[k] = v;
|
||||
else w.visibility.rules[Number(t.dataset.idx)][k] = v;
|
||||
}, `${w.id}|${path}|${t.dataset.idx}|${k}`);
|
||||
if (isChange || t.tagName === "BUTTON") renderInspector();
|
||||
return;
|
||||
}
|
||||
if (path === "nav") {
|
||||
mutate(() => { current.nav[k] = v; });
|
||||
return;
|
||||
@@ -867,6 +954,12 @@
|
||||
const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10;
|
||||
ha.callService("climate", "set_temperature", { temperature: t }, eid);
|
||||
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
|
||||
else if (act === "mp-select") {
|
||||
const w = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id);
|
||||
caches[w.id].sel = a.dataset.v;
|
||||
renderWidget(w);
|
||||
gridEl.querySelector(`.w[data-id="${w.id}"] [data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus();
|
||||
}
|
||||
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
|
||||
else if (act === "button") {
|
||||
const c = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id).cfg;
|
||||
@@ -1179,6 +1272,18 @@
|
||||
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
|
||||
renderWidget(w); renderInspector(); return;
|
||||
}
|
||||
const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]");
|
||||
if (ruleAdd || ruleDel) {
|
||||
const w = tab().widgets.find((x) => x.id === selectedId);
|
||||
mutate(() => {
|
||||
w.visibility ||= { mode: "all", rules: [] };
|
||||
if (ruleAdd) w.visibility.rules.push({ entity: "", op: "is", value: "" });
|
||||
else w.visibility.rules.splice(Number(ruleDel.dataset.ruleDel), 1);
|
||||
});
|
||||
renderInspector();
|
||||
if (ruleAdd) $(`#inspector [data-path="rule"][data-idx="${w.visibility.rules.length - 1}"][data-k="entity"]`)?.focus();
|
||||
return;
|
||||
}
|
||||
const op = e.target.closest("[data-tabop]");
|
||||
if (op) { tabOp(op.dataset.tabop, op.dataset.tabId); return; }
|
||||
const k = e.target.closest("[data-kiosk]");
|
||||
|
||||
Reference in New Issue
Block a user