Dashboard Studio 2.6.0 and Haven 2.3.0: easier to use on a phone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 2.6.0
|
||||
|
||||
Easier to use on a phone, in the browser and in Haven (now version 2.3.0, offered as an update in the app).
|
||||
|
||||
- **Back closes what's open:** Android's back gesture in Haven, and the browser's back button, now close an open sheet or the top bar instead of leaving the app.
|
||||
- **Haven finds your Home Assistant:** when you set it up or add a home, Haven lists the Home Assistant it finds on your home network. Tap it instead of typing the address.
|
||||
- **Haven keeps only homes that work:** an address that doesn't connect is no longer saved as a home; it is filled in again so you can correct it. Removing a home asks first, in the app itself.
|
||||
- **Haven says what it's doing:** the loading screen says which home it is connecting to, and after a while offers to choose another home or address.
|
||||
- **A phone opens a phone dashboard:** on a phone, Dashboard Studio opens the dashboard you last used on that phone, or else the first one designed for phones; and always on the dashboard, not the builder.
|
||||
- **Connecting, not offline:** while it first connects, the top bar says *Connecting…* instead of a red *Offline*.
|
||||
- **The ⋯ button steps aside:** on a phone dashboard, the round button in the corner moves out of the way while you scroll down, so it doesn't cover a card, and comes back when you scroll up.
|
||||
- **Friendlier details:** tapping a sensor or device shows what it is now in words, a line of the last 24 hours for sensors, and buttons for blinds, media players and vacuums. The entity ID and attributes are folded away under *Technical details*.
|
||||
- Weather reads *Partly cloudy* instead of *Partlycloudy* (and the same for a few other states).
|
||||
- Fixed: on a lit lamp tile in the phone sizes, the text was white on a pale lamp colour.
|
||||
|
||||
## 2.5.0
|
||||
|
||||
- **Phone sizes:** on a phone dashboard every card now comes in four sizes, like widgets on a phone: **Small** (half width, square), **Medium** (full width, two rows), **Large** (full width, four rows) and **Row** (full width, one line). Pick one under **Size on the phone** in the inspector, or drag the corner: it snaps to the nearest size.
|
||||
|
||||
@@ -43,10 +43,10 @@ Under **Address for the link**, use the address of Home Assistant on your home n
|
||||
Haven shows the dashboards and nothing else: no builder and no Admin screen, for admins too.
|
||||
|
||||
1. On the phone, scan the QR code under **Admin → Haven app** (or tap **Download Haven** on the phone itself), open the file and allow installing apps from that source when Android asks (once).
|
||||
2. Open Haven and enter the address you use for Home Assistant. On the phone itself, **Open in Haven** fills it in.
|
||||
2. Open Haven and tap your Home Assistant under **On this network** (Haven finds it on your home Wi-Fi), or enter the address you use for it. On the phone itself, **Open in Haven** fills it in.
|
||||
3. Sign in with your own Home Assistant login, once.
|
||||
|
||||
From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant: tap the phone button in the top bar (admins) or the kiosk corner five times (everyone else), then **Change address**. Haven needs Android 7 or newer. The earlier *Dashboard Studio* Android app is replaced by Haven; you can uninstall it.
|
||||
From then on Haven opens straight on your dashboard, full screen with its own icon, and keeps you signed in. It works with plain http addresses too. Kiosk mode keeps the screen on and hides Android's bars. To add another home, such as a holiday home, or switch between them: tap the phone button in the top bar (admins) or the kiosk corner five times (everyone else), then **Homes and addresses**. An address that doesn't connect isn't kept. If connecting takes long, the loading screen offers to choose another home or address. Android's back gesture closes an open sheet first; with nothing open, it puts Haven in the background. Haven needs Android 7 or newer. The earlier *Dashboard Studio* Android app is replaced by Haven; you can uninstall it.
|
||||
|
||||
- **Several homes:** under **Change address**, **Add another home** connects Haven to another Home Assistant, such as a holiday home or your parents'. Each home keeps its own sign-in; tap a home to open it, × removes it.
|
||||
- **Home screen widgets:** long-press your home screen, choose **Widgets → Haven**, and pick a light, switch, scene or thermostat. A tap does what it does on the dashboard; a thermostat gets − and + for its temperature.
|
||||
@@ -297,6 +297,9 @@ On a phone, such a dashboard fills the whole screen, without the top bar with th
|
||||
|
||||
### On phones
|
||||
|
||||
- **Which dashboard opens:** on a phone, Dashboard Studio opens the dashboard last used on that phone, or else the first one designed for phones (a link, the device's own dashboard or the person's dashboard still come first). It always opens on the dashboard, not the builder.
|
||||
- **Details:** tap a sensor or device for its details: what it is now, a line of the last 24 hours for sensors, and its buttons. The entity ID and attributes are under *Technical details*. Back (the gesture, or the browser's button) closes the sheet.
|
||||
|
||||
- **Install as an app:** on Android, use **Haven** (see above). The phone button in the top bar explains the other options: browsers can install Dashboard Studio as a web app when Home Assistant is opened over **https** (open Dashboard Studio in its own tab first); on iPhone and iPad, apps on the home screen can't use Home Assistant's sign-in, so use the sidebar in the Home Assistant app.
|
||||
- **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. When Dashboard Studio is installed from the browser on Android, long-press its icon to run one straight away (up to four shortcuts; Haven and iPhones don't support this).
|
||||
- **Offline:** each device keeps the last known states of its dashboard. When the connection drops, the dashboard keeps showing them with a small note saying from when, and catches up when it reconnects.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Dashboard Studio
|
||||
version: "2.5.0"
|
||||
version: "2.6.0"
|
||||
slug: dashboard_studio
|
||||
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
|
||||
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
||||
|
||||
+88
-17
@@ -301,8 +301,8 @@
|
||||
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
|
||||
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
|
||||
if (IN_APP) {
|
||||
body = p(tr("This is Haven, connected to {0}.", location.host));
|
||||
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
|
||||
body = p(tr("This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.", location.host));
|
||||
actions += `<button class="btn" data-x="reset">${tr("Homes and addresses")}</button>`;
|
||||
} else if (android) {
|
||||
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: Haven always works.
|
||||
body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
|
||||
@@ -325,7 +325,7 @@
|
||||
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
|
||||
}
|
||||
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
|
||||
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
|
||||
const choice = await openModal(`<h3>${IN_APP ? "Haven" : tr("Install as an app")}</h3>${body}
|
||||
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
|
||||
<div class="modal-actions">${actions}</div>`,
|
||||
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
|
||||
@@ -2238,15 +2238,34 @@
|
||||
|
||||
// ------------------------------------------------------------ dialogs
|
||||
let modalClose = null;
|
||||
// An open sheet is a step in the history, so going back (Android's back gesture in Haven, the browser's
|
||||
// back button) closes it instead of leaving the app.
|
||||
let modalInHistory = false, closingByBack = false, ownBacks = 0;
|
||||
window.addEventListener("popstate", () => {
|
||||
if (ownBacks) { ownBacks--; return; } // the step back a closing sheet took itself (another may have opened since)
|
||||
if (!modalInHistory) return;
|
||||
modalInHistory = false;
|
||||
closingByBack = true;
|
||||
modalClose?.(null);
|
||||
closingByBack = false;
|
||||
});
|
||||
/** Haven's back gesture asks first: close what is open here (a sheet, the top bar) and say so. */
|
||||
window.havenBack = () => {
|
||||
if (modalClose) { modalClose(null); return true; }
|
||||
if (document.body.classList.contains("peek")) { document.body.classList.remove("peek"); return true; }
|
||||
return false;
|
||||
};
|
||||
function openModal(html, onClick) {
|
||||
const card = $("#modal .modal-card");
|
||||
card.innerHTML = html;
|
||||
$("#modal").hidden = false;
|
||||
if (!modalInHistory) { history.pushState({ dsSheet: true }, "", location.href); modalInHistory = true; }
|
||||
const prevFocus = document.activeElement;
|
||||
return new Promise((resolve) => {
|
||||
modalClose = (result) => {
|
||||
$("#modal").hidden = true;
|
||||
modalClose = null;
|
||||
if (modalInHistory && !closingByBack) { modalInHistory = false; ownBacks++; history.back(); }
|
||||
prevFocus?.focus?.();
|
||||
resolve(result);
|
||||
};
|
||||
@@ -2467,23 +2486,56 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** The details of one entity: what it is now in words, a day's history for a sensor, the controls,
|
||||
* and the technical details (its ID and attributes) folded away for whoever needs them. */
|
||||
function moreInfo(eid) {
|
||||
const s = ha.states[eid];
|
||||
if (!s) return;
|
||||
if (domainOf(eid) === "light") { lightPopup(eid); return; }
|
||||
if (domainOf(eid) === "climate") { climatePopup(eid); return; }
|
||||
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(domainOf(eid));
|
||||
const actionLabel = domainOf(eid) === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(domainOf(eid)) ? tr("Run") : isOn(s) ? tr("Turn off") : tr("Turn on");
|
||||
openModal(`<h3>${esc(friendly(s))}</h3><div class="muted small">${esc(eid)}</div>
|
||||
<div class="num" style="font-size:34px;font-weight:700;margin:14px 0 2px">${esc(fmtState(s))}</div>
|
||||
const d = domainOf(eid);
|
||||
if (d === "light") { lightPopup(eid); return; }
|
||||
if (d === "climate") { climatePopup(eid); return; }
|
||||
const toggle = window.HAFmt.TOGGLE_DOMAINS.has(d) && !["climate", "vacuum", "media_player", "cover"].includes(d);
|
||||
const actionLabel = d === "lock" ? (s.state === "locked" ? tr("Unlock") : tr("Lock")) : ["scene", "script"].includes(d) ? tr("Run") : ["button", "input_button"].includes(d) ? tr("Press") : isOn(s) ? tr("Turn off") : tr("Turn on");
|
||||
const numeric = ["sensor", "input_number", "number", "counter"].includes(d) && s.state !== "" && isFinite(Number(s.state));
|
||||
// a few domains get their own buttons
|
||||
const svc = (service, ico, label) => `<button class="btn" data-svc="${service}">${Icons.svg(ico)}${esc(label)}</button>`;
|
||||
const extra = d === "cover" ? svc("cover.open_cover", "arrow-up", tr("Open")) + svc("cover.stop_cover", "stop", tr("Stop")) + svc("cover.close_cover", "arrow-down", tr("Close"))
|
||||
: d === "media_player" ? svc("media_player.media_previous_track", "prev", tr("Previous track")) + svc("media_player.media_play_pause", s.state === "playing" ? "pause" : "play", s.state === "playing" ? tr("Pause") : tr("Play")) + svc("media_player.media_next_track", "next", tr("Next track"))
|
||||
: d === "vacuum" ? svc(s.state === "cleaning" ? "vacuum.pause" : "vacuum.start", s.state === "cleaning" ? "pause" : "play", s.state === "cleaning" ? tr("Pause") : tr("Start")) + svc("vacuum.return_to_base", "home", tr("Dock")) : "";
|
||||
const attrs = Object.entries(s.attributes).filter(([k]) => !["friendly_name", "icon", "entity_picture", "supported_features"].includes(k));
|
||||
const done = openModal(`<div class="mi-head"><span class="mi-ico">${window.HAFmt.icon(s)}</span><h3>${esc(friendly(s))}</h3></div>
|
||||
<div class="mi-state num">${esc(fmtState(s))}</div>
|
||||
<div class="muted small">${esc(tr("Changed {0}", relTime(s.last_changed)))}</div>
|
||||
<table class="attr-table">${Object.entries(s.attributes).map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table>
|
||||
${numeric ? `<div class="mi-graph" aria-label="${esc(tr("The last 24 hours"))}"><div class="muted small">${tr("Loading history…")}</div></div>` : ""}
|
||||
${extra ? `<div class="mi-actions">${extra}</div>` : ""}
|
||||
<details class="mi-more"><summary>${tr("Technical details")}</summary>
|
||||
<table class="attr-table"><tr><td>${tr("Entity ID")}</td><td>${esc(eid)}</td></tr>${attrs.map(([k, v]) => `<tr><td>${esc(cap(k))}</td><td>${esc(typeof v === "object" ? JSON.stringify(v) : v)}</td></tr>`).join("")}</table></details>
|
||||
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button>${toggle ? `<button class="btn primary" data-x="toggle">${actionLabel}</button>` : ""}</div>`,
|
||||
(e, close) => {
|
||||
const x = e.target.closest("[data-x]")?.dataset.x;
|
||||
if (x === "toggle") defaultAction(eid);
|
||||
if (x) close();
|
||||
const b = e.target.closest("[data-x], [data-svc]");
|
||||
if (!b) return;
|
||||
if (b.dataset.svc) { const [dom, service] = b.dataset.svc.split("."); ha.callService(dom, service, {}, eid); haptic(); return; }
|
||||
if (b.dataset.x === "toggle") defaultAction(eid);
|
||||
close();
|
||||
});
|
||||
if (numeric) drawDetailGraph(eid, done);
|
||||
}
|
||||
|
||||
/** A day of a sensor's history as a line, with its lowest and highest value. */
|
||||
async function drawDetailGraph(eid, done) {
|
||||
let pts = [];
|
||||
try { pts = ((await ha.history([eid], 24))[eid] || []).filter(([, v]) => isFinite(v)); } catch { /* shown as unavailable */ }
|
||||
const box = $("#modal:not([hidden]) .mi-graph");
|
||||
if (!box) return;
|
||||
if (pts.length < 2) { box.innerHTML = `<div class="muted small">${tr("No history for the last 24 hours.")}</div>`; return; }
|
||||
const W_ = 320, H = 90, t0 = Date.now() - 24 * 3600e3, t1 = Date.now();
|
||||
const vs = pts.map(([, v]) => v), lo = Math.min(...vs), hi = Math.max(...vs), span = hi - lo || 1;
|
||||
const x = (t) => ((Math.max(t, t0) - t0) / (t1 - t0)) * W_, y = (v) => H - 6 - ((v - lo) / span) * (H - 12);
|
||||
const line = pts.map(([t, v], i) => `${i ? "L" : "M"}${x(t).toFixed(1)} ${y(v).toFixed(1)}`).join("") + `L${W_} ${y(vs[vs.length - 1]).toFixed(1)}`;
|
||||
const unit = ha.states[eid]?.attributes.unit_of_measurement || "";
|
||||
const f = (v) => `${Number(v).toLocaleString(LOCALE, { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ""}`;
|
||||
box.innerHTML = `<svg viewBox="0 0 ${W_} ${H}" preserveAspectRatio="none" aria-hidden="true"><path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>
|
||||
<div class="mi-graph-legend muted small"><span>${tr("24 hours ago")}</span><span>${esc(tr("Low {0} · high {1}", f(lo), f(hi)))}</span><span>${tr("Now")}</span></div>`;
|
||||
}
|
||||
|
||||
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
|
||||
@@ -2599,7 +2651,7 @@
|
||||
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
|
||||
const choice = await openModal(lockedKiosk
|
||||
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
|
||||
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
|
||||
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Homes and addresses")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
|
||||
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
|
||||
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
|
||||
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
|
||||
@@ -3003,11 +3055,23 @@
|
||||
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
|
||||
}
|
||||
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
|
||||
// Scrolling down, the ⋯ button steps aside so it doesn't sit on a card; scrolling up brings it back.
|
||||
let lastScrollY = 0;
|
||||
document.addEventListener("scroll", (e) => {
|
||||
if (!document.body.classList.contains("phone-ui")) return;
|
||||
const el = e.target === document ? document.scrollingElement : e.target;
|
||||
if (!el || el.closest?.(".w, #modal")) return; // a list inside a card, or a sheet: not the page
|
||||
const y = el.scrollTop || 0;
|
||||
if (Math.abs(y - lastScrollY) < 6) return;
|
||||
document.body.classList.toggle("menu-away", y > lastScrollY && y > 40);
|
||||
lastScrollY = y;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
// ------------------------------------------------------------ chrome wiring
|
||||
function setCurrent(id) {
|
||||
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
|
||||
lsSet("ds.current", current.id);
|
||||
if (phoneScreen.matches) lsSet("ds.currentPhone", current.id);
|
||||
activeTabId = lsGet(`ds.tab.${current.id}`);
|
||||
guestOn = false; guestCache = null; awayState = null; awayFrom = null;
|
||||
undoStack.length = redoStack.length = 0;
|
||||
@@ -3721,10 +3785,14 @@
|
||||
document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
|
||||
}
|
||||
|
||||
let everConnected = false;
|
||||
ha.onStatus = (ok) => {
|
||||
const c = $("#conn");
|
||||
if (ok) everConnected = true;
|
||||
c.classList.toggle("ok", ok);
|
||||
c.textContent = ok ? tr("Connected") : offlineText();
|
||||
// the first moments after opening are connecting, not offline
|
||||
c.classList.toggle("connecting", !ok && !everConnected && !lastLive);
|
||||
c.textContent = ok ? tr("Connected") : !everConnected && !lastLive ? tr("Connecting…") : offlineText();
|
||||
showOffline(!ok);
|
||||
// An update restarts the app, which drops the connection: check right after reconnecting.
|
||||
if (ok) checkVersion();
|
||||
@@ -3824,9 +3892,12 @@
|
||||
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
|
||||
// The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
|
||||
const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
|
||||
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
|
||||
// the builder is for bigger screens: a phone always opens on the dashboard
|
||||
mode = isAdmin() && !IN_APP && !phoneScreen.matches ? lastMode : "dashboard";
|
||||
editMode = mode === "builder";
|
||||
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
|
||||
// On a phone: the one last used on this phone, else the first dashboard designed for phones.
|
||||
const phone = phoneScreen.matches ? [lsGet("ds.currentPhone"), store.dashboards.find((d) => d.mobile)?.id] : [];
|
||||
setCurrent([hashId, dev?.dashboard, home, ...phone, lsGet("ds.current")].find(exists));
|
||||
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
|
||||
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
|
||||
else setMode(mode);
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
{"version": "2.2.0", "code": 2}
|
||||
{"version": "2.3.0", "code": 3}
|
||||
|
||||
@@ -303,6 +303,10 @@
|
||||
"Emptying": "Aan het legen", "Levelling": "Aan het egaliseren", "Sanitizing": "Aan het ontsmetten", "Sleeping": "Slaapt", "Ready": "Klaar voor gebruik",
|
||||
"Bin full": "Afvalbak vol", "Check the litter box": "Kijk even bij de kattenbak", "Cat inside": "Kat in de bak", "Needs attention": "Heeft aandacht nodig",
|
||||
"visits today": "bezoekjes vandaag", "visits": "bezoekjes", "visit": "bezoekje",
|
||||
"Connecting…": "Verbinden…", "Technical details": "Technische details", "Entity ID": "Entiteit-ID", "The last 24 hours": "De afgelopen 24 uur",
|
||||
"No history for the last 24 hours.": "Geen geschiedenis van de afgelopen 24 uur.", "24 hours ago": "24 uur geleden", "Low {0} · high {1}": "Laagst {0} · hoogst {1}",
|
||||
"Homes and addresses": "Huizen en adressen",
|
||||
"This is Haven, connected to {0}. You can add another home, such as a holiday home, or switch between them.": "Dit is Haven, verbonden met {0}. Je kunt nog een huis toevoegen, zoals een vakantiehuis, of tussen huizen wisselen.",
|
||||
"Size on the phone": "Formaat op de telefoon", "Small": "Klein", "Medium": "Middel", "Now {0}°": "Nu {0}°", "Nobody home": "Niemand thuis",
|
||||
"Tap to watch": "Tik om te kijken", "Returning": "Terug naar het dock", "Sun {0}": "Zon {0}", "Grid {0}": "Net {0}", "Sending back {0}": "Teruglevering {0}",
|
||||
"{0} open": "{0} open", "Nothing on the way": "Niets onderweg", "Partly open": "Half open", "{0} of {1} open": "{0} van {1} open", "Nothing planned": "Niets gepland", "Only show when the bin is full or something is wrong": "Alleen tonen als de afvalbak vol is of er iets mis is", "Clean": "Schoonmaken", "Auto": "Auto", "in the box today": "in de bak vandaag", "cat’s weight": "gewicht kat", "Clean now": "Nu schoonmaken", "Auto clean": "Automatisch schoonmaken",
|
||||
@@ -811,6 +815,12 @@
|
||||
armed_home: "Ingeschakeld (thuis)", armed_night: "Ingeschakeld (nacht)", disarmed: "Uitgeschakeld", triggered: "Geactiveerd",
|
||||
};
|
||||
|
||||
// States whose names don't read well as they are ("partlycloudy", "not_home").
|
||||
const STATES_EN = {
|
||||
not_home: "Away", partlycloudy: "Partly cloudy", "clear-night": "Clear night", "lightning-rainy": "Thunder and rain", "snowy-rainy": "Sleet",
|
||||
"windy-variant": "Windy", heat_cool: "Heat and cool", fan_only: "Fan only", above_horizon: "Above the horizon", below_horizon: "Below the horizon",
|
||||
armed_away: "Armed (away)", armed_home: "Armed (home)", armed_night: "Armed (night)", armed_vacation: "Armed (vacation)", disarmed: "Disarmed",
|
||||
};
|
||||
const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
||||
function detect() {
|
||||
const pref = read("ds.lang");
|
||||
@@ -837,7 +847,7 @@
|
||||
return args.length ? s.replace(/\{(\d)\}/g, (_, i) => args[i] ?? "") : s;
|
||||
},
|
||||
/** A Home Assistant state as readable text. */
|
||||
state: (st) => (lang === "nl" && STATES_NL[st]) || cap(st),
|
||||
state: (st) => (lang === "nl" ? STATES_NL[st] : STATES_EN[st]) || cap(st),
|
||||
};
|
||||
document.documentElement.lang = lang;
|
||||
})();
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<button id="btn-new" class="btn icon" aria-label="New dashboard" title="New dashboard"></button>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<span id="conn" class="conn" role="status" data-i18n>Offline</span>
|
||||
<span id="conn" class="conn connecting" role="status" data-i18n>Connecting…</span>
|
||||
<span id="save-status" class="save-status edit-only" role="status"></span>
|
||||
<span class="edit-only tb-group">
|
||||
<button id="btn-undo" class="btn icon" aria-label="Undo" title="Undo (⌘Z)"></button>
|
||||
|
||||
@@ -125,6 +125,10 @@ h2, h3 { margin: 0; }
|
||||
.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--crit); padding: 0 6px; }
|
||||
.conn::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.conn.ok { display: none; }
|
||||
.conn.connecting { color: var(--text-2); }
|
||||
.conn.connecting::before { animation: conn-pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes conn-pulse { 50% { opacity: .3; } }
|
||||
@media (prefers-reduced-motion: reduce) { .conn.connecting::before { animation: none; } }
|
||||
.save-status { font-size: 13px; color: var(--text-2); padding: 0 6px; white-space: nowrap; }
|
||||
|
||||
.menu {
|
||||
@@ -1215,6 +1219,8 @@ body.phone-ui:not(.peek) #topbar { display: none; }
|
||||
background: rgba(120, 120, 128, .28); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, .15); }
|
||||
#phone-menu svg { width: 20px; height: 20px; stroke-width: 2.6; }
|
||||
body.phone-ui:not(.peek):not(.kiosk) #phone-menu { display: grid; }
|
||||
#phone-menu { transition: opacity .2s, transform .2s; }
|
||||
body.menu-away #phone-menu { opacity: 0; transform: translateY(-12px); pointer-events: none; }
|
||||
/* the install button in the top bar is for phones and tablets; computers have it in the menu */
|
||||
@media (pointer: fine) { #btn-app { display: none; } }
|
||||
#btn-app[hidden] { display: none; }
|
||||
@@ -2142,3 +2148,19 @@ button.fader-input { cursor: pointer; }
|
||||
.phc-m:not(:has(.phc-bottom)) { justify-content: center; }
|
||||
.phc-m:not(:has(.phc-bottom)) .phc-name { font-size: 20px; }
|
||||
.phc-l:not(:has(.phc-val)) .phc-name { font-size: 24px; }
|
||||
|
||||
/* the details sheet: the state in words, a day's history, the controls; the technical details folded away */
|
||||
.mi-head { display: flex; align-items: center; gap: 10px; }
|
||||
.mi-head h3 { margin: 0; }
|
||||
.mi-ico { display: flex; font-size: 22px; color: var(--text-2); }
|
||||
.mi-state { font-size: 34px; font-weight: 400; letter-spacing: -0.02em; margin: 14px 0 2px; }
|
||||
.mi-graph { margin-top: 14px; }
|
||||
.mi-graph svg { display: block; width: 100%; height: 90px; }
|
||||
.mi-graph-legend { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; }
|
||||
.mi-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.mi-actions .btn { flex: 1; min-width: 0; justify-content: center; }
|
||||
.mi-more { margin-top: 16px; }
|
||||
.mi-more summary { cursor: pointer; font-size: 13px; color: var(--text-2); padding: 6px 0; }
|
||||
.mi-more .attr-table { margin-top: 4px; }
|
||||
/* lit tiles keep the lamp's dark text */
|
||||
.phc { color: inherit; }
|
||||
|
||||
Reference in New Issue
Block a user