Dashboard Studio 1.10.0: phone dashboards, room card, changelog
- Phone dashboards: a 4-column layout designed at phone width, with conversion both ways; on phones the top bar is hidden and comes back with a touch-and-hold on an empty spot - Room card built from Home Assistant areas (area, device and entity registries): climate, lights on with an all-lights toggle, thermostat, open doors and windows, motion, media, and device buttons - CHANGELOG.md for Home Assistant's Changelog tab, served at /api/changelog and shown under About > What's new Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+112
-4
@@ -30,6 +30,7 @@
|
||||
this.connected = false;
|
||||
this.onChange = () => {};
|
||||
this.onStatus = () => {};
|
||||
this.onRegistry = () => {};
|
||||
this.ready = new Promise((r) => (this._markReady = r));
|
||||
}
|
||||
connect() {
|
||||
@@ -38,6 +39,7 @@
|
||||
const ws = (this.ws = new WebSocket(url));
|
||||
ws.onmessage = (ev) => this.handle(JSON.parse(ev.data));
|
||||
ws.onclose = () => {
|
||||
this._reg = null;
|
||||
if (this.connected) this.ready = new Promise((r) => (this._markReady = r));
|
||||
this.connected = false;
|
||||
this.onStatus(false);
|
||||
@@ -65,6 +67,7 @@
|
||||
});
|
||||
this.connected = true;
|
||||
this._markReady();
|
||||
this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card
|
||||
this.onStatus(true);
|
||||
this.onChange(null);
|
||||
return;
|
||||
@@ -101,6 +104,25 @@
|
||||
});
|
||||
return res?.response || {};
|
||||
}
|
||||
/** Areas (rooms) and which entities are in them, from the area, entity and device registries. */
|
||||
registry() {
|
||||
this._reg ||= Promise.all([
|
||||
this.send({ type: "config/area_registry/list" }),
|
||||
this.send({ type: "config/entity_registry/list" }),
|
||||
this.send({ type: "config/device_registry/list" }),
|
||||
]).then(([areas, ents, devs]) => {
|
||||
const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id]));
|
||||
const byArea = {};
|
||||
for (const e of ents || []) {
|
||||
if (e.disabled_by || e.hidden_by || e.entity_category) continue; // skip config/diagnostic and hidden entities
|
||||
const area = e.area_id || devArea[e.device_id];
|
||||
if (area) (byArea[area] ||= []).push(e.entity_id);
|
||||
}
|
||||
this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea };
|
||||
return this.reg;
|
||||
}).catch((err) => { this._reg = null; throw err; });
|
||||
return this._reg;
|
||||
}
|
||||
/** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */
|
||||
async forecast(entityId, type = "daily") {
|
||||
await this.ready;
|
||||
@@ -205,7 +227,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|afvalwijzer|washer|dryer|dishwasher|remaining|progress/.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|vacuum|robot|filter|cleaning|cleaned/.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 }));
|
||||
@@ -358,7 +380,8 @@
|
||||
const pad = window.innerWidth < 560 ? 12 : 20;
|
||||
const avail = canvasEl.clientWidth - pad * 2;
|
||||
const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail;
|
||||
const compact = !editMode && width < 560;
|
||||
// Phone dashboards are drawn exactly as designed; others reflow into two columns on narrow screens.
|
||||
const compact = !editMode && width < 560 && !current.mobile;
|
||||
const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12);
|
||||
const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0;
|
||||
const rh = Number(g.rowHeight) || 72;
|
||||
@@ -483,8 +506,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
const phoneScreen = matchMedia("(max-width: 600px)");
|
||||
function renderGrid() {
|
||||
if (!current) return;
|
||||
document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.matches);
|
||||
renderNav();
|
||||
applyTheme();
|
||||
computeMetrics();
|
||||
@@ -633,6 +658,11 @@
|
||||
${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")}
|
||||
<input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`;
|
||||
}
|
||||
case "area": {
|
||||
const areas = ha.reg?.areas;
|
||||
if (!areas) return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="muted small">${tr("Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.")}</div></div>`;
|
||||
return `<div class="field">${label}<select class="input" id="${id}" ${data}><option value="">${tr("Choose a room")}</option>${areas.map((a) => `<option value="${esc(a.area_id)}" ${a.area_id === value ? "selected" : ""}>${esc(a.name)}</option>`).join("")}</select></div>`;
|
||||
}
|
||||
case "pos9": // a 3×3 grid for placing content in the card
|
||||
return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="pos9" role="group" aria-labelledby="${id}">
|
||||
${f.o.map((o) => `<button type="button" ${data} data-v="${o}" aria-pressed="${o === value}" aria-label="${esc(optLabel(o))}" title="${esc(optLabel(o))}"><i></i></button>`).join("")}</div></div>`;
|
||||
@@ -731,6 +761,8 @@
|
||||
${fieldHTML({ k: "blur", t: "number", l: "Blur behind tiles (px)", ph: "For photo backgrounds" }, t.blur, "theme")}
|
||||
</div>
|
||||
<div class="section"><div class="section-title">${tr("Grid")}</div>
|
||||
${fieldHTML({ k: "mobile", t: "check", l: "Designed for phones" }, !!current.mobile, "layout")}
|
||||
<p class="muted small" style="margin:-4px 2px 12px">${tr("A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.")}</p>
|
||||
<div class="row">${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div>
|
||||
<div class="row">${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}</div></div>
|
||||
<div class="section"><div class="section-title">${tr("Kiosk")}</div>
|
||||
@@ -746,7 +778,8 @@
|
||||
<div class="section"><div class="section-title">${tr("About")}</div>
|
||||
<div class="about"><img src="static/favicon.png?v=${esc(APP_VERSION)}" alt="" width="40" height="40">
|
||||
<div><div style="font-weight:600">Dashboard Studio</div><div class="muted small">${tr("Version {0}", esc(APP_VERSION))}</div></div></div>
|
||||
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="width:100%;margin-top:10px;text-decoration:none">${Icons.svg("globe")}${tr("Source code and changes")}</a>
|
||||
<div class="insp-actions"><button class="btn" data-whatsnew>${Icons.svg("sparkles")}${tr("What’s new")}</button>
|
||||
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="text-decoration:none">${Icons.svg("globe")}${tr("Source code")}</a></div>
|
||||
</div>
|
||||
<p class="hint">${tr("Select a widget to edit it.")}</p>`;
|
||||
}
|
||||
@@ -846,6 +879,7 @@
|
||||
if (isChange || t.tagName === "BUTTON") renderInspector();
|
||||
return;
|
||||
}
|
||||
if (path === "layout") { convertLayout(!!v); renderInspector(); return; }
|
||||
if (path === "nav") {
|
||||
mutate(() => { current.nav[k] = v; });
|
||||
return;
|
||||
@@ -946,6 +980,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch a dashboard between the 12-column layout and the 4-column phone layout, converting every tab. */
|
||||
function convertLayout(toMobile) {
|
||||
if (!!current.mobile === toMobile) return;
|
||||
mutate(() => {
|
||||
const from = Number(current.grid.cols) || 12, to = toMobile ? 4 : 12, f = to / from;
|
||||
current.mobile = toMobile;
|
||||
current.grid.cols = to;
|
||||
current.grid.maxWidth = toMobile ? 440 : 1440;
|
||||
for (const t of current.tabs) {
|
||||
const ws = [...t.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
for (const w of ws) {
|
||||
if (toMobile) { // phone: small tiles become half width, anything a third wide or more full width
|
||||
w.w = w.w >= from / 3 ? 4 : 2;
|
||||
w.x = w.w === 4 ? 0 : w.x * f >= 1.5 ? 2 : 0;
|
||||
} else {
|
||||
w.w = Math.max(1, Math.min(to, Math.round(w.w * f)));
|
||||
w.x = Math.min(to - w.w, Math.round(w.x * f));
|
||||
}
|
||||
}
|
||||
const placed = []; // re-pack top-down so nothing overlaps
|
||||
for (const w of ws) { while (placed.some((o) => overlap(w, o))) w.y++; placed.push(w); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findSpot(w, h, widgets = tab().widgets) {
|
||||
const cols = current.grid.cols;
|
||||
for (let y = 0; y < 500; y++)
|
||||
@@ -956,7 +1015,7 @@
|
||||
|
||||
function addWidget(type) {
|
||||
const wd = makeWidget(type);
|
||||
wd.w = Math.min(wd.w, current.grid.cols);
|
||||
wd.w = current.mobile ? (wd.w >= 3 ? 4 : 2) : Math.min(wd.w, current.grid.cols);
|
||||
Object.assign(wd, findSpot(wd.w, wd.h));
|
||||
mutate(() => tab().widgets.push(wd));
|
||||
select(wd.id);
|
||||
@@ -1086,6 +1145,7 @@
|
||||
const track = a.closest(".hs").querySelector(".hs-track");
|
||||
track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" });
|
||||
}
|
||||
else if (act === "svc-many") { const [d, sv] = a.dataset.v.split("."); ha.callService(d, sv, {}, a.dataset.ids.split(",")); }
|
||||
else if (act === "photo-next") {
|
||||
const c = caches[a.closest(".w").dataset.id];
|
||||
(c?.bg?.next || c?.next)?.(); // the clock keeps its slideshow under .bg
|
||||
@@ -1217,6 +1277,28 @@
|
||||
card.removeEventListener("change", update);
|
||||
}
|
||||
|
||||
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
|
||||
async function whatsNew() {
|
||||
let md = "";
|
||||
try { md = await fetch(new URL("api/changelog", BASE)).then((r) => r.text()); } catch { /* shown as empty below */ }
|
||||
const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/\*(.+?)\*/g, "<i>$1</i>");
|
||||
let html = "", inList = false;
|
||||
for (const line of md.split("\n")) {
|
||||
if (/^- /.test(line)) { if (!inList) { html += "<ul>"; inList = true; } html += `<li>${inline(line.slice(2))}</li>`; continue; }
|
||||
if (inList) { html += "</ul>"; inList = false; }
|
||||
if (/^## /.test(line)) html += `<h4>${inline(line.slice(3))}${line.slice(3).trim() === APP_VERSION ? ` <span class="muted small">· ${tr("this version")}</span>` : ""}</h4>`;
|
||||
}
|
||||
if (inList) html += "</ul>";
|
||||
const card = $("#modal .modal-card");
|
||||
const done = openModal(`<h3>${tr("What’s new")}</h3><div class="changelog">${html || `<p>${tr("The changelog couldn’t be loaded.")}</p>`}</div>
|
||||
<div class="modal-actions"><button class="btn primary" data-x="close">${tr("Close")}</button></div>`, (e, close) => { if (e.target.closest("[data-x]")) close(); });
|
||||
card.classList.add("wide");
|
||||
card.querySelector(".changelog").scrollIntoView({ block: "start" }); // start at the newest version, not at the Close button
|
||||
card.scrollTop = 0;
|
||||
await done;
|
||||
card.classList.remove("wide");
|
||||
}
|
||||
|
||||
function toast(msg, err) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast" + (err ? " err" : "");
|
||||
@@ -1324,6 +1406,25 @@
|
||||
document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); });
|
||||
|
||||
// ------------------------------------------------------------ phone dashboards
|
||||
// The top bar is hidden; touching and holding an empty spot brings it back for a while.
|
||||
let peekTimer = 0, holdTimer2 = 0;
|
||||
canvasEl.addEventListener("pointerdown", (e) => {
|
||||
if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return;
|
||||
holdTimer2 = setTimeout(() => {
|
||||
document.body.classList.add("peek");
|
||||
clearTimeout(peekTimer);
|
||||
peekTimer = setTimeout(() => document.body.classList.remove("peek"), 10000);
|
||||
navigator.vibrate?.(10);
|
||||
}, 600);
|
||||
});
|
||||
for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); });
|
||||
function phoneHint() {
|
||||
if (!document.body.classList.contains("phone-ui") || lsGet("ds.peekHint")) return;
|
||||
lsSet("ds.peekHint", "1");
|
||||
toast(tr("Touch and hold an empty spot to show the menu."));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ chrome wiring
|
||||
function setCurrent(id) {
|
||||
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
|
||||
@@ -1453,6 +1554,7 @@
|
||||
if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; }
|
||||
if (childBtn) { childOp(childBtn.dataset.childOp, Number(childBtn.dataset.i)); return; }
|
||||
if (e.target.closest("[data-child-back]")) { childIndex = null; renderInspector(); return; }
|
||||
if (e.target.closest("[data-whatsnew]")) { whatsNew(); 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);
|
||||
@@ -1560,6 +1662,7 @@
|
||||
}
|
||||
setInterval(checkVersion, 5 * 60 * 1000);
|
||||
ha.onChange = onStateChange;
|
||||
ha.onRegistry = () => { renderGrid(); if (editMode) renderInspector(); };
|
||||
|
||||
/** Translate the static page: elements marked data-i18n, plus every aria-label and title. */
|
||||
function translateStatic() {
|
||||
@@ -1597,6 +1700,11 @@
|
||||
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
|
||||
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
|
||||
if (location.hash.includes("kiosk") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
|
||||
setTimeout(phoneHint, 1500);
|
||||
// After an update, point at what changed (once per device).
|
||||
const seen = lsGet("ds.seenVersion");
|
||||
if (APP_VERSION !== "dev" && seen && seen !== APP_VERSION) setTimeout(() => toast(tr("Updated to {0}. See what’s new under About in the settings.", APP_VERSION)), 2000);
|
||||
if (APP_VERSION !== "dev") lsSet("ds.seenVersion", APP_VERSION);
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in New Issue
Block a user