Files
ha-dashboard-studio/dashboard_studio/web/app.js
T
veenm 297fb2a7a0 Dashboard Studio 1.14.1: Android app, phone menu button, install fix
- Android app (android/, built with build.sh): signs in to Home Assistant once, opens Dashboard Studio full screen, renews the ingress session; downloadable from Install as an app
- Phone dashboards: a round menu button brings back the top bar with Edit, Kiosk and Install as an app
- Install as an app explains what works per device (Android app, https for browser install, iPhone limits)
- Fixed: browser install failed because the manifest was fetched without the Home Assistant sign-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:34:52 +01:00

3322 lines
199 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Dashboard Studio editor + runtime. */
(function () {
const { esc, domainOf, isOn, friendly, fmtState, relTime, clockHTML, cap } = window.HAFmt;
const Widgets = window.Widgets;
const Icons = window.Icons;
const tr = window.I18n.t, LOCALE = window.I18n.locale;
// Filled in by the server; "dev" when index.html is opened some other way.
const APP_VERSION = (document.querySelector('meta[name="ds-version"]')?.content || "dev").replace("__VERSION__", "dev");
const REPO_URL = "https://git.melvanveen.nl/veenm/ha-dashboard-studio";
/** Option labels: a direct translation when there is one, else the capitalised value. */
const optLabel = (o) => (tr(o) !== o ? tr(o) : tr(cap(String(o).replace("-", " "))));
const $ = (s, el = document) => el.querySelector(s);
const uid = () => Math.random().toString(36).slice(2, 10);
const clone = (o) => JSON.parse(JSON.stringify(o));
// Ingress serves us under /api/hassio_ingress/<token>/ — keep every URL relative to that.
const BASE = new URL(location.pathname.endsWith("/") ? location.pathname : location.pathname + "/", location.origin);
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } };
const prefersDark = () => matchMedia("(prefers-color-scheme: dark)").matches;
const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
// ------------------------------------------------------------ HA connection
class HA {
constructor() {
this.states = {};
this.nextId = 1;
this.pending = new Map();
this.connected = false;
this.onChange = () => {};
this.onStatus = () => {};
this.onRegistry = () => {};
this.ready = new Promise((r) => (this._markReady = r));
}
connect() {
const url = new URL("ws", BASE);
url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
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);
for (const p of this.pending.values()) p.reject?.(new Error("disconnected"));
this.pending.clear();
setTimeout(() => this.connect(), 3000);
};
}
send(msg, onEvent) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject, onEvent });
this.ws.send(JSON.stringify({ ...msg, id }));
});
}
async handle(m) {
if (m.type === "ready") {
const states = await this.send({ type: "get_states" });
this.states = Object.fromEntries(states.map((s) => [s.entity_id, s]));
this.send({ type: "subscribe_events", event_type: "state_changed" }, (ev) => {
if (ev.event_type && ev.event_type !== "state_changed") return;
const { entity_id, new_state } = ev.data;
if (new_state) this.states[entity_id] = new_state;
else delete this.states[entity_id];
this.onChange(entity_id);
});
this.connected = true;
this._markReady();
this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card
// Home Assistant's notifications, kept current for the notifications card.
this.send({ type: "persistent_notification/subscribe" }, (ev) => {
if (ev.type === "current") this.notifications = { ...ev.notifications };
else if (ev.type === "removed") for (const id of Object.keys(ev.notifications || {})) delete this.notifications[id];
else this.notifications = { ...(this.notifications || {}), ...ev.notifications };
this.onChange("__notifications");
}).catch(() => {});
// Announcements: an automation fires this event to put a message on the screens.
this.send({ type: "subscribe_events", event_type: "dashboard_studio_announce" }, (ev) => {
if (ev.event_type === "dashboard_studio_announce") this.onAnnounce?.(ev.data || {});
}).catch(() => {});
this.onStatus(true);
this.onChange(null);
return;
}
if (m.type === "error" && m.id == null) {
// The server closes and we reconnect every few seconds; don't repeat the same message.
if (m.message !== this.lastError || Date.now() - this.lastErrorAt > 60000) toast(m.message, true);
this.lastError = m.message; this.lastErrorAt = Date.now();
return;
}
const p = this.pending.get(m.id);
if (!p) return;
if (m.type === "event") { p.onEvent?.(m.event); return; }
if (m.type === "result") {
if (!p.onEvent) this.pending.delete(m.id);
if (m.success) p.resolve(m.result);
else p.reject(new Error(m.error?.message || "Request failed"));
}
}
callService(domain, service, data = {}, entityId) {
if (!this.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return Promise.resolve(); }
const msg = { type: "call_service", domain, service, service_data: data };
if (entityId) msg.target = { entity_id: entityId };
return this.send(msg).catch((e) => toast(tr("{0} failed: {1}", `${domain}.${service}`, e.message), true));
}
/** Events from calendar entities, via calendar.get_events with return_response. */
async calendarEvents(entityIds, start, end) {
await this.ready;
const res = await this.send({
type: "call_service", domain: "calendar", service: "get_events",
target: { entity_id: entityIds },
service_data: { start_date_time: start.toISOString(), end_date_time: end.toISOString() },
return_response: true,
});
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 = {};
const deviceOf = {}, byDevice = {};
for (const e of ents || []) {
if (e.disabled_by) continue;
if (e.device_id) { deviceOf[e.entity_id] = e.device_id; (byDevice[e.device_id] ||= []).push(e.entity_id); }
if (e.hidden_by || e.entity_category) continue; // rooms 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, deviceOf, byDevice };
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;
const res = await this.send({
type: "call_service", domain: "weather", service: "get_forecasts",
target: { entity_id: entityId }, service_data: { type }, return_response: true,
});
return res?.response?.[entityId]?.forecast || [];
}
/** Daily totals of a meter from the long-term statistics, oldest first, today last. */
async statistics(entityId, days) {
await this.ready;
const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - (days - 1));
const res = await this.send({ type: "recorder/statistics_during_period", start_time: start.toISOString(), statistic_ids: [entityId], period: "day", types: ["change"] });
return (res?.[entityId] || []).map((r) => ({ start: typeof r.start === "number" ? r.start : new Date(r.start).getTime(), change: r.change }));
}
/** Forecast.Solar's expected production per hour, per configured plane. */
async solarForecast() {
await this.ready;
return this.send({ type: "energy/solar_forecast" });
}
async history(entityIds, hours) {
await this.ready;
const res = await this.send({
type: "history/history_during_period",
start_time: new Date(Date.now() - hours * 3600e3).toISOString(),
entity_ids: entityIds,
minimal_response: true,
no_attributes: true,
significant_changes_only: false,
});
const out = {};
for (const [eid, rows] of Object.entries(res || {})) {
out[eid] = rows.map((r) => [(r.lu ?? r.lc ?? 0) * 1000, Number(r.s)]);
}
return out;
}
}
const ha = new HA();
// ------------------------------------------------------------ themes
// "System" keeps the adaptive light/dark tokens from style.css.
const COLOR_KEYS = ["bg", "tile", "text", "text2", "accent", "onAccent", "separator", "fill", "lit"];
const PRESETS = {
System: { swatch: ["#f2f2f7", "#1c1c1e"] },
Light: { bg: "#f2f2f7", tile: "#ffffff", text: "#1d1d1f", text2: "#6e6e73", accent: "#0066cc", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", shadow: true, blur: 0 },
Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 },
Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 },
Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 },
Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", onAccent: "#0b1f14", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 },
// Text on tiles is 5.3:1 or better in every theme; bright accents get dark text (onAccent).
Graphite: { bg: "#18181b", tile: "#27272a", text: "#fafafa", text2: "#a1a1aa", accent: "#f59e0b", onAccent: "#1c1204", separator: "rgba(250,250,250,0.1)", fill: "rgba(250,250,250,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Indigo: { bg: "#0b1020", tile: "#151c33", text: "#eef1ff", text2: "#a7b0d6", accent: "#8aa4ff", onAccent: "#0b1020", separator: "rgba(238,241,255,0.1)", fill: "rgba(238,241,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Rose: { bg: "#1d1216", tile: "#2c1d23", text: "#fdf2f5", text2: "#d4b3bf", accent: "#ff7aa6", onAccent: "#2a0a15", separator: "rgba(253,242,245,0.1)", fill: "rgba(253,242,245,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 },
Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 },
Lavender: { bg: "#f3f0fa", tile: "#ffffff", text: "#231a3a", text2: "#675b84", accent: "#5b3fc4", separator: "rgba(35,26,58,0.1)", fill: "rgba(35,26,58,0.07)", lit: "#ffe9c2", shadow: true, blur: 0 },
Mint: { bg: "#eaf6f0", tile: "#ffffff", text: "#10291d", text2: "#4a6657", accent: "#0b7a53", separator: "rgba(16,41,29,0.1)", fill: "rgba(16,41,29,0.07)", lit: "#ffeec2", shadow: true, blur: 0 },
Sand: { bg: "#f3ebdf", tile: "#fffaf2", text: "#2d261d", text2: "#6b5e4c", accent: "#9a5416", separator: "rgba(45,38,29,0.1)", fill: "rgba(45,38,29,0.07)", lit: "#ffe2a8", shadow: true, blur: 0 },
"High contrast": { bg: "#000000", tile: "#141414", text: "#ffffff", text2: "#ffffff", accent: "#ffd60a", onAccent: "#000000", separator: "rgba(255,255,255,0.7)", fill: "rgba(255,255,255,0.2)", lit: "#ffd60a", shadow: false, blur: 0, outline: true },
};
const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true };
const DEFAULT_GRID = { cols: 12, rowHeight: 72, gap: 14, maxWidth: 1440 };
const DEFAULT_KIOSK = { saver: 0, saverStyle: "clock", saverSource: "links", saverUrls: "", saverAlbums: [], saverInterval: 30, exitCorner: "top-right", hideCursor: true, doorbell: "", doorbellCamera: "", doorbellSecs: 60, doorbellSound: true, rotate: 0, announce: true };
const DEFAULT_GUEST = { when: "manual", entity: "", from: "18:00", to: "23:00", entities: [], title: "" };
const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] });
const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, guest: { ...DEFAULT_GUEST }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] });
// Bring dashboards saved by older versions up to the current shape.
function migrate(d) {
const t = d.theme || {};
if (!t.preset) {
t.preset = "Custom";
if (t.card && !t.tile) t.tile = t.card;
if (t.border && !t.separator) t.separator = t.border;
}
d.theme = { ...DEFAULT_THEME, ...t };
d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) };
d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) };
d.guest = { ...DEFAULT_GUEST, ...(d.guest || {}) };
d.nav = { show: false, style: "icons", ...(d.nav || {}) };
if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }];
delete d.widgets;
// Media cards from before 1.5 kept one player in cfg.entity; move it into the list so it can be edited.
const fix = (w) => {
if (w.type === "media" && w.cfg.entity) { w.cfg.entities = [...new Set([w.cfg.entity, ...(w.cfg.entities || [])])]; delete w.cfg.entity; }
(w.cfg.children || []).forEach(fix);
};
d.tabs.forEach((t) => t.widgets.forEach(fix));
return d;
}
function makeWidget(type, x = 0, y = 0) {
const def = Widgets[type];
const cfg = {};
for (const f of def.fields) if ("d" in f) cfg[f.k] = typeof f.d === "string" && f.t !== "seg" ? tr(f.d) : f.d;
if (def.fields.some((f) => f.t === "entities")) cfg.entities = [];
if (def.container) cfg.children = [];
return { id: uid(), type, x, y, w: def.size[0], h: def.size[1], cfg, style: { transparent: !!def.transparent, bg: "", color: "", accent: "" } };
}
// ------------------------------------------------------------ app on the home screen
let installPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
// The Android app (android/ in the repository) shows this page in its own window and adds window.DSApp.
const IN_APP = !!window.DSApp;
const APK_ID = "nl.melvanveen.dashboardstudio";
async function installDialog() {
let inFrame = false;
try { inFrame = window.top !== window; } catch { inFrame = true; }
const ua = navigator.userAgent;
const ios = /iphone|ipad|ipod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const android = /android/i.test(ua);
const link = new URL(`?ds=${encodeURIComponent(current.id)}`, BASE).href;
const browserInstall = !inFrame && window.isSecureContext && installPrompt && !standalone();
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 the Dashboard Studio app, connected to {0}.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</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: the Android app always works.
body = p(tr("Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download the app and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without the app."), "muted small") : "");
actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "")
+ `<a class="btn" href="${new URL("android.apk", BASE).href}" download="Dashboard Studio.apk">${tr("Download the app")}</a>`
+ `<a class="btn primary" target="_top" href="intent://open?url=${encodeURIComponent(link)}#Intent;scheme=dashboardstudio;package=${APK_ID};end">${tr("Open in the app")}</a>`;
} else if (ios) {
// Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant.
body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead."));
} else if (inFrame) {
body = p(tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there."));
actions += `<button class="btn primary" data-x="tab">${tr("Open in its own tab")}</button>`;
} else if (!window.isSecureContext) {
body = p(tr("Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again."));
} else {
body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen."));
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
}
const choice = await openModal(`<h3>${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"); });
if (choice === "tab") window.open(link, "_blank", "noopener");
if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; }
if (choice === "reset") window.DSApp.reset();
}
/** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so
* after a while requests would fail. Renew it the way Home Assistant's own page does, with the
* login that page saved in this browser. Inside Home Assistant this isn't needed. */
async function keepIngressSession() {
let top = true;
try { top = window.top === window; } catch { top = false; }
if (!top || !location.pathname.startsWith("/api/hassio_ingress/")) return;
const renew = async () => {
let tokens;
try { tokens = JSON.parse(localStorage.getItem("hassTokens") || "null"); } catch { tokens = null; }
if (!tokens?.refresh_token) return;
if (!tokens.access_token || Date.now() > (tokens.expires || 0) - 60000) {
const r = await fetch("/auth/token", { method: "POST", body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: tokens.clientId || `${location.origin}/` }) });
if (!r.ok) return;
const n = await r.json();
tokens = { ...tokens, access_token: n.access_token, expires_in: n.expires_in, expires: Date.now() + n.expires_in * 1000 };
localStorage.setItem("hassTokens", JSON.stringify(tokens));
}
const r = await fetch("/api/hassio/ingress/session", { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}` } });
if (!r.ok) return;
const session = (await r.json())?.data?.session;
if (session) document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
};
try { await renew(); } catch { /* offline: the cached page carries on */ }
setInterval(() => renew().catch(() => {}), 5 * 60 * 1000);
}
// ------------------------------------------------------------ last known states
// The states the open dashboard uses are kept on this device, so it can show them while offline.
let stateCacheTimer = 0, lastLive = 0;
function cacheStates() {
if (stateCacheTimer) return; // at most every few seconds, however busy the states are
stateCacheTimer = setTimeout(() => {
stateCacheTimer = 0;
if (!current || !ha.connected) return;
const ids = new Set(["sun.sun"]);
for (const t of current.tabs) for (const w of t.widgets) entitiesOf(w).forEach((e) => ids.add(e));
const states = {};
for (const id of ids) if (ha.states[id]) states[id] = ha.states[id];
const json = JSON.stringify({ at: Date.now(), states });
if (json.length < 1.5e6) lsSet("ds.states", json);
}, 5000);
}
function loadCachedStates() {
try {
const c = JSON.parse(lsGet("ds.states") || "null");
if (c?.states && !Object.keys(ha.states).length) { ha.states = c.states; lastLive = c.at; }
} catch { /* nothing kept */ }
}
const offlineText = () => (lastLive ? tr("Offline · {0}", new Date(lastLive).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : tr("Offline"));
let offlineTimer = 0;
function showOffline(on) {
const el = $("#offline-note");
if (!on) { clearTimeout(offlineTimer); offlineTimer = 0; el.hidden = true; return; }
if (offlineTimer || !el.hidden) return; // already counting, or showing (each reconnect attempt reports again)
// a short blip (an update, a reconnect) shouldn't flash a note
offlineTimer = setTimeout(() => {
offlineTimer = 0;
el.textContent = lastLive ? tr("Offline. Showing the states from {0}.", new Date(lastLive).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : tr("Offline. Reconnecting…");
el.hidden = false;
}, 4000);
}
// ------------------------------------------------------------ sharing
// A link to Home Assistant's own page for this app, with the dashboard in it, so it opens
// through the sidebar on any device that is signed in to Home Assistant.
function sharedParams() {
const out = new URLSearchParams(location.search);
try { if (window.top !== window) for (const [k, v] of new URLSearchParams(window.top.location.search)) out.set(k, v); } catch { /* another origin */ }
return out;
}
function shareURL(id, inKiosk) {
let u;
try { u = new URL(window.top.location.href); } catch { u = new URL(location.href); }
u.hash = ""; u.search = "";
u.searchParams.set("ds", id);
if (inKiosk) u.searchParams.set("ds_kiosk", "1");
return u.href;
}
async function shareDialog() {
const draw = (inKiosk) => {
const url = shareURL(current.id, inKiosk);
$("#share-url").value = url;
$("#share-qr").innerHTML = window.QR.svg(url, { label: tr("QR code for {0}", current.name) });
};
const done = openModal(`<h3>${tr("Share “{0}”", esc(current.name))}</h3>
<p>${tr("Open this link or scan the code on another device that is signed in to Home Assistant.")}</p>
<div id="share-qr" class="share-qr"></div>
<div class="share-row"><input class="input" id="share-url" type="text" readonly aria-label="${tr("Link")}"><button class="btn" data-x="copy">${Icons.svg("copy")}${tr("Copy")}</button></div>
<label class="field check" style="margin-top:10px"><input type="checkbox" id="share-kiosk"> ${tr("Open in kiosk mode")}</label>
<div class="modal-actions"><button class="btn primary" data-x="done" autofocus>${tr("Done")}</button></div>`,
async (e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x;
if (x === "done") close(null);
if (x === "copy") {
try { await navigator.clipboard.writeText($("#share-url").value); toast(tr("Link copied.")); }
catch { $("#share-url").select(); document.execCommand?.("copy"); toast(tr("Link copied.")); }
}
});
draw(false);
$("#share-kiosk").onchange = (e) => draw(e.target.checked);
await done;
}
// ------------------------------------------------------------ import from Home Assistant
// Turns the cards of an existing Home Assistant dashboard into widgets, one tab per view.
const ent = (x) => (typeof x === "string" ? x : x?.entity) || "";
function convertCard(card, out) {
const t = String(card.type || "").replace(/^custom:/, "");
const e = card.entity || "";
const ents = (card.entities || []).map(ent).filter(Boolean);
const add = (type, cfg, w, h) => out.push({ type, cfg, w, h });
switch (t) {
case "vertical-stack": case "horizontal-stack": case "grid": (card.cards || []).forEach((c) => convertCard(c, out)); return;
case "entities": case "glance": if (ents.length) add("list", { title: card.title || "", entities: ents }, 4, Math.min(6, 1 + ents.length)); return;
case "light": add("light", { entity: e, name: card.name || "" }, 3, 2); return;
case "thermostat": case "climate": add("climate", { entity: e, name: card.name || "" }, 3, 3); return;
case "weather-forecast": add("forecast", { entity: e }, 6, 4); return;
case "media-control": add("media", { entities: [e] }, 4, 3); return;
case "markdown": add("text", { text: String(card.content || "").replace(/^#+\s*/gm, "").replace(/\*\*/g, "") }, 4, 2); return;
case "gauge": add("gauge", { entity: e, name: card.name || "", min: card.min ?? 0, max: card.max ?? 100 }, 3, 3); return;
case "history-graph": case "statistics-graph": case "sensor-graph": if (ents.length || e) add("graph", { title: card.title || "", entities: ents.length ? ents : [e], hours: card.hours_to_show || 24 }, 6, 3); return;
case "sensor": add("value", { entity: e, name: card.name || "" }, 3, 2); return;
case "picture-entity": case "picture-glance": { const cam = card.camera_image || (domainOf(e) === "camera" ? e : ""); if (cam) add("camera", { entity: cam }, 4, 3); return; }
case "calendar": if (ents.length) add("calendar", { entities: ents }, 4, 5); return;
case "todo-list": case "shopping-list": add("todo", { entity: e || "todo.shopping_list" }, 4, 4); return;
case "alarm-panel": add("alarm", { entity: e }, 4, 5); return;
case "area": add("room", { area: card.area || "" }, 4, 3); return;
case "tile": case "button": case "entity": case "mushroom-entity-card": case "mushroom-light-card":
if (domainOf(e) === "light") add("light", { entity: e, name: card.name || "" }, 3, 2);
else if (domainOf(e) === "climate") add("climate", { entity: e, name: card.name || "" }, 3, 3);
else if (e) add("entity", { entity: e, name: card.name || "" }, 3, 2);
return;
default:
if (e) add("entity", { entity: e, name: card.name || "" }, 3, 2);
else if (ents.length) add("list", { title: card.title || "", entities: ents }, 4, Math.min(6, 1 + ents.length));
else out.skipped = (out.skipped || 0) + 1;
}
}
function convertLovelace(config, name) {
const d = newDashboard(name);
d.tabs = [];
let skipped = 0;
for (const view of config.views || []) {
const tabW = { ...newTab(view.title || tr("Tab {0}", d.tabs.length + 1), String(view.icon || "").replace(/^mdi:/, "")), widgets: [] };
if (!Icons.has(tabW.icon)) tabW.icon = "tile";
const cards = [...(view.cards || []), ...(view.sections || []).flatMap((sec) => sec.cards || [])];
const out = [];
cards.forEach((c) => convertCard(c, out));
skipped += out.skipped || 0;
for (const o of out) {
if (!Widgets[o.type]) continue;
const wd = makeWidget(o.type);
Object.assign(wd.cfg, o.cfg);
wd.w = o.w; wd.h = o.h;
Object.assign(wd, findSpot(wd.w, wd.h, tabW.widgets, d.grid.cols));
tabW.widgets.push(wd);
}
d.tabs.push(tabW);
}
if (!d.tabs.length) d.tabs.push(newTab(tr("Overview")));
d.nav.show = d.tabs.length > 1;
return { d, skipped };
}
async function lovelaceDialog() {
if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; }
const list = await ha.send({ type: "lovelace/dashboards/list" }).catch(() => []);
const opts = [{ url_path: "", title: tr("Overview") }, ...(list || [])];
const pick = await openModal(`<h3>${tr("Import from Home Assistant")}</h3>
<p>${tr("Choose one of your Home Assistant dashboards. Its cards become widgets in a new dashboard here; the original stays as it is.")}</p>
<select class="input" id="ll-pick" style="margin-top:12px" aria-label="${tr("Dashboard")}">${opts.map((o) => `<option value="${esc(o.url_path || "")}">${esc(o.title || o.url_path)}</option>`).join("")}</select>
<div class="modal-actions"><button class="btn" data-x="cancel">${tr("Cancel")}</button><button class="btn primary" data-x="ok">${tr("Import")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok" ? { path: $("#ll-pick").value, title: $("#ll-pick").selectedOptions[0].text } : null); });
if (!pick) return;
let config;
try { config = await ha.send({ type: "lovelace/config", url_path: pick.path || null }); }
catch (e) { toast(tr("Couldn’t load that dashboard: {0}", e.message), true); return; }
if (config?.strategy && !config.views) { toast(tr("That dashboard is generated by Home Assistant and has no cards of its own to import."), true); return; }
const { d, skipped } = convertLovelace(config || {}, pick.title);
store.dashboards.push(d);
scheduleSave();
setCurrent(d.id);
setEdit(true);
const n = d.tabs.reduce((a, t) => a + t.widgets.length, 0);
toast(skipped ? tr("Imported {0} widgets. {1} cards couldn’t be converted.", n, skipped) : tr("Imported {0} widgets.", n));
}
// ------------------------------------------------------------ templates
// Ready-made layouts, filled with whatever matching devices this Home Assistant has.
function templateBuilder(name, { mobile = false } = {}) {
const d = newDashboard(name);
if (mobile) Object.assign(d, { mobile: true, grid: { ...d.grid, cols: 4, maxWidth: 440 } });
const cols = d.grid.cols, ws = d.tabs[0].widgets;
const ids = Object.keys(ha.states);
const of = (dom, test = () => true) => ids.filter((e) => domainOf(e) === dom && test(ha.states[e]));
const add = (type, w, h, cfg = {}) => {
const wd = makeWidget(type);
Object.assign(wd.cfg, cfg);
wd.w = Math.min(w, cols); wd.h = h;
Object.assign(wd, findSpot(wd.w, wd.h, ws, cols));
ws.push(wd);
return wd;
};
const dc = (s, ...c) => c.includes(s.attributes.device_class);
return { d, add, of, dc, ids };
}
const TEMPLATES = {
wall: { icon: "kiosk", name: "Wall tablet", desc: "Clock, weather, calendar, rooms and cameras for a screen on the wall.", build() {
const { d, add, of } = templateBuilder(tr("Wall tablet"));
Object.assign(d.theme, PRESETS.Dark, { preset: "Dark" });
d.kiosk.saver = 10;
const weather = of("weather")[0];
add("clock", 4, 3, { weather: weather || "", date: true });
if (weather) add("forecast", 8, 3, { entity: weather, show: "both" });
const cals = of("calendar");
if (cals.length) add("calendar", 4, 6, { entities: cals });
for (const a of (ha.reg?.areas || []).slice(0, 4)) add("room", 4, 3, { area: a.area_id });
const cam = of("camera")[0];
if (cam) add("camera", 4, 3, { entity: cam });
const players = of("media_player").slice(0, 4);
if (players.length) add("media", 4, 3, { entities: players });
return d;
} },
phone: { icon: "phone", name: "Phone", desc: "A 4-column layout for your phone: people, lights, climate and media.", build() {
const { d, add, of } = templateBuilder(tr("Phone"), { mobile: true });
add("heading", 4, 1, { text: tr("Home"), subtitle: "", size: 26 });
const people = of("person");
if (people.length) add("people", 4, 2, { entities: people.slice(0, 4) });
for (const e of of("light", (s) => !s.attributes.entity_id).slice(0, 4)) add("entity", 2, 2, { entity: e });
const clim = of("climate")[0];
if (clim) add("climate", 4, 3, { entity: clim });
const weather = of("weather")[0];
if (weather) add("weather", 4, 2, { entity: weather });
const garage = of("cover", (s) => /garage|gate|poort|hek/i.test(s.entity_id + friendly(s)))[0];
if (garage) add("gate", 2, 3, { entity: garage });
const players = of("media_player").slice(0, 3);
if (players.length) add("media", 4, 3, { entities: players });
return d;
} },
energy: { icon: "bolt", name: "Energy", desc: "Power flow, solar forecast, water and gas use and history graphs.", build() {
const { d, add, of, dc } = templateBuilder(tr("Energy"));
add("heading", 12, 1, { text: tr("Energy"), subtitle: "" });
const power = of("sensor", (s) => dc(s, "power"));
const find = (re) => power.find((e) => re.test(e));
const solar = find(/solar|pv|zon/i), grid = find(/grid|net|p1/i), battery = find(/battery|accu/i);
if (solar || grid) add("energy", 6, 5, { solar: solar || "", grid: grid || "", battery: battery || "" });
const today = of("sensor").find((e) => /energy_production_today/.test(e));
if (today) add("solar", 6, 5, { today, ...W_AUTOFILL("solar", today) });
for (const e of of("sensor", (s) => dc(s, "water", "gas") && /total/.test(s.attributes.state_class || "")).slice(0, 2))
add("usage", 4, 3, { entity: e, kind: ha.states[e].attributes.device_class === "gas" ? "gas" : "water" });
if (power.length) add("graph", 12, 3, { entities: power.slice(0, 3), title: tr("Power, last 24 hours") });
return d;
} },
family: { icon: "people", name: "Family", desc: "Who’s home, the family calendar, shopping list, parcels and the weather.", build() {
const { d, add, of } = templateBuilder(tr("Family"));
add("clock", 4, 2, { date: true, weather: of("weather")[0] || "" });
const people = of("person");
if (people.length) add("people", 8, 2, { entities: people });
const cals = of("calendar");
if (cals.length) add("calendar", 4, 6, { entities: cals });
const todo = of("todo")[0];
if (todo) add("todo", 4, 5, { entity: todo });
const weather = of("weather")[0];
if (weather) add("forecast", 4, 4, { entity: weather, show: "daily" });
const parcels = of("sensor", (s) => Object.values(s.attributes).some((v) => Array.isArray(v) && typeof v[0] === "object" && /parcel|delivery|postnl|dhl|pakket/i.test(s.entity_id)));
if (parcels.length) add("parcels", 4, 4, { entities: parcels });
const waste = of("sensor", (s) => /afvalwijzer/i.test(s.entity_id));
if (waste.length) add("waste", 4, 3, {});
add("notifications", 4, 3, {});
return d;
} },
};
const W_AUTOFILL = (type, eid) => Object.fromEntries(Object.entries(Widgets[type].autofill?.fill(eid, ha) || {}).filter(([, v]) => v));
/** New dashboard: a name and where to start from. */
async function templateDialog() {
const opts = [["empty", "tile", "Empty", "Start with a blank grid."], ...Object.entries(TEMPLATES).map(([k, t]) => [k, t.icon, t.name, t.desc]), ["devices", "sparkles", "From my devices", "A mix of your lights, sensors and devices."]];
return openModal(`<h3>${tr("New dashboard")}</h3>
<form id="tpl-form"><input class="input" id="tpl-name" type="text" value="${esc(tr("New dashboard"))}" aria-label="${tr("Name")}" style="margin-top:12px" autofocus>
<div class="tpl-grid" role="radiogroup" aria-label="${tr("Start from")}">${opts.map(([k, ic, n, desc], i) => `<label class="tpl"><input type="radio" name="tpl" value="${k}" ${i === 0 ? "checked" : ""}><span class="tpl-ico">${Icons.svg(ic)}</span><span><b>${esc(tr(n))}</b><span>${esc(tr(desc))}</span></span></label>`).join("")}</div>
<div class="modal-actions"><button type="button" class="btn" data-x="cancel">${tr("Cancel")}</button><button type="submit" class="btn primary">${tr("Create")}</button></div></form>`,
(e, close) => { if (e.target.closest("[data-x='cancel']")) close(null); });
}
document.addEventListener("submit", (e) => {
if (e.target.id !== "tpl-form") return;
e.preventDefault();
const name = $("#tpl-name").value.trim(), template = e.target.querySelector("input[name=tpl]:checked")?.value || "empty";
if (name && template !== "devices" && template !== "empty" && !ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; }
if (name) modalClose?.({ name, template });
});
function starterDashboard() {
const d = newDashboard(tr("Home"));
const ids = Object.keys(ha.states);
const first = (dom, n = 1) => ids.filter((e) => domainOf(e) === dom).slice(0, n);
const put = (type, x, y, w, h, cfg) => { const wd = makeWidget(type, x, y); Object.assign(wd, { w, h }); Object.assign(wd.cfg, cfg); d.tabs[0].widgets.push(wd); return wd; };
const numeric = (e) => !isNaN(Number(ha.states[e].state));
put("heading", 0, 0, 8, 1, { text: tr("Home"), subtitle: tr("Everything at a glance") });
const weather = first("weather")[0];
put("clock", 8, 0, 4, 3, { weather: weather || "" });
if (weather) put("weather", 0, 1, 4, 2, { entity: weather });
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|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 }));
// A car is recognisable as a lock with a sibling "<name>_battery" sensor.
const carLock = first("lock", 20).find((l) => ha.states[`sensor.${l.split(".")[1].replace(/_[^_]+$/, "")}_battery`]);
let y = 5;
if (carLock) {
const base = carLock.split(".")[1].replace(/_[^_]+$/, "");
const pick = (dom, suffix) => (ha.states[`${dom}.${base}_${suffix}`] ? `${dom}.${base}_${suffix}` : "");
put("car", 0, y, 6, 3, {
name: cap(base.replace(/_/g, " ")).replace(/\b\w/g, (m) => m.toUpperCase()),
level: pick("sensor", "battery"), range: pick("sensor", "range"), charging: pick("binary_sensor", "charging"),
power: pick("sensor", "charging_power"), plug: pick("binary_sensor", "plug"), limit: pick("number", "charge_limit"),
lock: carLock, climate: pick("climate", "climate"), location: pick("device_tracker", "location"), odometer: pick("sensor", "odometer"),
});
}
const sw = [...first("switch", 3).slice(1), ...first("input_boolean", 1), ...first("fan", 1), ...lights.slice(2)];
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, {});
}
return d;
}
// ------------------------------------------------------------ app state
let store = { version: 1, dashboards: [], assign: { devices: {}, users: {} } };
// Each browser gets its own id, so a wall tablet can be told which dashboard to open.
const DEVICE_ID = lsGet("ds.device") || (() => { const id = uid() + uid(); lsSet("ds.device", id); return id; })();
let me = null; // the signed-in Home Assistant user, from the server
let current = null;
let editMode = lsGet("ds.edit") === "1";
let selectedId = null;
/** Shift- or ⌘-click selects several widgets; `multi` then holds them all, including selectedId. */
const multi = new Set();
const isSel = (id) => id === selectedId || multi.has(id);
let activeTabId = null;
/** The tab whose widgets are on screen. */
const tab = () => (guestOn && !editMode ? guestTab() : current.tabs.find((t) => t.id === activeTabId) || current.tabs[0]);
/** Cards inside a container card (scroll, swipe) live in its cfg.children. */
let childIndex = null;
function findWidget(id, list = tab().widgets) {
for (const w of list) {
if (w.id === id) return w;
const inner = w.cfg?.children && findWidget(id, w.cfg.children);
if (inner) return inner;
}
return null;
}
const selectedWidget = () => tab().widgets.find((x) => x.id === selectedId);
const selection = () => tab().widgets.filter((w) => isSel(w.id));
/** What the inspector edits: a card inside the selected container, or the selected widget. */
const editTarget = () => { const w = selectedWidget(); return (childIndex != null && w?.cfg.children?.[childIndex]) || w; };
const undoStack = [], redoStack = [];
const undoAt = [], redoAt = []; // when each step was made, for the undo history list
let lastMutKey = null, lastMutAt = 0;
const caches = {}; // per-widget runtime cache (graph history)
const gridEl = $("#grid"), stageEl = $("#stage"), canvasEl = $("#canvas"), navEl = $("#tabnav");
let metrics = { colW: 0, rh: 72, gap: 14, cols: 12, compact: false };
// ------------------------------------------------------------ persistence
let saveTimer = null, savePending = false;
function scheduleSave() {
savePending = true;
$("#save-status").textContent = tr("Saving…");
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 700);
}
async function save() {
savePending = false;
try {
const r = await fetch(new URL("api/dashboards", BASE), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(store) });
if (!r.ok) throw new Error(r.status);
$("#save-status").textContent = tr("Saved");
} catch {
$("#save-status").textContent = tr("Not saved");
toast(tr("Couldn’t save. Your changes are kept here and will save on the next edit."), true);
}
}
/** Apply a change to the current dashboard with undo support.
* `key` coalesces rapid edits (typing) into one undo step. */
function mutate(fn, key, { rerender = true } = {}) {
const now = Date.now();
if (!(key && key === lastMutKey && now - lastMutAt < 1200)) {
undoStack.push(JSON.stringify(current));
undoAt.push(now);
if (undoStack.length > 100) { undoStack.shift(); undoAt.shift(); }
redoStack.length = redoAt.length = 0;
}
lastMutKey = key; lastMutAt = now;
fn();
scheduleSave();
updateUndoButtons();
if (rerender) renderGrid();
}
function restore(snapshot) {
const idx = store.dashboards.findIndex((d) => d.id === current.id);
current = JSON.parse(snapshot);
store.dashboards[idx] = current;
if (!tab().widgets.some((w) => w.id === selectedId)) { selectedId = null; multi.clear(); }
lastMutKey = null;
scheduleSave(); updateUndoButtons(); renderAll();
}
function undo() { if (undoStack.length) { redoStack.push(JSON.stringify(current)); redoAt.push(undoAt.pop()); restore(undoStack.pop()); } }
function redo() { if (redoStack.length) { undoStack.push(JSON.stringify(current)); undoAt.push(redoAt.pop()); restore(redoStack.pop()); } }
/** What changed between two versions of a dashboard, in a few words. */
function describeChange(a, b) {
const all = (d) => { const m = new Map(); const walk = (ws) => ws.forEach((w) => { m.set(w.id, w); walk(w.cfg?.children || []); }); d.tabs.forEach((t) => walk(t.widgets)); return m; };
const wa = all(a), wb = all(b), name = (w) => tr(Widgets[w.type]?.label || w.type);
const added = [...wb.values()].filter((w) => !wa.has(w.id)), gone = [...wa.values()].filter((w) => !wb.has(w.id));
const several = (list, one, many) => (list.length === 1 ? tr(one, name(list[0])) : tr(many, list.length));
if (added.length && gone.length) return tr("Moved widgets around");
if (added.length) return several(added, "Added {0}", "Added {0} widgets");
if (gone.length) return several(gone, "Deleted {0}", "Deleted {0} widgets");
const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
const changed = [...wb.values()].filter((w) => !same(w, wa.get(w.id)));
if (changed.length) {
const kind = (w) => { const o = wa.get(w.id); return o.x !== w.x || o.y !== w.y ? (o.w !== w.w || o.h !== w.h ? "size" : "move") : o.w !== w.w || o.h !== w.h ? "size" : "edit"; };
const k = kind(changed[0]);
if (k === "move") return several(changed, "Moved {0}", "Moved {0} widgets");
if (k === "size") return several(changed, "Resized {0}", "Resized {0} widgets");
return several(changed, "Edited {0}", "Edited {0} widgets");
}
if (!same(a.tabs.map((t) => [t.id, t.name, t.icon]), b.tabs.map((t) => [t.id, t.name, t.icon])) || !same(a.nav, b.nav)) return tr("Changed the tabs");
if (!same(a.theme, b.theme)) return tr("Changed the look");
if (!same(a.grid, b.grid) || a.mobile !== b.mobile) return tr("Changed the grid");
if (!same(a.kiosk, b.kiosk)) return tr("Changed the kiosk settings");
if (!same(a.guest, b.guest)) return tr("Changed guest mode");
if (a.name !== b.name) return tr("Renamed the dashboard");
return tr("Changed the dashboard");
}
/** The changes made in this session, newest first; picking one goes back to just after it. */
async function undoHistoryDialog() {
if (!undoStack.length) { toast(tr("No changes yet in this session.")); return; }
const states = [...undoStack, JSON.stringify(current)];
const rows = undoStack.map((snap, i) => ({ i, label: describeChange(JSON.parse(snap), JSON.parse(states[i + 1])), at: undoAt[i] })).reverse();
const pick = await openModal(`<h3>${tr("Undo history")}</h3><p>${tr("Pick a change to go back to just after it. Redo brings the later ones back.")}</p>
<div class="hist-list">${rows.map((r, n) => `<button class="hist-row undo-row" data-x="${r.i}"><span class="grow"><span class="hist-when">${esc(r.label)}</span>
<span class="muted small">${esc(new Date(r.at).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit", second: "2-digit" }))}</span></span>${n === 0 ? `<span class="muted small">${tr("Current")}</span>` : ""}</button>`).join("")}
<button class="hist-row undo-row" data-x="-1"><span class="grow"><span class="hist-when">${tr("Before your changes")}</span></span></button></div>
<div class="modal-actions"><button class="btn" data-x="cancel" autofocus>${tr("Close")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "cancel" ? null : x); });
if (pick == null) return;
const steps = undoStack.length - (Number(pick) + 1);
for (let i = 0; i < steps; i++) undo();
}
function updateUndoButtons() {
$("#btn-undo").disabled = !undoStack.length;
$("#btn-redo").disabled = !redoStack.length;
}
// ------------------------------------------------------------ rendering
const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", onAccent: "--d-on-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" };
/** A theme's light or dark twin, built from its accent, for themes set to follow the device. */
function themeVariant(t) {
if (!t.follow || t.preset === "System" || t.preset === "Custom" || !t.bg || !/^#[0-9a-f]{6}$/i.test(t.bg)) return t;
const lum = (hex) => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; };
const isDark = lum(t.bg) < 0.45, wantDark = prefersDark();
if (isDark === wantDark) return t;
const a = t.accent || "#0a84ff";
return wantDark
? { ...t, bg: `color-mix(in srgb, ${a} 7%, #0f0f11)`, tile: `color-mix(in srgb, ${a} 9%, #1c1c1e)`, text: "#f5f5f7", text2: "#a1a1a6", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", onAccent: "#ffffff", shadow: false }
: { ...t, bg: `color-mix(in srgb, ${a} 7%, #f2f2f7)`, tile: `color-mix(in srgb, ${a} 3%, #ffffff)`, text: "#1d1d1f", text2: "#6e6e73", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", onAccent: "#ffffff", shadow: true };
}
function applyTheme() {
const t = themeVariant(nightDark ? { ...current.theme, ...PRESETS.Dark, preset: "Dark" } : current.theme), s = stageEl.style;
stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain");
stageEl.classList.add(`surface-${t.surface || "cards"}`);
for (const k of COLOR_KEYS) {
if (t.preset !== "System" && t[k]) s.setProperty(VAR_FOR[k], t[k]);
else s.removeProperty(VAR_FOR[k]);
}
const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)";
if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`);
s.setProperty("--d-radius", `${t.radius ?? 20}px`);
const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar);
window.DSApp?.setBarColor(bar);
s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`);
stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System");
s.setProperty("--d-blur", `${t.blur || 0}px`);
if (t.preset === "System") s.removeProperty("--d-shadow");
else s.setProperty("--d-shadow", t.shadow ? "0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.06)" : "none");
}
function computeMetrics() {
const g = current.grid;
const pad = window.innerWidth < 560 ? 12 : 20;
const avail = canvasEl.clientWidth - pad * 2;
const landscape = !editMode && !!current.mobile && phoneLandscape.matches;
const width = landscape ? Math.min(avail, 900) : g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail;
// 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 : landscape ? (Number(g.cols) || 4) * 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;
metrics = { width, cols, gap, rh, colW: (width - gap * (cols - 1)) / cols, compact, landscape };
gridEl.style.width = `${width}px`;
gridEl.style.margin = `${editMode ? 4 : 8}px auto ${pad}px`;
}
// 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) {
const scr = w.visibility?.screen, phone = matchMedia("(max-width: 600px)").matches;
if ((scr === "phone" && !phone) || (scr === "large" && phone)) return false;
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.landscape) { // reading order, packed into the double-width grid
const placed = [];
for (const w of [...shown].sort((a, b) => a.y - b.y || a.x - b.x)) {
const p = { ...findSpot(w.w, w.h, placed, metrics.cols), w: w.w, h: w.h };
map[w.id] = p;
placed.push(p);
}
return map;
}
if (!metrics.compact) {
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 = [...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) {
if (small(w)) {
if (halfOpen) { map[w.id] = { x: 1, y: halfOpen.y, w: 1, h: halfOpen.h }; y = halfOpen.y + halfOpen.h; halfOpen = null; }
else { const h = Math.max(2, w.h); map[w.id] = { x: 0, y, w: 1, h }; halfOpen = { y, h }; }
} else {
if (halfOpen) { y = halfOpen.y + halfOpen.h; halfOpen = null; }
const h = w.type === "clock" ? Math.min(w.h, 2) : w.h;
map[w.id] = { x: 0, y, w: 2, h };
y += h;
}
}
return map;
}
function rectFor(p) {
const { colW, gap, rh } = metrics;
return { left: p.x * (colW + gap), top: p.y * (rh + gap), width: p.w * colW + (p.w - 1) * gap, height: p.h * rh + (p.h - 1) * gap };
}
function placeEl(el, p) {
const r = rectFor(p);
Object.assign(el.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
}
function updateGridHeight(pos) {
const rows = Object.values(pos).reduce((m, p) => Math.max(m, p.y + p.h), 0) + (editMode ? 4 : 0);
gridEl.style.height = `${Math.max(rows * (metrics.rh + metrics.gap) - metrics.gap, 0)}px`;
const cw = metrics.colW + metrics.gap, ch = metrics.rh + metrics.gap;
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${cw}' height='${ch}'><rect x='0.5' y='0.5' width='${Math.max(metrics.colW - 1, 0)}' height='${metrics.rh - 1}' rx='10' fill='rgba(127,127,127,0.06)' stroke='rgba(127,127,127,0.2)' stroke-dasharray='3 4'/></svg>`;
gridEl.style.setProperty("--cell-bg", `url("data:image/svg+xml,${encodeURIComponent(svg)}")`);
gridEl.style.backgroundSize = `${cw}px ${ch}px`;
}
/** Side menu with the dashboard's tabs (a bottom tab bar on phones). */
function renderNav() {
const show = !(guestOn && !editMode) && (current.nav.show || (editMode && current.tabs.length > 1));
document.body.classList.toggle("has-tabs", show);
navEl.hidden = !show;
if (!show) return;
const active = tab().id;
// "icons": a slim rail; names appear as a tooltip on hover or keyboard focus.
navEl.classList.toggle("icons", current.nav.style !== "labels");
navEl.innerHTML = current.tabs.map((t) => `<button class="tabnav-item" data-tab="${t.id}" aria-label="${esc(t.name)}" ${t.id === active ? 'aria-current="page"' : ""}>
<span class="tabnav-ico">${Icons.has(t.icon) ? Icons.svg(t.icon) : t.icon ? `<span class="glyph">${esc(t.icon)}</span>` : Icons.svg("tile")}</span><span class="tabnav-label">${esc(t.name)}</span></button>`).join("") +
(editMode ? `<button class="tabnav-item add" data-tabop="add" aria-label="${tr("Add tab")}">${Icons.svg("plus")}<span class="tabnav-label">${tr("Add tab")}</span></button>` : "");
}
function setTab(id) {
activeTabId = id;
lsSet(`ds.tab.${current.id}`, id);
{ selectedId = null; multi.clear(); }
renderGrid();
if (editMode) renderInspector();
stageEl.scrollTop = 0;
}
function addTab() {
const t = newTab(tr("Tab {0}", current.tabs.length + 1), "tile");
mutate(() => { current.tabs.push(t); current.nav.show = true; }, null, { rerender: false });
setTab(t.id);
}
async function tabOp(op, id) {
const i = current.tabs.findIndex((t) => t.id === id);
if (op === "add") return addTab();
if (op === "up" || op === "down") {
const j = op === "up" ? i - 1 : i + 1;
if (j < 0 || j >= current.tabs.length) return;
mutate(() => { [current.tabs[i], current.tabs[j]] = [current.tabs[j], current.tabs[i]]; });
renderInspector();
} else if (op === "del") {
if (current.tabs.length === 1) { toast(tr("A dashboard needs at least one tab."), true); return; }
const t = current.tabs[i];
if (t.widgets.length && !(await confirmDestructive(tr("Delete tab “{0}”?", t.name), tr("Its {0} widgets will be deleted too. You can undo this with ⌘Z.", t.widgets.length), tr("Delete")))) return;
mutate(() => { current.tabs.splice(i, 1); }, null, { rerender: false });
setTab(current.tabs[Math.max(0, i - 1)].id);
}
}
const phoneScreen = matchMedia("(max-width: 600px)");
// A phone on its side: wide but low. Phone dashboards then use two columns of their layout side by side.
const phoneLandscape = matchMedia("(orientation: landscape) and (max-height: 500px)");
function renderGrid() {
if (!current) return;
document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && (phoneScreen.matches || phoneLandscape.matches));
renderNav();
applyTheme();
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;
gridEl.appendChild(el);
placeEl(el, pos[w.id]);
renderWidget(w, el);
}
updateGridHeight(pos);
}
async function api(path) {
const r = await fetch(new URL(path, BASE));
if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`);
return r.json();
}
const ctxFor = (w) => ({
ha, w, api, cache: (caches[w.id] ||= {}),
rerender: () => renderWidget(w),
isVisible: (child) => editMode || isVisible(child),
// Containers draw placeholders (.w.child); this fills each with its card.
renderChildren: (el) => el.querySelectorAll(".w.child[data-id]").forEach((ph) => {
const ch = (w.cfg.children || []).find((c) => c.id === ph.dataset.id);
if (ch) renderWidget(ch, ph, { child: true });
}),
});
function renderWidget(w, el = gridEl.querySelector(`.w[data-id="${w.id}"]`), { child = false } = {}) {
if (!el) return;
const def = Widgets[w.type];
if (!def) { el.innerHTML = `<div class="w-body"><div class="empty">${tr("Unknown widget “{0}”.", esc(w.type))}</div></div>`; return; }
const ctx = ctxFor(w);
const st = w.style || {};
const lit = def.lit?.(w.cfg, ctx) || false;
el.classList.toggle("selected", editMode && isSel(w.id));
el.classList.toggle("transparent", !!st.transparent);
el.classList.toggle("lit-lamp", lit === "lamp");
el.classList.toggle("lit-on", lit === "on");
const lamp = lit === "lamp" ? def.lamp?.(w.cfg, ctx) : null;
for (const [prop, val] of [["--lamp", lamp], ["--w-bg", st.bg], ["--w-color", st.color], ["--w-accent", st.accent]]) {
if (val) el.style.setProperty(prop, val); else el.style.removeProperty(prop);
}
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 && !child && !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 && !child ? `<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);
}
function entitiesOf(w) {
const def = Widgets[w.type];
if (!def) return [];
if (def.container) return (w.cfg.children || []).flatMap(entitiesOf);
if (def.entities) return def.entities(w.cfg, ha);
return w.cfg.entity ? [w.cfg.entity] : [];
}
let interacting = false;
function onStateChange(entityId) {
if (ha.connected) { lastLive = Date.now(); cacheStates(); }
if (livePopup && livePopup.eid === entityId) livePopup.draw(); // an open climate popup follows the device
if (!current) return;
if (entityId === current.kiosk.doorbell || entityId == null) checkDoorbell();
if (entityId === "sun.sun" || entityId == null) applyNight();
if (entityId === current.guest?.entity || entityId == null) checkGuest();
if (entityId == null || entityId === awayEntity()) checkAway();
if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; }
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
if (caches[w.id]?.busy) { caches[w.id].dirty = true; continue; } // mid-swipe: redraw when the swipe ends
if (el?.contains(document.activeElement) && document.activeElement.matches("input[type=text]")) { (caches[w.id] ||= {}).typing = true; continue; }
const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null;
renderWidget(w, el);
if (focused) el.querySelector(`[data-act="${focused}"]`)?.focus();
}
}
function renderDashSelect() {
$("#dash-select").innerHTML = store.dashboards.map((d) => `<option value="${d.id}" ${d.id === current?.id ? "selected" : ""}>${esc(d.name)}</option>`).join("");
}
// ------------------------------------------------------------ suggestions
// Devices that aren't on any dashboard yet, each with the widget that suits it.
const SUGGEST = { light: "light", climate: "climate", cover: "covers", media_player: "media", camera: "camera", lock: "entity", switch: "entity", fan: "entity",
vacuum: "vacuum", alarm_control_panel: "alarm", todo: "todo", input_boolean: "entity", scene: "entity", weather: "forecast" };
function suggestions() {
const used = new Set();
for (const d of store.dashboards) for (const t of d.tabs) for (const w of t.widgets) { entitiesOf(w).forEach((e) => used.add(e)); ruleEntities(w).forEach((e) => used.add(e)); }
const hidden = new Set();
// entities Home Assistant hides, and lights that are part of a group already on a dashboard
for (const st0 of Object.values(ha.states)) if (Array.isArray(st0.attributes.entity_id) && used.has(st0.entity_id)) st0.attributes.entity_id.forEach((e) => hidden.add(e));
return Object.values(ha.states).filter((x) => SUGGEST[domainOf(x.entity_id)] && !used.has(x.entity_id) && !hidden.has(x.entity_id) && x.state !== "unavailable")
.sort((a, b) => domainOf(a.entity_id).localeCompare(domainOf(b.entity_id)) || friendly(a).localeCompare(friendly(b)));
}
async function suggestionsDialog() {
const list = suggestions();
if (!list.length) { toast(tr("Every device is on a dashboard already.")); return; }
const draw = () => suggestions().map((x) => `<div class="sg-row"><span class="sg-ico">${window.HAFmt.icon(x)}</span><span class="grow"><b>${esc(friendly(x))}</b><span class="muted small">${esc(x.entity_id)} · ${esc(tr(Widgets[SUGGEST[domainOf(x.entity_id)]].label))}</span></span><button class="btn" data-x="add" data-e="${esc(x.entity_id)}">${Icons.svg("plus")}${tr("Add")}</button></div>`).join("") || `<div class="muted small">${tr("Every device is on a dashboard already.")}</div>`;
await openModal(`<h3>${tr("Not on a dashboard yet")}</h3><p>${tr("These devices aren’t on any of your dashboards. Add one to this tab with the widget that suits it.")}</p>
<div class="hist-list sg-list">${draw()}</div>
<div class="modal-actions"><button class="btn primary" data-x="done" autofocus>${tr("Done")}</button></div>`,
(e, close) => {
const b = e.target.closest("[data-x]");
if (!b) return;
if (b.dataset.x === "done") return close(null);
const eid = b.dataset.e, type = SUGGEST[domainOf(eid)];
const wd = makeWidget(type);
if (Widgets[type].fields.some((f) => f.k === "entities")) wd.cfg.entities = [eid]; else wd.cfg.entity = eid;
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));
$(".sg-list").innerHTML = draw();
renderPalette();
});
}
function renderPalette() {
const search = $("#palette-search"), q = search.value.trim().toLowerCase();
search.placeholder = tr("Search widgets");
const items = Object.entries(Widgets).filter(([type, d]) => !q || [type, d.label, tr(d.label)].some((x) => x.toLowerCase().includes(q)));
const presets = (store.presets || []).filter((p) => !q || p.name.toLowerCase().includes(q));
const presetHTML = presets.length ? `<div class="pal-sub">${tr("My presets")}</div>${presets.map((p) => `<div class="pal-preset"><button class="pal-item" data-preset-add="${esc(p.id)}">${Icons.svg(Widgets[p.widget.type]?.icon || "bookmark")}<span>${esc(p.name)}</span></button><button class="pal-del" data-preset-del="${esc(p.id)}" aria-label="${esc(tr("Delete preset “{0}”", p.name))}" title="${esc(tr("Delete preset “{0}”", p.name))}">${Icons.svg("close")}</button></div>`).join("")}<div class="pal-sub">${tr("Widgets")}</div>` : "";
const nSug = ha.connected && store.dashboards?.length ? suggestions().length : 0;
const sugHTML = nSug && !q ? `<button class="pal-sug" data-suggest>${Icons.svg("sparkles")}<span>${esc(tr(nSug === 1 ? "1 device isn’t on a dashboard" : "{0} devices aren’t on a dashboard", nSug))}</span></button>` : "";
$("#palette-list").innerHTML = items.length || presets.length
? sugHTML + presetHTML + items.map(([type, d]) => `<button class="pal-item" data-add="${type}">${Icons.svg(d.icon)}<span>${esc(tr(d.label))}</span></button>`).join("")
: `<p class="muted small" style="grid-column:1/-1;margin:4px 2px">${tr("No widgets match “{0}”.", esc(search.value.trim()))}</p>`;
}
/** A set-up widget saved under a name, to add again on any dashboard. Kept with the dashboards. */
async function savePreset(id) {
const w = findWidget(id);
if (!w) return;
const name = await askText(tr("Save as preset"), w.cfg.name || w.cfg.title || tr(Widgets[w.type].label), tr("Save"));
if (!name) return;
const { x, y, ...rest } = clone(w);
(store.presets ||= []).push({ id: uid(), name, widget: rest });
scheduleSave();
renderPalette();
toast(tr("Saved “{0}”. You’ll find it at the top of the widget list.", name));
}
function addPreset(pid) {
const p = (store.presets || []).find((x) => x.id === pid);
if (!p) return;
const wd = { ...clone(p.widget), id: uid() };
(wd.cfg.children || []).forEach((ch) => (ch.id = uid()));
wd.w = Math.min(wd.w, current.grid.cols);
Object.assign(wd, findSpot(wd.w, wd.h));
mutate(() => tab().widgets.push(wd));
select(wd.id);
}
$("#palette-search").addEventListener("input", renderPalette);
$("#palette-search").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#palette-list .pal-item")?.click(); });
function renderAll() {
document.body.classList.toggle("editing", editMode);
$("#btn-edit").textContent = editMode ? tr("Done") : tr("Edit");
renderDashSelect();
renderGrid();
if (editMode) renderInspector();
applyNight();
}
// ------------------------------------------------------------ inspector
const entPickAttrs = (domains) => `data-ent-pick="${(domains || []).join(",")}" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ent-pop"`;
function fieldHTML(f, value, path) {
f = { ...f, l: tr(f.l), ph: f.ph ? tr(f.ph) : f.ph };
const id = `f-${path}-${f.k}`;
const data = `data-path="${path}" data-k="${f.k}"`;
const label = `<label for="${id}">${esc(f.l)}</label>`;
switch (f.t) {
case "check":
return `<div class="field check"><label><input type="checkbox" ${data} ${value ? "checked" : ""}> ${esc(f.l)}</label></div>`;
case "seg":
return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="seg" role="group" aria-labelledby="${id}">
${f.o.map((o) => `<button type="button" ${data} data-v="${esc(o)}" aria-pressed="${o === value}">${esc(f.labels?.[o] ?? optLabel(o))}</button>`).join("")}</div></div>`;
case "select":
return `<div class="field">${label}<select class="input" id="${id}" ${data}>${f.o.map((o) => `<option value="${esc(o)}" ${o === value ? "selected" : ""}>${esc(f.labels?.[o] ?? optLabel(o))}</option>`).join("")}</select></div>`;
case "time":
return `<div class="field">${label}<input class="input" id="${id}" type="time" ${data} value="${esc(value ?? "")}"></div>`;
case "number":
return `<div class="field">${label}<input class="input" id="${id}" type="number" step="any" ${data} data-num="1" value="${esc(value ?? "")}" placeholder="${esc(f.ph || "")}"></div>`;
case "textarea":
case "json":
return `<div class="field">${label}<textarea class="input" id="${id}" ${data} ${f.t === "json" ? 'data-json="1" placeholder=\'{"brightness_pct": 50}\'' : ""}>${esc(value ?? "")}</textarea></div>`;
case "color":
return `<div class="field">${label}<div class="color-row">
<input type="color" data-sync="${id}" value="${/^#[0-9a-f]{6}$/i.test(value || "") ? value : "#888888"}" aria-label="${esc(f.l)} picker">
<input class="input" id="${id}" type="text" ${data} value="${esc(value ?? "")}" placeholder="${esc(f.ph || tr("Default"))}"></div></div>`;
case "icon":
return `<div class="field">${label}<div class="icon-row"><button type="button" class="icon-prev" data-icon-pick="${id}" aria-label="${tr("Choose an icon")}" title="${tr("Choose an icon")}">${value ? (Icons.has(value) ? Icons.svg(value) : esc(value)) : Icons.svg("search")}</button>
<input class="input" id="${id}" type="text" list="dl-icons" ${data} value="${esc(value ?? "")}" placeholder="${tr("Automatic")}" autocomplete="off"></div></div>`;
case "entity": {
const s = ha.states[value];
return `<div class="field">${label}<input class="input" id="${id}" type="text" ${entPickAttrs(f.domains)} ${data} value="${esc(value ?? "")}" placeholder="${f.domains ? f.domains[0] + "." : "domain."}…" autocomplete="off" spellcheck="false">
${s ? `<div class="ent-hint">${esc(friendly(s))} · ${esc(fmtState(s))}</div>` : value ? `<div class="ent-hint" style="color:var(--crit)">${tr("No entity with this ID")}</div>` : ""}</div>`;
}
case "entities": {
const lid = entPickAttrs(f.domains);
const list = Array.isArray(value) ? value : [];
return `<div class="field"><span class="field-label">${esc(f.l)}</span>
${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" ${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" ${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>`;
case "albums":
return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="albums" ${data} data-selected="${esc(JSON.stringify(value || []))}"><div class="muted small">${tr("Loading albums…")}</div></div></div>`;
default:
return `<div class="field">${label}<input class="input" id="${id}" type="text" ${data} value="${esc(value ?? "")}" placeholder="${esc(f.ph || "")}"></div>`;
}
}
function datalists() {
return `<datalist id="dl-icons">${Icons.names.map((n) => `<option value="${n}">`).join("")}</datalist>`;
}
const STYLE_FIELDS = [
{ k: "transparent", t: "check", l: "No background" },
{ k: "bg", t: "color", l: "Background", ph: "Theme" },
{ k: "color", t: "color", l: "Text color", ph: "Theme" },
{ k: "accent", t: "color", l: "Accent color", ph: "Theme" },
];
const COLOR_FIELDS = [
{ k: "bg", t: "color", l: "Background (color or CSS gradient)" },
{ k: "tile", t: "color", l: "Tile color" },
{ k: "text", t: "color", l: "Text color" },
{ k: "text2", t: "color", l: "Secondary text color" },
{ k: "accent", t: "color", l: "Accent color" },
];
const GRID_FIELDS = [
{ k: "cols", t: "number", l: "Columns" },
{ k: "rowHeight", t: "number", l: "Row height (px)" },
{ k: "gap", t: "number", l: "Spacing (px)" },
{ k: "maxWidth", t: "number", l: "Maximum width (px)", ph: "0 = full width" },
];
function renderInspector() {
const el = $("#inspector");
updateEditBar();
if (multi.size > 1) {
const btn = (k, l) => `<button class="btn" data-align="${k}">${tr(l)}</button>`;
el.innerHTML = `<div class="panel-title">${tr("{0} widgets selected", multi.size)}</div>
<div class="section"><div class="section-title">${tr("Align")}</div>
<div class="align-grid">${btn("left", "Left edges")}${btn("right", "Right edges")}${btn("top", "Top edges")}${btn("bottom", "Bottom edges")}${btn("width", "Same width")}${btn("height", "Same height")}</div>
<p class="muted small" style="margin:8px 2px 0">${tr("Same width and height follow the widget you clicked last.")}</p></div>
<div class="insp-actions"><button class="btn" data-tool="dup">${Icons.svg("copy")}${tr("Duplicate")}</button><button class="btn" data-tool="copyto">${Icons.svg("copy-to")}${tr("Copy to…")}</button><button class="btn danger" data-tool="del">${Icons.svg("trash")}${tr("Delete")}</button></div>
<p class="hint">${tr("Shift-click or ⌘-click widgets to add or remove them. Drag one to move them all; the arrow keys move them too.")}</p>`;
return;
}
const w = tab().widgets.find((x) => x.id === selectedId);
if (w && childIndex != null && w.cfg.children?.[childIndex]) {
// Editing one card inside a container: its own fields and style, with a way back.
const ch = w.cfg.children[childIndex], cdef = Widgets[ch.type];
el.innerHTML = `${datalists()}<button class="btn back" data-child-back>${Icons.svg("chevron", "back")}${esc(tr(Widgets[w.type].label))}</button>
<div class="panel-title">${Icons.svg(cdef.icon)} ${esc(tr(cdef.label))}</div>
${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")}
${w.type === "hscroll" ? fieldHTML({ k: "_width", t: "number", l: "Width of this card (px)", ph: "Same as the others" }, ch.cfg._width, "cfg") : ""}
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}</div>
<div class="insp-actions"><button class="btn" data-child-op="out" data-i="${childIndex}">${Icons.svg("arrow-up")}${tr("Take out of this card")}</button><button class="btn danger" data-child-op="del" data-i="${childIndex}">${Icons.svg("trash")}${tr("Remove from container")}</button></div>`;
return;
}
if (w) {
const def = Widgets[w.type];
el.innerHTML = `${datalists()}<div class="panel-title">${Icons.svg(def.icon)} ${esc(tr(def.label))}</div>
${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")}
${def.container ? childrenHTML(w) : ""}
<div class="section"><div class="section-title">${tr("Size and position")}</div>
<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)}
${holdHTML(w)}
${["button", "entity", "light", "gate", "climate"].includes(w.type) ? `<div class="section"><div class="section-title">${tr("Home screen")}</div>
<div class="field check"><label><input type="checkbox" data-shortcut ${w.shortcut ? "checked" : ""}> ${tr("Shortcut on the app icon")}</label></div>
<p class="muted small" style="margin:-4px 2px 0">${tr("When Dashboard Studio is installed as an app on Android, long-press its icon to run this widget straight away. Up to four shortcuts.")}</p></div>` : ""}
<div class="insp-actions"><button class="btn" data-tool="dup">${Icons.svg("copy")}${tr("Duplicate")}</button><button class="btn" data-tool="copyto">${Icons.svg("copy-to")}${tr("Copy to…")}</button><button class="btn" data-tool="preset">${Icons.svg("bookmark")}${tr("Save as preset")}</button><button class="btn danger" data-tool="del">${Icons.svg("trash")}${tr("Delete")}</button></div>
<p class="hint">${tr("Drag a widget onto a scroll or swipe card to put it inside.")}</p>`;
fillAlbums(el);
return;
}
const t = current.theme, k = current.kiosk;
el.innerHTML = `<div class="panel-title">${tr("Dashboard")}</div>
${fieldHTML({ k: "name", t: "text", l: "Name" }, current.name, "dash")}
<datalist id="dl-icons">${Icons.names.map((n) => `<option value="${n}">`).join("")}</datalist>
<div class="section"><div class="section-title">${tr("Tabs")}</div>
${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")}
${fieldHTML({ k: "style", t: "seg", l: "Menu style", o: ["icons", "labels"] }, current.nav.style || "icons", "nav")}
${current.tabs.length > 1 ? `${fieldHTML({ k: "awayTab", t: "select", l: "When you’re away from home, open", o: ["", ...current.tabs.map((t) => t.id)], labels: { "": tr("Nothing special"), ...Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) } }, current.nav.awayTab || "", "nav")}
${current.nav.awayTab ? fieldHTML({ k: "awayPerson", t: "entity", l: "Away means this person is away", domains: ["person", "device_tracker"], ph: "person." }, current.nav.awayPerson || "", "nav") : ""}
${current.nav.awayTab ? `<p class="muted small" style="margin:-4px 2px 10px">${tr("Leave the person empty to follow whoever is signed in on the device. Not used in kiosk mode.")}</p>` : ""}` : ""}
<div class="tab-list">${current.tabs.map((t, i) => `<div class="tab-item">
<button type="button" class="icon-prev" data-icon-pick="tabicon-${t.id}" aria-label="${tr("Choose an icon")}" title="${tr("Choose an icon")}">${Icons.has(t.icon) ? Icons.svg(t.icon) : esc(t.icon || "")}</button>
<input class="input" type="text" data-path="tab" data-tab="${t.id}" data-k="name" value="${esc(t.name)}" aria-label="${tr("Tab name")}">
<input class="input tab-icon" id="tabicon-${t.id}" type="text" list="dl-icons" data-path="tab" data-tab="${t.id}" data-k="icon" value="${esc(t.icon || "")}" placeholder="${tr("Icon")}" aria-label="${tr("Icon")}" autocomplete="off">
<button class="btn icon" data-tabop="up" data-tab-id="${t.id}" aria-label="${tr("Move up")}" ${i === 0 ? "disabled" : ""}>${Icons.svg("chevron", "flip")}</button>
<button class="btn icon" data-tabop="down" data-tab-id="${t.id}" aria-label="${tr("Move down")}" ${i === current.tabs.length - 1 ? "disabled" : ""}>${Icons.svg("chevron")}</button>
<button class="btn icon danger" data-tabop="del" data-tab-id="${t.id}" aria-label="${esc(tr("Delete tab “{0}”?", t.name))}">${Icons.svg("trash")}</button>
</div>`).join("")}</div>
<button class="btn" data-tabop="add" style="width:100%;margin-top:6px">${Icons.svg("plus")}${tr("Add tab")}</button>
</div>
<div class="section"><div class="section-title">${tr("Appearance")}</div>
<div class="presets" role="group" aria-label="${tr("Theme")}">${Object.entries(PRESETS).map(([n, p]) => {
const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg;
const fg = p.swatch ? "#6e6e73" : p.text;
return `<button class="preset" data-preset="${n}" aria-pressed="${t.preset === n}" style="background:${bg};color:${fg}"><i style="background:${p.accent || "#0a84ff"}"></i><span>${tr(n)}</span></button>`;
}).join("")}</div>
<p class="muted small" style="margin:8px 2px 0">${tr("System follows your device’s light or dark appearance.")}</p>
<div style="height:12px"></div>
${t.preset !== "System" && t.preset !== "Custom" ? fieldHTML({ k: "follow", t: "check", l: "Switch to a dark or light version with the device" }, t.follow, "theme") : ""}
${fieldHTML({ k: "surface", t: "seg", l: "Tile style", o: ["cards", "outline", "plain"] }, t.surface, "theme")}
${fieldHTML({ k: "radius", t: "number", l: "Corner radius (px)" }, t.radius, "theme")}
</div>
<div class="section"><div class="section-title">${tr("Colors")}${t.preset !== "Custom" ? ` <span style="font-weight:400">· ${tr("editing switches to Custom")}</span>` : ""}</div>
${COLOR_FIELDS.map((f) => fieldHTML(f, t.preset === "System" ? "" : t[f.k], "theme")).join("")}
${fieldHTML({ k: "bgImage", t: "text", l: "Background image URL", ph: "/local/wallpaper.jpg" }, t.bgImage, "theme")}
${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>
${fieldHTML({ k: "saver", t: "number", l: "Screen saver after (minutes)", ph: "0 = off" }, k.saver, "kiosk")}
${Number(k.saver) > 0 ? fieldHTML({ k: "saverStyle", t: "seg", l: "Screen saver", o: ["clock", "photos"] }, k.saverStyle, "kiosk") : ""}
${Number(k.saver) > 0 && k.saverStyle === "photos" ? `${fieldHTML({ k: "saverSource", t: "seg", l: "Photos from", o: ["links", "immich"] }, k.saverSource, "kiosk")}
${k.saverSource === "immich" ? fieldHTML({ k: "saverAlbums", t: "albums", l: "Immich albums" }, k.saverAlbums, "kiosk") : fieldHTML({ k: "saverUrls", t: "textarea", l: "Image URLs, one per line" }, k.saverUrls, "kiosk")}
${fieldHTML({ k: "saverInterval", t: "number", l: "Change photo every (seconds)" }, k.saverInterval, "kiosk")}` : ""}
${fieldHTML({ k: "exitCorner", t: "select", l: "Exit corner (tap it 5 times to leave)", o: ["top-right", "top-left", "bottom-right", "bottom-left"] }, k.exitCorner, "kiosk")}
${fieldHTML({ k: "hideCursor", t: "check", l: "Hide the pointer when idle" }, k.hideCursor, "kiosk")}
${current.tabs.length > 1 ? fieldHTML({ k: "rotate", t: "number", l: "Go to the next tab every (seconds)", ph: "0 = off" }, k.rotate, "kiosk") : ""}
${fieldHTML({ k: "announce", t: "check", l: "Show announcements from Home Assistant" }, k.announce, "kiosk")}
${fieldHTML({ k: "clickSound", t: "check", l: "A soft click when something is pressed" }, k.clickSound, "kiosk")}
${fieldHTML({ k: "doorbell", t: "entity", l: "Doorbell", domains: ["binary_sensor", "event", "input_boolean", "switch"] }, k.doorbell, "kiosk")}
${k.doorbell ? `${fieldHTML({ k: "doorbellCamera", t: "entity", l: "Camera to show", domains: ["camera"] }, k.doorbellCamera, "kiosk")}
<div class="row">${fieldHTML({ k: "doorbellSecs", t: "number", l: "Close after (seconds)" }, k.doorbellSecs, "kiosk")}</div>
${fieldHTML({ k: "doorbellSound", t: "check", l: "Play a sound" }, k.doorbellSound, "kiosk")}` : ""}
<p class="muted small" style="margin:-4px 2px 12px">${tr("When the doorbell rings in kiosk mode, the camera fills the screen.")}</p>
<div class="insp-actions"><button class="btn" data-kiosk="here">${Icons.svg("kiosk")}${tr("Start kiosk")}</button><button class="btn" data-kiosk="tab">${Icons.svg("exit")}${tr("New tab")}</button></div>
<p class="muted small" style="margin:8px 2px 0">${tr("On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.")}</p>
</div>
${guestHTML()}
<div class="section"><div class="section-title">${tr("This device")}</div>
${fieldHTML({ k: "lang", t: "select", l: "Language on this device", o: Object.keys(window.I18n.languages), labels: Object.fromEntries(Object.entries(window.I18n.languages).map(([c, n]) => [c, c === "auto" ? tr(n) : n])) }, window.I18n.preference(), "device")}
${nightHTML()}
</div>
<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>
<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>`;
fillAlbums(el);
}
function guestHTML() {
const g = (current.guest ||= { ...DEFAULT_GUEST });
return `<div class="section"><div class="section-title">${tr("Guest mode")}</div>
<p class="muted small" style="margin:0 2px 10px">${tr("A simple screen with only lights and thermostats, for guests or a babysitter.")}</p>
${fieldHTML({ k: "when", t: "seg", l: "Turns on", o: ["manual", "entity", "times"], labels: { manual: tr("With the button"), entity: tr("With an entity"), times: tr("Between times") } }, g.when, "guest")}
${g.when === "entity" ? fieldHTML({ k: "entity", t: "entity", l: "On while this is on", domains: ["input_boolean", "switch", "schedule", "binary_sensor"] }, g.entity, "guest") : ""}
${g.when === "times" ? `<div class="row">${fieldHTML({ k: "from", t: "time", l: "From" }, g.from, "guest")}${fieldHTML({ k: "to", t: "time", l: "Until" }, g.to, "guest")}</div>` : ""}
${fieldHTML({ k: "title", t: "text", l: "Welcome text", ph: tr("Welcome") }, g.title, "guest")}
${fieldHTML({ k: "entities", t: "entities", l: "Lights and thermostats (leave empty for all)", domains: ["light", "climate"] }, g.entities, "guest")}
<div class="insp-actions"><button class="btn" data-guest-toggle>${Icons.svg("person")}${guestManual() ? tr("End guest mode on this device") : tr("Start guest mode on this device")}</button></div></div>`;
}
function nightHTML() {
const n = nightCfg();
return `${fieldHTML({ k: "mode", t: "seg", l: "Night mode", o: ["off", "sunset", "times"], labels: { off: tr("Off"), sunset: tr("After sunset"), times: tr("Between times") } }, n.mode, "night")}
${n.mode === "times" ? `<div class="row">${fieldHTML({ k: "from", t: "time", l: "From" }, n.from, "night")}${fieldHTML({ k: "to", t: "time", l: "Until" }, n.to, "night")}</div>` : ""}
${n.mode !== "off" ? `${fieldHTML({ k: "dim", t: "number", l: "Dim the screen (%)" }, n.dim, "night")}${fieldHTML({ k: "dark", t: "check", l: "Switch to the dark theme" }, n.dark, "night")}` : ""}
<p class="muted small" style="margin:-4px 2px 12px">${tr("Night mode is saved on this device only, and is off while editing.")}</p>
<div class="field check"><label><input type="checkbox" data-haptics ${lsGet("ds.haptics") === "0" ? "" : "checked"}> ${tr("Vibrate briefly when you press something (phones that support it)")}</label></div>`;
}
/** Album pickers load their list from the server after the inspector is drawn. */
async function fillAlbums(root) {
for (const box of root.querySelectorAll(".albums")) {
const selected = new Set(JSON.parse(box.dataset.selected || "[]"));
try {
const albums = await api("api/immich/albums");
if (!box.isConnected) return;
box.innerHTML = albums.length
? albums.map((a) => `<label class="album"><input type="checkbox" value="${esc(a.id)}" ${selected.has(a.id) ? "checked" : ""}><span class="grow">${esc(a.name)}</span><span class="muted small">${a.count}</span></label>`).join("")
: `<div class="muted small">${tr("No albums found in Immich.")}</div>`;
} catch (e) {
box.innerHTML = `<div class="small" style="color:var(--crit)">${esc(e.message)}</div>`;
}
}
}
/** The list of cards inside a container, with add / edit / reorder / remove. */
function childrenHTML(w) {
const kids = w.cfg.children || [];
const summary = (c) => c.cfg.name || c.cfg.title || c.cfg.text || c.cfg.label || c.cfg.entity || (c.cfg.entities || [])[0] || "";
const types = Object.entries(Widgets).filter(([, d]) => !d.container);
return `<div class="section"><div class="section-title">${tr("Cards")}</div>
${kids.length ? "" : `<p class="muted small" style="margin:0 2px 10px">${tr("Add the cards this container should hold.")}</p>`}
<div class="child-list">${kids.map((c, i) => `<div class="child-item">
<button class="child-open" data-child-edit="${i}">${Icons.svg(Widgets[c.type]?.icon || "tile")}<span class="grow"><span>${esc(tr(Widgets[c.type]?.label || c.type))}</span><span class="muted small">${esc(summary(c))}</span></span>${Icons.svg("chevron", "fwd")}</button>
<button class="btn icon" data-child-op="up" data-i="${i}" aria-label="${tr("Move up")}" ${i === 0 ? "disabled" : ""}>${Icons.svg("chevron", "flip")}</button>
<button class="btn icon" data-child-op="down" data-i="${i}" aria-label="${tr("Move down")}" ${i === kids.length - 1 ? "disabled" : ""}>${Icons.svg("chevron")}</button>
</div>`).join("")}</div>
<div class="row" style="margin-top:8px"><select class="input" id="child-type" aria-label="${tr("Card type")}">${types.map(([k, d]) => `<option value="${k}">${esc(tr(d.label))}</option>`).join("")}</select>
<button class="btn" data-child-op="add" style="flex:none">${Icons.svg("plus")}${tr("Add")}</button></div></div>`;
}
function childOp(op, i) {
const w = selectedWidget();
const kids = (w.cfg.children ||= []);
if (op === "add") {
const type = $("#child-type").value, c = makeWidget(type);
mutate(() => kids.push({ id: c.id, type, cfg: c.cfg, style: { transparent: false, bg: "", color: "", accent: "" } }));
childIndex = kids.length - 1; // go straight to setting it up
} else if (op === "up" || op === "down") {
const j = op === "up" ? i - 1 : i + 1;
if (j < 0 || j >= kids.length) return;
mutate(() => { [kids[i], kids[j]] = [kids[j], kids[i]]; });
} else if (op === "del") {
mutate(() => kids.splice(i, 1));
childIndex = null;
} else if (op === "out") { // back onto the dashboard as a widget of its own
const c = kids[i], def = Widgets[c.type];
const nw = { ...c, w: Math.min(c.w || def.size[0], current.grid.cols), h: c.h || def.size[1] };
delete nw.cfg._width;
Object.assign(nw, findSpot(nw.w, nw.h));
mutate(() => { kids.splice(i, 1); tab().widgets.push(nw); });
childIndex = null;
select(nw.id);
return;
}
renderInspector();
}
/** What touching and holding a widget does: its details by default, or a second action. */
function holdHTML(w) {
if (Widgets[w.type]?.container) return "";
const h = w.hold || {};
return `<div class="section"><div class="section-title">${tr("Touch and hold")}</div>
${fieldHTML({ k: "action", t: "select", l: "Touch and hold to", o: ["details", "toggle", "service", "none"], labels: { details: tr("Show the details"), toggle: tr("Switch on or off"), service: tr("Run an action"), none: tr("Do nothing") } }, h.action || "details", "hold")}
${h.action === "service" ? `${fieldHTML({ k: "service", t: "text", l: "Action (e.g. scene.turn_on)", ph: "script.good_night" }, h.service, "hold")}${fieldHTML({ k: "entity", t: "entity", l: "Target entity (optional)" }, h.entity, "hold")}${fieldHTML({ k: "data", t: "json", l: "Action data (JSON)" }, h.data, "hold")}` : ""}</div>`;
}
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" ${entPickAttrs()} 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>
${fieldHTML({ k: "screen", t: "seg", l: "Show on", o: ["all", "phone", "large"], labels: { all: tr("All screens"), phone: tr("Phones"), large: tr("Larger screens") } }, vis.screen || "all", "vis")}
${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") {
const tb = current.tabs.find((x) => x.id === t.dataset.tab);
mutate(() => { tb[k] = v; }, `tab|${tb.id}|${k}`, { rerender: false });
renderNav();
if (isChange && k === "icon") renderInspector();
return;
}
if (path === "hold") {
const w = selectedWidget();
if (t.dataset.json && v) { try { JSON.parse(v); t.classList.remove("invalid"); } catch { t.classList.add("invalid"); return; } }
mutate(() => { (w.hold ||= {})[k] = v; }, `${w.id}|hold|${k}`, { rerender: false });
if (isChange && k === "action") 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 === "layout") { convertLayout(!!v); renderInspector(); return; }
if (path === "nav") {
mutate(() => { current.nav[k] = v; });
if (k === "awayTab" || k === "awayPerson") { awayState = null; if (isChange) renderInspector(); }
return;
}
if (path === "move") {
const w = tab().widgets.find((x) => x.id === selectedId), target = current.tabs.find((x) => x.id === v);
if (!w || !target || target === tab()) return;
mutate(() => {
tab().widgets = tab().widgets.filter((x) => x !== w);
Object.assign(w, findSpot(w.w, w.h, target.widgets));
target.widgets.push(w);
});
select(null);
toast(tr("Moved to “{0}”.", target.name));
return;
}
if (path === "guest") {
const g = current.guest;
if (idx === "new") { if (!isChange || !v) return; mutate(() => { (g[k] ||= []).push(v); }, null, { rerender: false }); }
else mutate(() => { if (idx != null) g[k][Number(idx)] = v; else g[k] = v; }, `guest|${k}|${idx ?? ""}`, { rerender: false });
checkGuest(true);
if (isChange || t.tagName === "BUTTON") renderInspector();
return;
}
if (path === "night") {
const n = nightCfg();
n[k] = v;
lsSet("ds.night", JSON.stringify(n));
applyNight();
if (isChange) renderInspector();
return;
}
if (path === "device") { // per-device language: stored locally, applied by reloading
lsSet("ds.lang", v);
location.reload();
return;
}
const w = editTarget();
const key = `${w?.id}|${path}|${k}|${idx ?? ""}`;
if (idx === "new") {
if (!isChange || !v) return;
mutate(() => { (w.cfg[k] ||= []).push(v); }, null);
renderInspector();
$(`#inspector [data-k="${k}"][data-idx="new"]`)?.focus();
return;
}
if (t.dataset.json && v) {
try { JSON.parse(v); t.classList.remove("invalid"); } catch { t.classList.add("invalid"); return; }
}
mutate(() => {
if (path === "cfg") {
if (idx != null) w.cfg[k][Number(idx)] = v;
else w.cfg[k] = v;
const def = Widgets[w.type];
if (def.autofill && k === def.autofill.from && v && idx == null) {
const fill = def.autofill.fill(v, ha);
for (const [fk, fv] of Object.entries(fill)) if (fv && (w.cfg[fk] == null || w.cfg[fk] === "" || (Array.isArray(w.cfg[fk]) && !w.cfg[fk].length))) w.cfg[fk] = fv;
// The type may change from its default, and a default name follows it (washer → dishwasher).
if (fill.kind && w.cfg.kind !== fill.kind) {
const names = { washer: "Washing machine", dryer: "Dryer", dishwasher: "Dishwasher" };
if (!w.cfg.name || w.cfg.name === tr(names[w.cfg.kind] || "")) w.cfg.name = tr(names[fill.kind]);
w.cfg.kind = fill.kind;
}
}
// A light group lists its lights underneath, which needs room.
if (w.type === "light" && k === "entity" && Array.isArray(ha.states[v]?.attributes.entity_id) && w.h && w.h < 4) { w.h = 4; resolveCollisions(w); }
} else if (path === "style") { (w.style ||= {})[k] = v; }
else if (path === "pos") {
const n = Math.max(k === "w" || k === "h" ? 1 : 0, Math.round(Number(v) || 0));
w[k] = k === "x" || k === "w" ? Math.min(n, current.grid.cols - (k === "x" ? w.w : w.x)) : n;
resolveCollisions(w);
} else if (path === "theme") {
const th = current.theme;
if (COLOR_KEYS.includes(k) && th.preset !== "Custom") {
if (th.preset === "System") Object.assign(th, PRESETS[prefersDark() ? "Dark" : "Light"]);
th.preset = "Custom";
}
th[k] = v;
} else if (path === "grid") { current.grid[k] = v === "" ? 0 : v; }
else if (path === "kiosk") { current.kiosk[k] = v; }
else if (path === "dash") { current.name = v; renderDashSelect(); }
}, key, { rerender: ["pos", "theme", "grid"].includes(path) || (path === "cfg" && k === "entity") });
if (path === "cfg" || path === "style") renderWidget(selectedWidget());
if (path === "kiosk") {
doorbellPrev = ha.states[current.kiosk.doorbell]?.state;
if (isChange && ["saver", "saverStyle", "saverSource", "doorbell"].includes(k)) renderInspector();
return;
}
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
if (w && isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON" || Widgets[w.type].autofill?.from === k)) renderInspector();
if (path === "theme" && (t.tagName === "BUTTON" || (isChange && COLOR_KEYS.includes(k)))) renderInspector();
}
function onInspectorInput(e) {
const t = e.target;
if (t.matches?.("[data-haptics]")) { if (e.type === "change") { lsSet("ds.haptics", t.checked ? "1" : "0"); haptic(); } return; }
if (t.matches?.("[data-shortcut]")) {
if (e.type !== "change") return;
const w = selectedWidget(), s0 = ha.states[w.cfg.entity];
mutate(() => { if (t.checked) w.shortcut = w.cfg.name || w.cfg.label || (s0 ? friendly(s0) : tr(Widgets[w.type].label)); else delete w.shortcut; }, null, { rerender: false });
return;
}
const albums = t.closest?.(".albums");
if (albums) {
if (e.type !== "change") return;
const ids = [...albums.querySelectorAll("input:checked")].map((i) => i.value);
applyField({ dataset: { path: albums.dataset.path || "cfg", k: albums.dataset.k }, tagName: "DIV", classList: { add() {}, remove() {} } }, ids, false);
return;
}
if (t.dataset.sync) { // color swatch -> text field
const txt = document.getElementById(t.dataset.sync);
txt.value = t.value;
applyField(txt, t.value, e.type === "change");
return;
}
if (!t.dataset.path) return;
let v = t.type === "checkbox" ? t.checked : t.value;
if (t.dataset.num) v = v === "" ? "" : Number(v);
applyField(t, v, e.type === "change");
}
// ------------------------------------------------------------ layout ops
const overlap = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
// Push anything overlapping `moved` downward, cascading.
function resolveCollisions(moved) {
const queue = [moved];
let guard = 0;
while (queue.length && guard++ < 2000) {
const a = queue.shift();
for (const b of tab().widgets) {
if (b !== a && overlap(a, b)) { b.y = a.y + a.h; queue.push(b); }
}
}
}
/** 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, cols = current.grid.cols) {
for (let y = 0; y < 500; y++)
for (let x = 0; x + w <= cols; x++)
if (!widgets.some((o) => overlap({ x, y, w, h }, o))) return { x, y };
return { x: 0, y: 0 };
}
// On a phone, the widget list and the settings are sheets that slide up from the bottom.
const phoneEditing = () => editMode && matchMedia("(max-width: 640px)").matches;
function openSheet(name) {
document.body.classList.toggle("sheet-palette", name === "palette");
document.body.classList.toggle("sheet-inspector", name === "inspector");
$("#sheet-backdrop").hidden = !name;
}
function updateEditBar() {
$("#eb-insp").innerHTML = `${Icons.svg(selectedId ? "edit" : "sliders")}${esc(selectedId ? tr("Edit") : tr("Settings"))}`;
}
$("#eb-add").onclick = () => openSheet("palette");
$("#eb-insp").onclick = () => openSheet("inspector");
$("#sheet-backdrop").onclick = () => openSheet(null);
document.querySelectorAll(".sheet-close").forEach((b) => (b.onclick = () => openSheet(null)));
function addWidget(type) {
const wd = makeWidget(type);
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);
if (phoneEditing()) openSheet(null);
gridEl.querySelector(`.w[data-id="${wd.id}"]`)?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function duplicate(id) {
const src = tab().widgets.find((w) => w.id === id);
if (!src) return;
const copy = freshCopy(src);
Object.assign(copy, findSpot(copy.w, copy.h));
mutate(() => tab().widgets.push(copy));
select(copy.id);
}
function remove(id) {
mutate(() => { tab().widgets = tab().widgets.filter((w) => w.id !== id); });
delete caches[id];
select(null);
toast(tr("Widget deleted. Press ⌘Z to undo."));
}
/** Several widgets at once: copies keep their layout and land below everything else. */
function duplicateMany(ids) {
const src = tab().widgets.filter((w) => ids.includes(w.id));
if (!src.length) return;
const top = Math.min(...src.map((w) => w.y)), bottom = Math.max(...tab().widgets.map((w) => w.y + w.h));
const copies = src.map((w) => { const c = freshCopy(w); c.y += bottom - top; return c; });
mutate(() => tab().widgets.push(...copies));
select(null);
copies.forEach((c) => select(c.id, true));
}
function removeMany(ids) {
mutate(() => { tab().widgets = tab().widgets.filter((w) => !ids.includes(w.id)); });
ids.forEach((id) => delete caches[id]);
select(null);
toast(tr("{0} widgets deleted. Press ⌘Z to undo.", ids.length));
}
/** A copy with new ids, for the widget and every card inside it. */
function freshCopy(w) {
const c = { ...clone(w), id: uid() };
(c.cfg.children || []).forEach((ch) => (ch.id = uid()));
return c;
}
/** Line up the selected widgets with each other, or give them the size of the one clicked last. */
function alignSelection(how) {
const ws = selection(), ref = selectedWidget();
if (ws.length < 2) return;
const minX = Math.min(...ws.map((w) => w.x)), maxR = Math.max(...ws.map((w) => w.x + w.w));
const minY = Math.min(...ws.map((w) => w.y)), maxB = Math.max(...ws.map((w) => w.y + w.h));
mutate(() => {
for (const w of ws) {
if (how === "left") w.x = minX;
else if (how === "right") w.x = maxR - w.w;
else if (how === "top") w.y = minY;
else if (how === "bottom") w.y = maxB - w.h;
else if (how === "width") w.w = Math.min(ref.w, current.grid.cols - w.x);
else if (how === "height") w.h = ref.h;
}
// Widgets that now overlap are stacked rather than drawn on top of each other.
[...ws].sort((a, b) => a.y - b.y || a.x - b.x).forEach(resolveCollisions);
});
}
/** Copy widgets to another tab, here or on another dashboard. */
async function copyTo(ids) {
const src = tab().widgets.filter((w) => ids.includes(w.id));
if (!src.length) return;
const opts = store.dashboards.map((d) => `<optgroup label="${esc(d.name)}">${d.tabs.map((t) =>
`<option value="${d.id}|${t.id}" ${d.id === current.id && t.id === tab().id ? "selected" : ""}>${esc(d.tabs.length > 1 ? t.name : d.name)}</option>`).join("")}</optgroup>`).join("");
const v = await openModal(`<h3>${src.length > 1 ? tr("Copy {0} widgets to…", src.length) : tr("Copy to…")}</h3>
<select class="input" id="copy-target" style="margin-top:12px" aria-label="${tr("Dashboard")}">${opts}</select>
<div class="modal-actions"><button class="btn" data-x="cancel">${tr("Cancel")}</button><button class="btn primary" data-x="ok">${tr("Copy")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok" ? $("#copy-target").value : null); });
if (!v) return;
const [did, tid] = v.split("|");
const d = store.dashboards.find((x) => x.id === did), t = d?.tabs.find((x) => x.id === tid);
if (!t) return;
const cols = Number(d.grid?.cols) || 12;
const put = () => {
for (const w of [...src].sort((a, b) => a.y - b.y || a.x - b.x)) {
const c = freshCopy(w);
c.w = Math.min(c.w, cols);
Object.assign(c, findSpot(c.w, c.h, t.widgets, cols));
t.widgets.push(c);
}
};
if (d === current) mutate(put);
else { put(); scheduleSave(); }
toast(tr("Copied to “{0}”.", d.tabs.length > 1 ? `${d.name} · ${t.name}` : d.name));
}
function select(id, toggle = false) {
if (toggle && id) {
if (selectedId && !multi.size) multi.add(selectedId);
multi.has(id) ? multi.delete(id) : multi.add(id);
selectedId = multi.has(id) ? id : [...multi].pop() ?? null;
if (multi.size < 2) multi.clear();
childIndex = null;
} else {
if (id !== selectedId) childIndex = null;
selectedId = id;
multi.clear();
}
gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", isSel(el.dataset.id)));
renderInspector();
}
// ------------------------------------------------------------ entity picker
// Entity fields get a list with each entity's icon, name and id, filtered as you type.
const entPop = document.createElement("div");
entPop.id = "ent-pop"; entPop.className = "ent-pop"; entPop.setAttribute("role", "listbox"); entPop.hidden = true;
document.body.appendChild(entPop);
let entFor = null, entItems = [], entActive = 0;
function entOpen(input, typed) {
entFor = input;
const doms = input.dataset.entPick ? input.dataset.entPick.split(",") : null;
const q = (typed || !ha.states[input.value] ? input.value : "").trim().toLowerCase();
const rank = (s) => { const n = friendly(s).toLowerCase(), id = s.entity_id.toLowerCase(); return !q ? 0 : n.startsWith(q) || id.split(".")[1]?.startsWith(q) ? 0 : 1; };
entItems = Object.values(ha.states)
.filter((s) => (!doms || doms.includes(domainOf(s.entity_id))) && (!q || s.entity_id.toLowerCase().includes(q) || friendly(s).toLowerCase().includes(q)))
.sort((a, b) => rank(a) - rank(b) || friendly(a).localeCompare(friendly(b)))
.slice(0, 80);
entActive = 0;
entDraw();
}
function entDraw() {
if (!entFor) return;
entPop.innerHTML = entItems.length
? entItems.map((s, i) => `<div class="ep-item ${i === entActive ? "on" : ""}" role="option" id="ep-${i}" aria-selected="${i === entActive}" data-eid="${esc(s.entity_id)}">${window.HAFmt.icon(s)}<span class="ep-text"><span class="ep-name">${esc(friendly(s))}</span><span class="ep-id">${esc(s.entity_id)}</span></span><span class="ep-state">${esc(fmtState(s))}</span></div>`).join("")
: `<div class="ep-none">${tr("No matching entities")}</div>`;
const r = entFor.getBoundingClientRect(), below = window.innerHeight - r.bottom;
entPop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - Math.max(r.width, 300) - 8))}px`;
entPop.style.width = `${Math.max(r.width, 300)}px`;
entPop.style.maxHeight = `${Math.max(160, Math.min(320, below > 200 ? below - 12 : r.top - 12))}px`;
entPop.hidden = false;
if (below > 200) { entPop.style.top = `${r.bottom + 4}px`; entPop.style.bottom = ""; }
else { entPop.style.top = ""; entPop.style.bottom = `${window.innerHeight - r.top + 4}px`; }
entFor.setAttribute("aria-expanded", "true");
entFor.setAttribute("aria-activedescendant", entItems.length ? `ep-${entActive}` : "");
entPop.querySelector(".ep-item.on")?.scrollIntoView({ block: "nearest" });
}
function entClose() {
entPop.hidden = true;
entFor?.setAttribute("aria-expanded", "false");
entFor = null;
}
function entPick(eid) {
const input = entFor;
entClose();
if (!input || !eid) return;
input.value = eid;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}
document.addEventListener("focusin", (e) => { if (e.target.matches?.("[data-ent-pick]")) entOpen(e.target, false); else if (entFor && !entPop.contains(e.target)) entClose(); });
document.addEventListener("input", (e) => { if (e.target === entFor && e.isTrusted) entOpen(entFor, true); });
document.addEventListener("focusout", (e) => { if (e.target === entFor) setTimeout(() => { if (entFor && document.activeElement !== entFor) entClose(); }, 0); });
document.addEventListener("keydown", (e) => {
if (!entFor || e.target !== entFor) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (entPop.hidden) { entOpen(entFor, false); return; }
entActive = (entActive + (e.key === "ArrowDown" ? 1 : -1) + entItems.length) % Math.max(1, entItems.length);
entDraw();
} else if (e.key === "Enter" && !entPop.hidden && entItems[entActive]) { e.preventDefault(); entPick(entItems[entActive].entity_id); }
else if (e.key === "Escape" && !entPop.hidden) { e.preventDefault(); e.stopPropagation(); entPop.hidden = true; entFor.setAttribute("aria-expanded", "false"); }
}, true);
entPop.addEventListener("pointerdown", (e) => e.preventDefault()); // keep the focus in the field
entPop.addEventListener("click", (e) => { const it = e.target.closest("[data-eid]"); if (it) entPick(it.dataset.eid); });
document.addEventListener("scroll", (e) => { if (entFor && !entPop.contains(e.target)) entDraw(); }, true);
// ------------------------------------------------------------ drag / resize
let drag = null;
gridEl.addEventListener("pointerdown", (e) => {
if (!editMode || e.button !== 0) return;
const el = e.target.closest(".w");
if (!el) { select(null); return; }
if (e.target.closest("[data-tool]")) return;
const w = tab().widgets.find((x) => x.id === el.dataset.id);
if (e.shiftKey || e.metaKey || e.ctrlKey) { select(w.id, true); e.preventDefault(); return; }
// By touch, the first tap selects (so the page can still scroll); a selected widget drags.
if (e.pointerType === "touch" && w.id !== selectedId && !multi.has(w.id)) { select(w.id); return; }
const mode = e.target.closest(".w-resize") ? "resize" : "move";
if (multi.has(w.id) && mode === "move") { startGroupDrag(e, el); return; }
if (selectedId !== w.id) select(w.id);
const ph = document.createElement("div");
ph.className = "placeholder";
gridEl.appendChild(ph);
placeEl(ph, w);
drag = { w, el, ph, mode, touch: e.pointerType === "touch", sx: e.clientX, sy: e.clientY, orig: { x: w.x, y: w.y, w: w.w, h: w.h }, next: { x: w.x, y: w.y, w: w.w, h: w.h }, r0: rectFor(w) };
el.classList.add("dragging");
el.setPointerCapture(e.pointerId);
e.preventDefault();
});
/** Dragging one of several selected widgets moves them all, keeping their layout. */
function startGroupDrag(e, el) {
const items = selection().map((w) => {
const wel = gridEl.querySelector(`.w[data-id="${w.id}"]`), ph = document.createElement("div");
ph.className = "placeholder";
gridEl.appendChild(ph);
placeEl(ph, w);
wel?.classList.add("dragging");
return { w, el: wel, ph, r0: rectFor(w) };
});
drag = { group: items, sx: e.clientX, sy: e.clientY, cx: 0, cy: 0 };
el.setPointerCapture(e.pointerId);
e.preventDefault();
}
function moveGroup(e) {
const { colW, gap, rh, cols } = metrics, ws = drag.group.map((g) => g.w);
const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
const minX = Math.min(...ws.map((w) => w.x)), maxR = Math.max(...ws.map((w) => w.x + w.w)), minY = Math.min(...ws.map((w) => w.y));
drag.cx = Math.max(-minX, Math.min(cols - maxR, Math.round(dx / (colW + gap))));
drag.cy = Math.max(-minY, Math.round(dy / (rh + gap)));
const pos = positions();
for (const g of drag.group) {
if (g.el) { g.el.style.left = `${g.r0.left + dx}px`; g.el.style.top = `${g.r0.top + dy}px`; }
const n = { x: g.w.x + drag.cx, y: g.w.y + drag.cy, w: g.w.w, h: g.w.h };
placeEl(g.ph, n);
pos[g.w.id] = n;
}
updateGridHeight(pos);
}
/** The scroll or swipe card under the pointer, if a dragged widget could be dropped into it. */
function containerAt(x, y) {
for (const n of document.elementsFromPoint(x, y)) {
for (let c = n.closest?.(".w"); c; c = c.parentElement?.closest(".w")) {
if (c === drag.el) continue;
const t = tab().widgets.find((o) => o.id === c.dataset.id);
if (t && Widgets[t.type]?.container) return c;
}
}
return null;
}
gridEl.addEventListener("pointermove", (e) => {
if (!drag) return;
if (drag.group) { moveGroup(e); return; }
const { colW, gap, rh, cols } = metrics;
const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
const o = drag.orig, n = drag.next;
if (drag.mode === "move") {
drag.el.style.left = `${drag.r0.left + dx}px`;
drag.el.style.top = `${drag.r0.top + dy}px`;
n.x = Math.max(0, Math.min(cols - o.w, o.x + Math.round(dx / (colW + gap))));
n.y = Math.max(0, o.y + Math.round(dy / (rh + gap)));
} else {
drag.el.style.width = `${Math.max(colW, drag.r0.width + dx)}px`;
drag.el.style.height = `${Math.max(rh, drag.r0.height + dy)}px`;
n.w = Math.max(1, Math.min(cols - o.x, o.w + Math.round(dx / (colW + gap))));
n.h = Math.max(1, o.h + Math.round(dy / (rh + gap)));
}
placeEl(drag.ph, n);
showGuides(n, drag.w);
if (drag.mode === "move" && !Widgets[drag.w.type]?.container) {
const into = containerAt(e.clientX, e.clientY);
if (into !== drag.into) { drag.into?.classList.remove("drop-into"); into?.classList.add("drop-into"); drag.into = into; }
drag.ph.hidden = !!into;
}
const pos = positions(); pos[drag.w.id] = n;
updateGridHeight(pos);
});
/** Lines where the dragged widget's edges line up with other widgets, like in design apps. */
function showGuides(n, w) {
gridEl.querySelectorAll(".guide").forEach((g) => g.remove());
const { colW, gap, rh } = metrics;
const others = tab().widgets.filter((o) => o !== w);
const xs = new Set(others.flatMap((o) => [o.x, o.x + o.w])), ys = new Set(others.flatMap((o) => [o.y, o.y + o.h]));
const line = (cls, px) => { const g = document.createElement("div"); g.className = `guide ${cls}`; g.style[cls === "v" ? "left" : "top"] = `${Math.max(0, px)}px`; gridEl.appendChild(g); };
for (const x of new Set([n.x, n.x + n.w])) if (xs.has(x)) line("v", x * (colW + gap) - gap / 2);
for (const y of new Set([n.y, n.y + n.h])) if (ys.has(y)) line("h", y * (rh + gap) - gap / 2);
}
function endDrag() {
gridEl.querySelectorAll(".guide").forEach((g) => g.remove());
if (!drag) return;
if (drag.group) {
const { group, cx, cy } = drag;
drag = null;
group.forEach((g) => { g.ph.remove(); g.el?.classList.remove("dragging"); });
if (!cx && !cy) { group.forEach((g) => g.el && placeEl(g.el, g.w)); return; }
mutate(() => {
group.forEach((g) => { g.w.x += cx; g.w.y += cy; });
group.map((g) => g.w).sort((a, b) => a.y - b.y).forEach(resolveCollisions);
});
return;
}
const { w, ph, el, orig, next, into, touch: touchEnd } = drag;
ph.remove();
el.classList.remove("dragging");
into?.classList.remove("drop-into");
drag = null;
if (into) { // dropped onto a scroll or swipe card: it becomes one of its cards
const box = tab().widgets.find((o) => o.id === into.dataset.id);
mutate(() => {
tab().widgets = tab().widgets.filter((o) => o !== w);
(box.cfg.children ||= []).push({ id: w.id, type: w.type, cfg: w.cfg, style: w.style, w: w.w, h: w.h, ...(w.visibility ? { visibility: w.visibility } : {}) });
});
select(box.id);
toast(tr("Moved into “{0}”.", tr(Widgets[box.type].label)));
return;
}
if (orig.x === next.x && orig.y === next.y && orig.w === next.w && orig.h === next.h) {
placeEl(el, w);
if (touchEnd && phoneEditing()) openSheet("inspector"); // tapping the selected widget opens its settings
return;
}
mutate(() => { Object.assign(w, next); resolveCollisions(w); });
if (!multi.has(w.id)) select(w.id);
}
gridEl.addEventListener("pointerup", endDrag);
gridEl.addEventListener("pointercancel", endDrag);
// ------------------------------------------------------------ actions (view mode)
function defaultAction(eid) {
const d = domainOf(eid), s = ha.states[eid];
switch (d) {
case "scene": case "script": return ha.callService(d, "turn_on", {}, eid);
case "button": case "input_button": return ha.callService(d, "press", {}, eid);
case "lock": return ha.callService("lock", s?.state === "locked" ? "unlock" : "lock", {}, eid);
case "cover": return ha.callService("cover", "toggle", {}, eid);
case "media_player": return ha.callService("media_player", "media_play_pause", {}, eid);
case "climate": return ha.callService("climate", s?.state === "off" ? "turn_on" : "turn_off", {}, eid);
default: return ha.callService("homeassistant", "toggle", {}, eid);
}
}
gridEl.addEventListener("click", (e) => {
if (!editMode && e.target.closest("[data-act]")) haptic();
if (editMode) {
const tool = e.target.closest("[data-tool]");
if (tool) {
const id = tool.closest(".w").dataset.id;
toolAction(tool.dataset.tool, multi.has(id) ? [...multi] : [id]);
}
return;
}
const a = e.target.closest("[data-act]");
if (!a || a.tagName === "INPUT") return;
const eid = a.dataset.e, act = a.dataset.act;
if (act === "toggle") defaultAction(eid);
else if (act === "more") moreInfo(eid);
else if (act === "temp") {
const s = ha.states[eid];
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 === "gate") gateAction(eid, a.dataset.v === "1");
else if (act === "card-tab") { // a card's own tabs (Formula One): kept in its cache, not saved
const id = a.closest(".w[data-id]")?.dataset.id;
(caches[id] ||= {}).tab = a.dataset.v;
const top = tab().widgets.find((w) => w.id === id || findWidget(id, w.cfg.children || []));
if (top) renderWidget(top);
}
else if (act === "light-color") {
const [kind, val] = a.dataset.v.split(":");
ha.callService("light", "turn_on", kind === "k" ? { color_temp_kelvin: Number(val) } : { rgb_color: val.split(",").map(Number) }, eid);
}
else if (act === "light-popup") lightPopup(eid);
else if (act === "todo-add") todoAdd(a.closest(".todo-add"));
else if (act === "alarm-key") {
const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id], k = a.dataset.v;
cache.code = k === "C" ? "" : k === "⌫" ? (cache.code || "").slice(0, -1) : ((cache.code || "") + k).slice(0, 12);
renderWidget(w, box, { child: box.classList.contains("child") });
}
else if (act === "alarm") alarmAction(a, eid);
else if (act === "note-dismiss") ha.callService("persistent_notification", "dismiss", { notification_id: a.dataset.v });
else if (act === "note-dismiss-all") ha.callService("persistent_notification", "dismiss_all", {});
else if (act === "update-install") {
const s = ha.states[eid];
confirmAction(tr("Install {0}?", s?.attributes.title || friendly(s)), tr("Version {0} will be installed. Home Assistant may restart.", s?.attributes.latest_version || ""), tr("Install"))
.then((ok) => { if (ok) ha.callService("update", "install", {}, eid); });
}
else if (act === "todo-toggle" || act === "todo-del") {
const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id], item = (cache.items || []).find((i) => i.uid === a.dataset.v);
if (!item) return;
const call = act === "todo-del" ? ha.callService("todo", "remove_item", { item: item.uid }, eid)
: ha.callService("todo", "update_item", { item: item.uid, status: item.status === "completed" ? "needs_action" : "completed" }, eid);
item.status = act === "todo-del" ? "deleted" : item.status === "completed" ? "needs_action" : "completed"; // show it right away
if (act === "todo-del") cache.items = cache.items.filter((i) => i !== item);
renderWidget(w, box, { child: box.classList.contains("child") });
call.then(() => { cache.bump = (cache.bump || 0) + 1; renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") }); });
}
else if (act === "todo-showdone") {
const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id];
cache.showDone = !(cache.showDone ?? w.cfg.showDone);
renderWidget(w, box, { child: box.classList.contains("child") });
}
else if (act === "timer") {
const mins = Number(a.dataset.d);
const data = mins > 0 ? { duration: `${String(Math.floor(mins / 60)).padStart(2, "0")}:${String(mins % 60).padStart(2, "0")}:00` } : {};
ha.callService("timer", a.dataset.v, data, eid);
}
else if (act === "timer-ok") {
const box = a.closest(".w"), w = findWidget(box.dataset.id);
caches[w.id].doneUntil = 0;
renderWidget(w, box, { child: box.classList.contains("child") });
}
else if (act === "vac-fan") ha.callService("vacuum", "set_fan_speed", { fan_speed: a.dataset.v }, eid);
else if (act === "mp-select") {
const box = a.closest(".w"), w = findWidget(box.dataset.id);
caches[w.id].sel = a.dataset.v;
renderWidget(w, box, { child: box.classList.contains("child") });
box.querySelector(`[data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus();
}
else if (act === "hs-prev" || act === "hs-next") {
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
}
else if (act === "sw-go") caches[a.closest(".w:not(.child)").dataset.id]?.go?.(Number(a.dataset.v));
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
else if (act === "button") {
if (runButton(findWidget(a.closest(".w").dataset.id).cfg)) a.animate?.([{ transform: "scale(.96)" }, { transform: "scale(1)" }], 160);
}
});
gridEl.addEventListener("focusout", (e) => {
const box = e.target.closest?.(".w");
if (!box || !e.target.matches("input[type=text]")) return;
const w = findWidget(box.dataset.id), cache = w && caches[w.id];
if (cache?.typing) { cache.typing = false; setTimeout(() => renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") }), 150); }
});
gridEl.addEventListener("input", (e) => {
const t = e.target;
if (!t.dataset.act) return;
interacting = true;
t.style.setProperty("--pct", `${t.value}%`);
});
gridEl.addEventListener("change", (e) => {
const t = e.target, eid = t.dataset.e;
if (!editMode) haptic();
interacting = false;
if (t.dataset.act === "brightness") ha.callService("light", "turn_on", { brightness_pct: Number(t.value) }, eid);
if (t.dataset.act === "volume") ha.callService("media_player", "volume_set", { volume_level: Number(t.value) / 100 }, eid);
if (t.dataset.act === "cover-pos") ha.callService("cover", "set_cover_position", { position: Number(t.value) }, eid);
if (t.dataset.act === "cover-tilt") ha.callService("cover", "set_cover_tilt_position", { tilt_position: Number(t.value) }, eid);
});
// ------------------------------------------------------------ dialogs
let modalClose = null;
function openModal(html, onClick) {
const card = $("#modal .modal-card");
card.innerHTML = html;
$("#modal").hidden = false;
const prevFocus = document.activeElement;
return new Promise((resolve) => {
modalClose = (result) => {
$("#modal").hidden = true;
modalClose = null;
prevFocus?.focus?.();
resolve(result);
};
card.onclick = (e) => onClick?.(e, modalClose);
(card.querySelector("[autofocus]") || card.querySelector("button"))?.focus();
});
}
$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") modalClose?.(null); });
/** A short sheet with a text field. Resolves to the text, or null when cancelled. */
function askText(title, value, confirmLabel) {
return openModal(`<h3>${esc(title)}</h3>
<form id="ask-form"><input class="input" id="ask-input" type="text" value="${esc(value)}" autofocus style="margin-top:12px">
<div class="modal-actions"><button type="button" class="btn" data-x="cancel">${tr("Cancel")}</button><button type="submit" class="btn primary">${esc(confirmLabel)}</button></div></form>`,
(e, close) => { if (e.target.dataset.x === "cancel") close(null); });
}
document.addEventListener("submit", (e) => {
if (e.target.id !== "ask-form") return;
e.preventDefault();
const v = $("#ask-input").value.trim();
if (v) modalClose?.(v);
});
/** Like confirmDestructive, for actions that aren't destructive. */
/** A button widget's action: its own action with data, or the default for its entity. */
function runButton(c) {
let data = {};
if (c.data) { try { data = JSON.parse(c.data); } catch { toast(tr("The button’s action data isn’t valid JSON. Fix it in the inspector."), true); return false; } }
if (c.service && c.service.includes(".")) { const [d, sv] = c.service.split("."); ha.callService(d, sv, data, c.entity || undefined); }
else if (c.entity) defaultAction(c.entity);
else { toast(tr("This button has no action yet. Set one in the inspector."), true); return false; }
return true;
}
/** A home screen shortcut (long-press the app icon): runs a widget's main action, wherever it is. */
async function runShortcut(id) {
let w = null;
for (const d of store.dashboards) for (const t of d.tabs) w ||= findWidget(id, t.widgets);
if (!w) { toast(tr("That shortcut’s widget no longer exists."), true); return; }
await ha.ready;
const eid = w.cfg.entity || (w.cfg.entities || [])[0];
if (w.type === "button") { if (!runButton(w.cfg)) return; }
else if (w.type === "gate") await gateAction(eid, w.cfg.confirm !== false);
else if (eid) defaultAction(eid);
else return;
toast(tr("Done: {0}", w.shortcut || w.cfg.name || w.cfg.label || friendly(ha.states[eid] || { entity_id: eid })));
}
/** The garage and gate card: open, close or stop, after a check when the card asks for one. */
async function gateAction(eid, ask) {
const s = ha.states[eid];
if (!s) return;
const d = domainOf(eid), open = ["open", "opening", "unlocked", "unlocking", "on"].includes(s.state), moving = ["opening", "closing"].includes(s.state);
const verb = d === "button" ? "Press" : moving ? "Stop" : open ? (d === "lock" ? "Lock" : "Close") : d === "lock" ? "Unlock" : "Open";
if (ask && !(await confirmAction(tr(`${verb} {0}?`, friendly(s)), "", tr(verb)))) return;
if (d === "cover") ha.callService("cover", moving ? "stop_cover" : open ? "close_cover" : "open_cover", {}, eid);
else if (d === "lock") ha.callService("lock", open ? "lock" : "unlock", {}, eid);
else if (d === "button") ha.callService("button", "press", {}, eid);
else ha.callService(d, "toggle", {}, eid);
}
function confirmAction(title, message, label) {
return openModal(`<h3>${esc(title)}</h3><p>${esc(message)}</p>
<div class="modal-actions"><button class="btn" data-x="cancel" autofocus>${tr("Cancel")}</button><button class="btn primary" data-x="ok">${esc(label)}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok"); });
}
function confirmDestructive(title, message, label) {
return openModal(`<h3>${esc(title)}</h3><p>${esc(message)}</p>
<div class="modal-actions"><button class="btn" data-x="cancel" autofocus>${tr("Cancel")}</button><button class="btn danger-fill" data-x="ok">${esc(label)}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "ok"); });
}
// ------------------------------------------------------------ climate popup
let livePopup = null;
const HVAC_ICON = { off: "power", heat: "flame", cool: "snowflake", heat_cool: "thermometer", auto: "sparkles", dry: "droplet", fan_only: "fan" };
/** A thermostat dial with −/+, modes, presets and fan modes. Taps on −/+ add up and are sent after a short pause. */
function climatePopup(eid) {
let pending = null, sendTimer = 0;
const step = () => Number(ha.states[eid]?.attributes.target_temp_step) || 0.5;
const draw = () => {
const s = ha.states[eid];
if (!s) return;
const a = s.attributes, min = Number(a.min_temp ?? 7), max = Number(a.max_temp ?? 30);
const target = pending ?? a.temperature;
const off = s.state === "off";
const color = { heat: "var(--warn)", cool: "var(--accent)", heat_cool: "var(--ok)", auto: "var(--ok)" }[s.state] || "var(--text-2)";
// 270° dial: track, the target as an arc and a knob, the current temperature as a small dot
const A0 = Math.PI * 0.75, SPAN = Math.PI * 1.5, R = 86;
const ang = (v) => A0 + SPAN * Math.max(0, Math.min(1, (v - min) / (max - min || 1)));
const pt = (v, r = R) => [100 + r * Math.cos(ang(v)), 100 + r * Math.sin(ang(v))];
const arc = (v0, v1) => { const [x0, y0] = pt(v0), [x1, y1] = pt(v1); return `M${x0.toFixed(1)} ${y0.toFixed(1)} A${R} ${R} 0 ${ang(v1) - ang(v0) > Math.PI ? 1 : 0} 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`; };
const hasTarget = target != null && isFinite(Number(target));
const cur = Number(a.current_temperature);
const chips = (list, current, kind) => (list || []).map((m) => `<button class="chip-btn" data-cl="${kind}" data-v="${esc(m)}" aria-pressed="${m === current}">${kind === "hvac" && HVAC_ICON[m] ? Icons.svg(HVAC_ICON[m]) : ""}<span>${esc(window.I18n.state(m))}</span></button>`).join("");
const card = $("#modal .modal-card");
card.innerHTML = `<h3>${esc(friendly(s))}</h3>
<p>${tr("Now")} <b class="num">${isFinite(cur) ? cur + "°" : "–"}</b>${a.current_humidity != null ? ` · ${tr("Humidity")} <b class="num">${esc(a.current_humidity)}%</b>` : ""}${a.hvac_action ? ` · ${esc(window.I18n.state(a.hvac_action))}` : ""}</p>
<div class="cl-dial ${off ? "is-off" : ""}" style="--cl:${color}">
<svg viewBox="0 0 200 200" aria-hidden="true">
<path d="${arc(min, max)}" class="cl-track"/>
${hasTarget && !off ? `<path d="${arc(min, Number(target))}" class="cl-fill"/><circle cx="${pt(Number(target))[0]}" cy="${pt(Number(target))[1]}" r="11" class="cl-knob"/>` : ""}
${isFinite(cur) ? `<circle cx="${pt(cur, R - 18)[0]}" cy="${pt(cur, R - 18)[1]}" r="4" class="cl-cur"/>` : ""}
</svg>
<div class="cl-center"><span class="cl-mode">${esc(window.I18n.state(s.state))}</span>
<span class="cl-target num" aria-live="polite">${hasTarget ? `${Number(target).toLocaleString(LOCALE, { minimumFractionDigits: step() < 1 ? 1 : 0, maximumFractionDigits: 1 })}°` : "–"}</span>
<span class="muted small">${tr("Target")}</span></div>
</div>
${hasTarget ? `<div class="cl-steps"><button class="round-btn" data-cl="temp" data-v="-1" aria-label="${tr("Lower")}">${Icons.svg("minus")}</button><button class="round-btn" data-cl="temp" data-v="1" aria-label="${tr("Raise")}">${Icons.svg("plus")}</button></div>` : ""}
${a.hvac_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Mode")}</div><div class="cl-chips">${chips(a.hvac_modes, s.state, "hvac")}</div>` : ""}
${a.preset_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Preset")}</div><div class="cl-chips">${chips(a.preset_modes, a.preset_mode, "preset")}</div>` : ""}
${a.fan_modes?.length ? `<div class="section-title" style="margin-top:14px">${tr("Fan")}</div><div class="cl-chips">${chips(a.fan_modes, a.fan_mode, "fan")}</div>` : ""}
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`;
};
livePopup = { eid, draw };
const done = openModal("", (e, close) => {
const b = e.target.closest("[data-cl], [data-x]");
if (!b) return;
if (b.dataset.x) { close(); return; }
const s = ha.states[eid], kind = b.dataset.cl;
if (kind === "temp") {
const a = s.attributes, base = pending ?? Number(a.temperature);
pending = Math.round(Math.max(Number(a.min_temp ?? 7), Math.min(Number(a.max_temp ?? 30), base + Number(b.dataset.v) * step())) * 10) / 10;
draw();
clearTimeout(sendTimer);
sendTimer = setTimeout(() => { const v = pending; ha.callService("climate", "set_temperature", { temperature: v }, eid).then(() => { if (pending === v) pending = null; }); }, 700);
} else if (kind === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: b.dataset.v }, eid);
else if (kind === "preset") ha.callService("climate", "set_preset_mode", { preset_mode: b.dataset.v }, eid);
else if (kind === "fan") ha.callService("climate", "set_fan_mode", { fan_mode: b.dataset.v }, eid);
});
draw();
$("#modal .modal-card [data-x]")?.focus();
done.then(() => { livePopup = null; });
}
/** Arm or disarm, with the code typed on the card's keypad. Arming with doors or windows open asks first. */
async function alarmAction(a, eid) {
const box = a.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id], svc = a.dataset.v;
if (svc !== "alarm_disarm") {
const sel = w.cfg.openings?.length ? w.cfg.openings.map((e) => ha.states[e]).filter(Boolean) : Object.values(ha.states).filter((s) => s.entity_id.startsWith("binary_sensor.") && ["door", "window", "opening", "garage_door"].includes(s.attributes.device_class));
const open = sel.filter((s) => s.state === "on");
if (open.length && !(await confirmAction(tr("Turn on the alarm anyway?"), tr("{0} still open: {1}", open.length, open.map((s) => friendly(s)).join(", ")), tr("Turn on")))) return;
}
const data = cache.code ? { code: cache.code } : {};
cache.code = "";
renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") });
ha.callService("alarm_control_panel", svc, data, eid);
}
/** Add what was typed to a to-do list; the list refreshes once Home Assistant has it. */
function todoAdd(form) {
const input = form?.querySelector(".todo-input"), item = input?.value.trim();
if (!item) return;
const box = form.closest(".w"), w = findWidget(box.dataset.id), cache = caches[w.id];
input.value = "";
(cache.items ||= []).unshift({ uid: `pending-${Date.now()}`, summary: item, status: "needs_action" });
renderWidget(w, box, { child: box.classList.contains("child") });
box.querySelector(".todo-input")?.focus();
ha.callService("todo", "add_item", { item }, form.dataset.e).then(() => { cache.bump = (cache.bump || 0) + 1; renderWidget(w, gridEl.querySelector(`.w[data-id="${w.id}"]`), { child: box.classList.contains("child") }); });
}
gridEl.addEventListener("submit", (e) => { if (e.target.matches(".todo-add")) { e.preventDefault(); todoAdd(e.target); } });
/** One lamp (or group): on/off, a big brightness slider, color temperature, swatches and any color. Follows the light live. */
function lightPopup(eid) {
const H = window.HAFmt;
let holding = false; // don't redraw under a finger that is dragging a slider
const draw = () => {
const s = ha.states[eid];
if (!s || holding) return;
const a = s.attributes, on = s.state === "on";
const pct = on ? Math.round((a.brightness || 255) / 2.55) : 0;
const dimmable = (a.supported_color_modes || []).some((m) => m !== "onoff");
const kMin = Number(a.min_color_temp_kelvin) || 2000, kMax = Number(a.max_color_temp_kelvin) || 6500, k = Number(a.color_temp_kelvin) || Math.round((kMin + kMax) / 2);
const rgb = Array.isArray(a.rgb_color) ? "#" + a.rgb_color.map((v) => Number(v).toString(16).padStart(2, "0")).join("") : "#ffd58a";
const sw = (list) => list.map(([v, col, lbl]) => `<button class="swatch big" style="--sw:${col}" data-lp="color" data-v="${v}" aria-label="${esc(tr(lbl))}" title="${esc(tr(lbl))}"></button>`).join("");
$("#modal .modal-card").innerHTML = `<div class="lp-head"><span class="lp-dot" style="--tint:${H.lightTint(s) || "var(--fill)"}">${H.icon(s)}</span>
<div><h3>${esc(friendly(s))}</h3><p>${esc(on ? (pct ? `${pct}%` : tr("On")) : tr("Off"))}</p></div>
<button class="btn ${on ? "" : "primary"}" data-lp="toggle" style="margin-left:auto">${on ? tr("Turn off") : tr("Turn on")}</button></div>
${dimmable ? `<div class="section-title" style="margin-top:16px">${tr("Brightness")}</div>
<input class="lp-slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%;--fill-c:${H.lightTint(s) || "#ffd58a"}" data-lp="brightness" aria-label="${tr("Brightness")}">` : ""}
${H.canTemp(s) ? `<div class="section-title" style="margin-top:16px">${tr("Color temperature")}</div>
<input class="lp-slider lp-temp" type="range" min="${kMin}" max="${kMax}" step="50" value="${k}" data-lp="kelvin" aria-label="${tr("Color temperature")}">
<div class="lp-swatches">${sw(H.WHITES)}</div>` : ""}
${H.canColor(s) ? `<div class="section-title" style="margin-top:16px">${tr("Color")}</div>
<div class="lp-swatches">${sw(H.COLORS)}<label class="swatch big custom" title="${tr("Any color")}"><input type="color" value="${rgb}" data-lp="rgb" aria-label="${tr("Any color")}">${Icons.svg("palette")}</label></div>` : ""}
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`;
};
livePopup = { eid, draw };
const card = $("#modal .modal-card");
const done = openModal("", (e, close) => {
const b = e.target.closest("[data-lp], [data-x]");
if (!b) return;
if (b.dataset.x) { close(); return; }
if (b.dataset.lp === "toggle") ha.callService("light", ha.states[eid]?.state === "on" ? "turn_off" : "turn_on", {}, eid);
if (b.dataset.lp === "color") {
const [kind, val] = b.dataset.v.split(":");
ha.callService("light", "turn_on", kind === "k" ? { color_temp_kelvin: Number(val) } : { rgb_color: val.split(",").map(Number) }, eid);
}
});
const onInput = (e) => { if (e.target.dataset.lp === "brightness") e.target.style.setProperty("--pct", `${e.target.value}%`); };
const onChange = (e) => {
const f = e.target, kind = f.dataset.lp;
holding = false;
if (kind === "brightness") ha.callService("light", "turn_on", { brightness_pct: Number(f.value) }, eid);
if (kind === "kelvin") ha.callService("light", "turn_on", { color_temp_kelvin: Number(f.value) }, eid);
if (kind === "rgb") ha.callService("light", "turn_on", { rgb_color: [1, 3, 5].map((i) => parseInt(f.value.slice(i, i + 2), 16)) }, eid);
};
const onDown = (e) => { if (e.target.matches("input[type=range]")) holding = true; };
card.addEventListener("input", onInput);
card.addEventListener("change", onChange);
card.addEventListener("pointerdown", onDown);
draw();
card.querySelector("[data-x]")?.focus();
done.then(() => {
livePopup = null;
card.removeEventListener("input", onInput); card.removeEventListener("change", onChange); card.removeEventListener("pointerdown", onDown);
});
}
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>
<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>
<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();
});
}
/** Who sees which dashboard: this device, other known devices, and people. */
async function assignDialog() {
const users = await api("api/users").catch(() => []);
const people = new Map();
for (const s of Object.values(ha.states)) if (s.entity_id.startsWith("person.") && s.attributes.user_id) people.set(s.attributes.user_id, friendly(s));
for (const u of users) if (!people.has(u.id)) people.set(u.id, u.display || u.name || u.id);
const a = store.assign;
const opts = (val) => `<option value="">${tr("Last used")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const devRow = (id, d, mine) => `<div class="assign-row" data-dev="${id}">
<input class="input" data-a="name" value="${esc(d?.name || "")}" placeholder="${mine ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<select class="input" data-a="dashboard" aria-label="${tr("Dashboard")}">${opts(d?.dashboard)}</select>
<label class="assign-kiosk"><input type="checkbox" data-a="kiosk" ${d?.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${mine ? "" : `<button class="btn icon" data-x="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
const others = Object.entries(a.devices).filter(([id]) => id !== DEVICE_ID);
const html = `<h3>${tr("Who sees which dashboard")}</h3>
<p>${tr("Choose the dashboard that opens first. A device’s choice comes before a person’s; otherwise the last dashboard used on that device opens.")}</p>
<div class="section-title" style="margin-top:16px">${tr("This device")}</div>${devRow(DEVICE_ID, a.devices[DEVICE_ID], true)}
${others.length ? `<div class="section-title" style="margin-top:16px">${tr("Other devices")}</div>${others.map(([id, d]) => devRow(id, d, false)).join("")}` : ""}
<div class="section-title" style="margin-top:16px">${tr("People")}</div>
${people.size ? [...people].map(([id, name]) => `<div class="assign-row" data-user="${esc(id)}"><span class="assign-name">${esc(name)}${me?.id === id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</span>
<select class="input" data-a="dashboard" aria-label="${esc(tr("Dashboard for {0}", name))}">${opts(a.users[id])}</select></div>`).join("") : `<p class="muted small">${tr("People appear here once they have opened Dashboard Studio, or when they have a person entity.")}</p>`}
<div class="modal-actions"><button class="btn primary" data-x="done">${tr("Done")}</button></div>`;
const card = $("#modal .modal-card");
const done = openModal(html, (e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x;
if (x === "forget") { delete a.devices[e.target.closest("[data-dev]").dataset.dev]; e.target.closest(".assign-row").remove(); scheduleSave(); }
if (x === "done") close();
});
card.classList.add("wide");
const update = (e) => {
const f = e.target, key = f.dataset.a, row = f.closest(".assign-row");
if (!key || !row) return;
const v = f.type === "checkbox" ? f.checked : f.value;
if (row.dataset.user) { if (v) a.users[row.dataset.user] = v; else delete a.users[row.dataset.user]; }
else { const d = (a.devices[row.dataset.dev] ||= {}); d[key] = v; }
scheduleSave();
};
card.addEventListener("change", update);
card.addEventListener("input", (e) => { if (e.target.dataset.a === "name") update(e); });
await done;
card.classList.remove("wide");
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");
}
/** A searchable grid of every icon; choosing one fills the given field as if it was typed. */
async function iconPicker(input) {
if (!input) return;
const grid = (f) => Icons.search(f).map((n) => `<button class="ip-icon ${n === input.value ? "on" : ""}" data-name="${n}" title="${n}" aria-label="${n}">${Icons.svg(n)}<span>${n}</span></button>`).join("") || `<p class="muted small">${tr("No icons match.")}</p>`;
const card = $("#modal .modal-card");
const done = openModal(`<h3>${tr("Choose an icon")}</h3>
<input class="input ip-search" type="search" placeholder="${tr("Search, e.g. kitchen or keuken")}" aria-label="${tr("Search icons")}" autofocus>
<div class="ip-grid">${grid("")}</div>
<p class="muted small" style="margin:10px 2px 0">${tr("You can also type an emoji in the field instead.")}</p>
<div class="modal-actions"><button class="btn" data-x="none">${tr("No icon")}</button><button class="btn" data-x="close">${tr("Cancel")}</button></div>`,
(e, close) => {
const b = e.target.closest("[data-name], [data-x]");
if (!b) return;
if (b.dataset.name) close(b.dataset.name); else close(b.dataset.x === "none" ? "" : null);
});
card.classList.add("wide");
const search = card.querySelector(".ip-search");
search.addEventListener("input", () => { card.querySelector(".ip-grid").innerHTML = grid(search.value); });
search.focus();
const name = await done;
card.classList.remove("wide");
if (name == null) return;
input.value = name;
input.dispatchEvent(new Event("change", { bubbles: true }));
}
function toast(msg, err) {
const el = document.createElement("div");
el.className = "toast" + (err ? " err" : "");
el.textContent = msg;
$("#toasts").appendChild(el);
setTimeout(() => el.remove(), err ? 6000 : 3500);
}
// ------------------------------------------------------------ kiosk
// No visible controls. Tapping one screen corner 5 times quickly opens a
// short-lived exit prompt; Escape does the same on a keyboard.
const CORNER_PX = 90, CORNER_TAPS = 5, CORNER_WINDOW_MS = 2500, PROMPT_MS = 8000;
let kiosk = false, swallowClick = false, wakeLock = null, idleTimer = 0, saverTimer = 0;
let cornerTaps = [], promptOpen = false;
async function requestWakeLock() {
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; }
window.DSApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
}
const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " ");
function enterKiosk({ fullscreen = true } = {}) {
if (editMode) setEdit(false);
kiosk = true;
document.body.classList.add("kiosk");
if (fullscreen) toggleFullscreen(true);
requestWakeLock();
doorbellPrev = ha.states[current.kiosk.doorbell]?.state;
history.replaceState(null, "", `#kiosk&view=${current.id}`);
renderGrid();
poke();
toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS));
}
function exitKiosk() {
kiosk = false;
document.body.classList.remove("kiosk", "idle");
hideSaver();
closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null;
window.DSApp?.keepAwake(false);
if (document.fullscreenElement) document.exitFullscreen?.();
history.replaceState(null, "", location.pathname);
renderGrid();
}
function toggleFullscreen(quiet) {
if (document.fullscreenElement) { document.exitFullscreen?.(); return; }
const p = document.documentElement.requestFullscreen?.();
if (!p) { if (!quiet) toast(tr("Full screen isn’t available in this browser."), true); return; }
p.catch(() => { if (!quiet) toast(tr("Home Assistant’s frame blocks full screen. Use “New tab” in the kiosk settings instead."), true); });
}
/** The exit prompt closes itself if nobody answers, so the screen goes back to the dashboard. */
async function kioskPrompt() {
if (promptOpen || modalClose) return;
promptOpen = true;
const timer = setTimeout(() => modalClose?.(null), PROMPT_MS);
const choice = await openModal(`<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); });
clearTimeout(timer);
promptOpen = false;
if (choice === "exit") exitKiosk();
else if (choice === "full") toggleFullscreen(false);
else if (choice === "guest") guestOn ? endGuest() : setGuestManual(true);
}
function inExitCorner(e) {
const c = current.kiosk.exitCorner || "top-right";
const x = c.endsWith("left") ? e.clientX : window.innerWidth - e.clientX;
const y = c.startsWith("top") ? e.clientY : window.innerHeight - e.clientY;
return x < CORNER_PX && y < CORNER_PX;
}
/** Any activity: wake from the screen saver, count corner taps, reset idle timers. */
function poke(e) {
if (!kiosk) return;
if (!$("#saver").hidden) {
hideSaver();
if (e?.type === "pointerdown") swallowClick = true; // the waking tap shouldn't also press a tile
} else if (e?.type === "pointerdown" && !modalClose && inExitCorner(e)) {
swallowClick = true; // corner taps never press whatever widget sits there
const now = Date.now();
cornerTaps = [...cornerTaps.filter((t) => now - t < CORNER_WINDOW_MS), now];
if (cornerTaps.length >= CORNER_TAPS) { cornerTaps = []; kioskPrompt(); }
}
document.body.classList.remove("idle");
clearTimeout(idleTimer);
if (current.kiosk.hideCursor) idleTimer = setTimeout(() => document.body.classList.add("idle"), 3000);
clearTimeout(saverTimer);
const mins = Number(current.kiosk.saver) || 0;
if (mins > 0) saverTimer = setTimeout(showSaver, mins * 60000);
}
const saverCache = {};
function showSaver() {
if (!kiosk || modalClose || !$("#doorbell").hidden) return;
const k = current.kiosk, el = $("#saver"), photos = k.saverStyle === "photos";
el.classList.toggle("photo", photos);
updateSaver();
el.hidden = false;
if (photos) window.HAFmt.slideshow(el, $("#saver .photos"), { api }, saverCache,
{ source: k.saverSource, urls: k.saverUrls, albums: k.saverAlbums, shuffle: true, interval: k.saverInterval || 30 });
}
function hideSaver() {
$("#saver").hidden = true;
clearTimeout(saverCache.timer);
}
function updateSaver() {
const d = new Date();
$("#saver .time").textContent = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
$("#saver .date").textContent = d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" });
}
for (const ev of ["pointerdown", "pointermove", "keydown", "wheel"]) document.addEventListener(ev, poke, { capture: true, passive: true });
document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); });
// ------------------------------------------------------------ doorbell
// In kiosk mode a ring (a sensor turning on, or a new doorbell event) shows the camera full screen.
let doorbellPrev, doorbellTimer = 0;
function checkDoorbell() {
const k = current?.kiosk, s = k?.doorbell && ha.states[k.doorbell];
if (!s) return;
const prev = doorbellPrev;
doorbellPrev = s.state;
if (prev === undefined || prev === s.state || !kiosk) return;
const rang = domainOf(k.doorbell) === "event" ? !["unavailable", "unknown"].includes(s.state) : s.state === "on";
if (rang) ringDoorbell();
}
function ringDoorbell() {
const k = current.kiosk, el = $("#doorbell");
hideSaver();
clearTimeout(saverTimer);
if (modalClose) modalClose(null);
const cam = k.doorbellCamera;
el.querySelector(".db-cam").innerHTML = cam
? `<img data-cam="${esc(cam)}" data-refresh="1" src="api/camera/${encodeURIComponent(cam)}?t=${Date.now()}" alt="${esc(friendly(ha.states[cam] || { entity_id: cam }))}">`
: `<div class="db-icon">${Icons.svg("bell")}</div>`;
el.querySelector(".db-title").textContent = tr("Someone’s at the door");
el.querySelector(".db-close").textContent = tr("Close");
el.hidden = false;
if (k.doorbellSound !== false) { window.HAFmt.beep?.(); setTimeout(() => window.HAFmt.beep?.(), 450); }
clearTimeout(doorbellTimer);
doorbellTimer = setTimeout(closeDoorbell, Math.max(5, Number(k.doorbellSecs) || 60) * 1000);
el.querySelector(".db-close").focus();
}
function closeDoorbell() {
clearTimeout(doorbellTimer);
const el = $("#doorbell");
if (el.hidden) return;
el.hidden = true;
el.querySelector(".db-cam").innerHTML = "";
poke(); // the screen saver counts from here
}
$("#doorbell").addEventListener("click", (e) => { if (e.target.closest(".db-close")) closeDoorbell(); });
// ------------------------------------------------------------ announcements
// An automation fires the event dashboard_studio_announce with a message; open screens show it full screen.
let announceTimer = 0;
ha.onAnnounce = (d) => {
if (!current || editMode || current.kiosk.announce === false || !d.message) return;
const me2 = (store.assign?.devices?.[DEVICE_ID]?.name || "").toLowerCase();
const targets = [].concat(d.device || d.devices || []).map((x) => String(x).toLowerCase());
if (targets.length && !targets.includes(me2) && !targets.includes(DEVICE_ID.toLowerCase())) return;
const el = $("#announce");
hideSaver();
if (modalClose) modalClose(null);
el.querySelector(".an-title").textContent = d.title || tr("Announcement");
el.querySelector(".an-msg").textContent = d.message;
el.querySelector(".an-ico").innerHTML = Icons.svg(Icons.has(d.icon) ? d.icon : "megaphone");
el.querySelector(".an-ok").textContent = tr("OK");
el.hidden = false;
el.querySelector(".an-ok").focus();
if (d.sound !== false) { window.HAFmt.beep?.(); setTimeout(() => window.HAFmt.beep?.(), 350); }
clearTimeout(announceTimer);
announceTimer = setTimeout(closeAnnounce, Math.max(5, Number(d.seconds) || 60) * 1000);
};
function closeAnnounce() {
clearTimeout(announceTimer);
$("#announce").hidden = true;
poke();
}
$("#announce").addEventListener("click", (e) => { if (e.target.closest(".an-ok") || e.target.id === "announce") closeAnnounce(); });
// ------------------------------------------------------------ tab rotation
// In kiosk mode the tabs can take turns; touching the screen pauses that for a minute.
let lastRotate = Date.now(), lastTouch = 0;
document.addEventListener("pointerdown", () => { lastTouch = Date.now(); }, { capture: true, passive: true });
function rotateTick() {
const secs = Number(current?.kiosk.rotate) || 0;
if (!kiosk || secs <= 0 || current.tabs.length < 2 || (guestOn && !editMode) || modalClose || !$("#saver").hidden) return;
const now = Date.now();
if (now - lastTouch < 60000 || now - lastRotate < Math.max(5, secs) * 1000) return;
lastRotate = now;
const i = current.tabs.findIndex((t) => t.id === tab().id);
setTab(current.tabs[(i + 1) % current.tabs.length].id);
}
// ------------------------------------------------------------ away from home
// A dashboard can switch to another tab (garage, alarm, cameras) while you are away, and back when you're home.
let awayState = null, awayFrom = null;
function awayEntity() {
if (!current?.nav?.awayTab) return null;
if (current.nav.awayPerson) return current.nav.awayPerson;
return Object.values(ha.states).find((x) => x.entity_id.startsWith("person.") && me?.id && x.attributes.user_id === me.id)?.entity_id || null;
}
function checkAway() {
const eid = awayEntity(), s = eid && ha.states[eid];
if (!s || editMode || kiosk || !current.tabs.some((t) => t.id === current.nav.awayTab)) return;
const away = s.state !== "home";
if (away === awayState) return;
const first = awayState === null;
awayState = away;
if (away && tab().id !== current.nav.awayTab) { awayFrom = tab().id; setTab(current.nav.awayTab); if (!first) toast(tr("You’re away: showing “{0}”.", tab().name)); }
else if (!away && awayFrom && tab().id === current.nav.awayTab) { setTab(awayFrom); awayFrom = null; }
}
// ------------------------------------------------------------ guest mode
// A plain screen with lights and thermostats. Turned on per device with a button, or for
// everyone by an entity or a time of day. Editing always shows the real dashboard.
let guestOn = false, guestCache = null;
const guestManual = () => !!current && lsGet(`ds.guest.${current.id}`) === "1";
function setGuestManual(on) {
lsSet(`ds.guest.${current.id}`, on ? "1" : "0");
checkGuest(true);
toast(on ? tr("Guest mode is on.") : tr("Guest mode is off."));
}
function checkGuest(force) {
if (!current) return;
const g = current.guest || DEFAULT_GUEST;
const skipped = Date.now() < Number(lsGet(`ds.guestskip.${current.id}`) || 0); // ended early on this device
const on = guestManual() || (g.when === "entity" && !!g.entity && ["on", "home", "true"].includes(ha.states[g.entity]?.state))
|| (g.when === "times" && !skipped && betweenTimes(g.from, g.to));
if (on === guestOn && !force) return;
guestOn = on;
guestCache = null;
document.body.classList.toggle("guest", on && !editMode);
$("#btn-guest").hidden = !(on && !editMode);
if (!editMode) renderGrid();
}
function guestTab() {
const g = current.guest || DEFAULT_GUEST, cols = Number(current.grid.cols) || 12;
const pickIds = g.entities?.length ? g.entities : Object.keys(ha.states).filter((e) => (e.startsWith("light.") || e.startsWith("climate.")) && !ha.states[e].attributes.entity_id);
const key = `${pickIds.join(",")}|${cols}|${g.title}`;
if (guestCache?.key === key) return guestCache.tab;
const lights = pickIds.filter((e) => e.startsWith("light.")).sort((a, b) => friendly(ha.states[a] || { entity_id: a }).localeCompare(friendly(ha.states[b] || { entity_id: b })));
const climates = pickIds.filter((e) => e.startsWith("climate."));
const widgets = [];
const add = (type, cfg, w, h) => {
const wd = makeWidget(type);
Object.assign(wd.cfg, cfg);
wd.id = `guest-${type}-${cfg.entity || "title"}`;
wd.w = Math.min(w, cols); wd.h = h;
Object.assign(wd, findSpot(wd.w, wd.h, widgets, cols));
widgets.push(wd);
};
add("heading", { text: g.title || tr("Welcome"), subtitle: tr("Lights and temperature"), size: 30 }, cols, 1);
const tw = cols >= 12 ? 4 : Math.max(2, Math.floor(cols / 2));
for (const e of climates) add("climate", { entity: e }, tw, 3);
const lw = cols >= 12 ? 3 : Math.max(2, Math.floor(cols / 2));
for (const e of lights) add("light", { entity: e, colors: false }, lw, 2);
guestCache = { key, tab: { id: "guest", name: tr("Guest mode"), icon: "person", widgets } };
return guestCache.tab;
}
/** Ending guest mode undoes whatever turned it on: the button, the entity, or the rest of today's time window. */
async function endGuest() {
if (!(await confirmAction(tr("End guest mode?"), "", tr("End guest mode")))) return;
const g = current.guest;
if (g.when === "entity" && g.entity && ["input_boolean", "switch"].includes(domainOf(g.entity))) ha.callService(domainOf(g.entity), "turn_off", {}, g.entity);
if (g.when === "times" && betweenTimes(g.from, g.to)) {
const [h, m] = String(g.to || "0:0").split(":").map(Number), end = new Date();
end.setHours(h || 0, m || 0, 0, 0);
if (end < Date.now()) end.setDate(end.getDate() + 1);
lsSet(`ds.guestskip.${current.id}`, String(end.getTime()));
}
setGuestManual(false);
}
$("#btn-guest").onclick = endGuest;
// ------------------------------------------------------------ night mode
// Per device: dim the screen and use the dark theme after sunset or between two times.
const DEFAULT_NIGHT = { mode: "off", from: "22:00", to: "07:00", dim: 40, dark: true };
let nightDark = false;
function nightCfg() {
try { return { ...DEFAULT_NIGHT, ...JSON.parse(lsGet("ds.night") || "{}") }; } catch { return { ...DEFAULT_NIGHT }; }
}
/** Whether the time of day is between two "HH:MM" times, also across midnight. */
function betweenTimes(fromV, toV) {
const mins = (v) => { const [h, m] = String(v || "0:0").split(":").map(Number); return (h || 0) * 60 + (m || 0); };
const d = new Date(), now = d.getHours() * 60 + d.getMinutes(), from = mins(fromV), to = mins(toV);
return from <= to ? now >= from && now < to : now >= from || now < to;
}
function isNight(n) {
if (n.mode === "sunset") return ha.states["sun.sun"]?.state === "below_horizon";
return n.mode === "times" && betweenTimes(n.from, n.to);
}
function applyNight() {
if (!current) return;
const n = nightCfg(), on = !editMode && isNight(n);
document.body.classList.toggle("night", on);
document.body.style.setProperty("--night-dim", on ? String(Math.min(90, Math.max(0, Number(n.dim) || 0)) / 100) : "0");
const dark = on && !!n.dark;
if (dark !== nightDark) { nightDark = dark; applyTheme(); }
}
// ------------------------------------------------------------ touch: haptics, swiping tabs, pull to refresh, sheets
const coarse = matchMedia("(pointer: coarse)");
function haptic() {
if (coarse.matches && lsGet("ds.haptics") !== "0") navigator.vibrate?.(8);
if (kiosk && current?.kiosk.clickSound) clickSound();
}
/** A short, quiet tick, made on the spot (no sound file), for wall tablets that can't vibrate. */
let audioCtx = null;
function clickSound() {
try {
audioCtx ||= new (window.AudioContext || window.webkitAudioContext)();
const t0 = audioCtx.currentTime, o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.type = "triangle"; o.frequency.setValueAtTime(1800, t0); o.frequency.exponentialRampToValueAtTime(600, t0 + 0.04);
g.gain.setValueAtTime(0.12, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.05);
o.connect(g).connect(audioCtx.destination);
o.start(t0); o.stop(t0 + 0.06);
} catch { /* no audio */ }
}
// ------------------------------------------------------------ touch and hold
// Holding a widget for half a second runs its second action; the tap that follows is swallowed.
let holdTimer = 0, holdStart = null;
gridEl.addEventListener("pointerdown", (e) => {
clearTimeout(holdTimer);
if (editMode || e.button !== 0) return;
const el = e.target.closest(".w[data-id]");
if (!el || e.target.closest("input, textarea, select, .hs-nav, .sw-dot")) return;
holdStart = { x: e.clientX, y: e.clientY };
holdTimer = setTimeout(() => {
const w = findWidget(el.dataset.id);
if (!w || !runHold(w)) return;
swallowClick = true;
haptic();
el.animate?.([{ transform: "scale(.97)" }, { transform: "scale(1)" }], 180);
}, 500);
});
gridEl.addEventListener("pointermove", (e) => { if (holdStart && Math.hypot(e.clientX - holdStart.x, e.clientY - holdStart.y) > 10) { clearTimeout(holdTimer); holdStart = null; } });
for (const ev of ["pointerup", "pointercancel", "pointerleave"]) gridEl.addEventListener(ev, () => { clearTimeout(holdTimer); holdStart = null; });
gridEl.addEventListener("contextmenu", (e) => { if (!editMode && coarse.matches && e.target.closest(".w")) e.preventDefault(); });
function runHold(w) {
const h = w.hold || {}, eid = w.cfg.entity || (w.cfg.entities || [])[0] || h.entity;
switch (h.action || "details") {
case "none": return false;
case "toggle": if (!eid) return false; defaultAction(eid); return true;
case "service": {
if (!h.service?.includes(".")) return false;
let data = {};
if (h.data) { try { data = JSON.parse(h.data); } catch { return false; } }
const [d, sv] = h.service.split(".");
ha.callService(d, sv, data, h.entity || undefined);
return true;
}
default: if (!eid || !ha.states[eid]) return false; moreInfo(eid); return true;
}
}
// Swipe left or right over the dashboard to go to the next or previous tab (not over cards that swipe themselves).
let swipeStart = null;
const SWIPE_OWN = ".hs, .sw, input, textarea, select, .swatches, .todo-list, .f1-tabs, .fc-hours, .sf-bars, .us-bars";
canvasEl.addEventListener("touchstart", (e) => {
const tch = e.touches[0];
swipeStart = !editMode && e.touches.length === 1 && current.tabs.length > 1 && !(guestOn) && !e.target.closest(SWIPE_OWN)
? { x: tch.clientX, y: tch.clientY, t: Date.now() } : null;
}, { passive: true });
canvasEl.addEventListener("touchend", (e) => {
if (!swipeStart) return;
const tch = e.changedTouches[0], dx = tch.clientX - swipeStart.x, dy = tch.clientY - swipeStart.y, dt = Date.now() - swipeStart.t;
swipeStart = null;
if (Math.abs(dx) < 70 || Math.abs(dy) > Math.abs(dx) * 0.6 || dt > 700) return;
const i = current.tabs.findIndex((t) => t.id === tab().id), j = i + (dx < 0 ? 1 : -1);
if (j < 0 || j >= current.tabs.length) return;
haptic();
setTab(current.tabs[j].id);
if (!reducedMotion()) gridEl.animate([{ transform: `translateX(${dx < 0 ? 40 : -40}px)`, opacity: 0.4 }, { transform: "none", opacity: 1 }], { duration: 220, easing: "cubic-bezier(.2,.8,.2,1)" });
}, { passive: true });
// Pull down at the top to fetch every state again.
let pull = null;
const ptr = $("#ptr");
stageEl.addEventListener("touchstart", (e) => {
pull = !editMode && stageEl.scrollTop <= 0 && e.touches.length === 1 && !e.target.closest("input, textarea, select, .hs, .sw") ? { y: e.touches[0].clientY, d: 0 } : null;
}, { passive: true });
stageEl.addEventListener("touchmove", (e) => {
if (!pull) return;
pull.d = Math.max(0, e.touches[0].clientY - pull.y);
if (stageEl.scrollTop > 0) { pull = null; ptr.hidden = true; return; }
if (pull.d > 10) {
ptr.hidden = false;
const k = Math.min(1, pull.d / 90);
ptr.style.transform = `translate(-50%, ${Math.min(70, pull.d * 0.5)}px) rotate(${k * 270}deg)`;
ptr.style.opacity = String(k);
ptr.classList.toggle("ready", k >= 1);
}
}, { passive: true });
stageEl.addEventListener("touchend", async () => {
if (!pull) return;
const go = pull.d >= 90;
pull = null;
if (!go) { ptr.hidden = true; return; }
haptic();
ptr.classList.add("spin");
try {
if (ha.connected) {
const states = await ha.send({ type: "get_states" });
ha.states = Object.fromEntries(states.map((x) => [x.entity_id, x]));
onStateChange(null);
} else toast(tr("Not connected to Home Assistant. Retrying…"), true);
} finally {
setTimeout(() => { ptr.classList.remove("spin", "ready"); ptr.hidden = true; }, 400);
}
});
// On phones, popups are sheets from the bottom; drag one down to close it.
let sheetDrag = null;
const sheet = $("#modal .modal-card");
sheet.addEventListener("touchstart", (e) => {
sheetDrag = matchMedia("(max-width: 600px)").matches && sheet.scrollTop <= 0 && !e.target.closest("input, textarea, select, .lp-slider, input[type=range]") ? { y: e.touches[0].clientY, d: 0 } : null;
}, { passive: true });
sheet.addEventListener("touchmove", (e) => {
if (!sheetDrag) return;
sheetDrag.d = Math.max(0, e.touches[0].clientY - sheetDrag.y);
sheet.style.transition = "none";
sheet.style.transform = `translateY(${sheetDrag.d}px)`;
}, { passive: true });
sheet.addEventListener("touchend", () => {
if (!sheetDrag) return;
const close = sheetDrag.d > 110;
sheetDrag = null;
sheet.style.transition = "";
sheet.style.transform = "";
if (close) modalClose?.(null);
});
// ------------------------------------------------------------ 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(() => {
peek(true);
navigator.vibrate?.(10);
}, 600);
});
for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); });
// The round button in the corner does the same, for dashboards without an empty spot.
function peek(on) {
document.body.classList.toggle("peek", on);
clearTimeout(peekTimer);
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
}
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
// ------------------------------------------------------------ chrome wiring
function setCurrent(id) {
current = store.dashboards.find((d) => d.id === id) || store.dashboards[0];
lsSet("ds.current", current.id);
activeTabId = lsGet(`ds.tab.${current.id}`);
guestOn = false; guestCache = null; awayState = null; awayFrom = null;
undoStack.length = redoStack.length = 0;
{ selectedId = null; multi.clear(); }
updateUndoButtons();
renderAll();
checkGuest(true);
checkAway();
}
function setEdit(on) {
editMode = on;
if (!on) openSheet(null);
else renderPalette(); // the suggestions count needs the states
lsSet("ds.edit", on ? "1" : "0");
if (!on) { selectedId = null; multi.clear(); }
renderAll();
checkGuest(true);
checkAway();
}
/** Earlier saves of this dashboard, from the server. Restoring one can be undone. */
async function historyDialog() {
if (savePending) { clearTimeout(saveTimer); await save(); }
let list;
try { list = await api(`api/history/${encodeURIComponent(current.id)}`); } catch (e) { toast(tr("Couldn’t load the version history. {0}", e.message), true); return; }
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
const rows = list.map((v, i) => `<div class="hist-row">
<div class="grow"><div class="hist-when">${esc(when(v.at))}</div>
<div class="muted small">${esc(relTime(new Date(v.at * 1000).toISOString()))} · ${tr("{0} widgets", v.widgets)}${v.tabs > 1 ? ` · ${tr("{0} tabs", v.tabs)}` : ""}${v.name !== current.name ? ` · “${esc(v.name)}”` : ""}</div></div>
${i === 0 ? `<span class="muted small">${tr("Current")}</span>` : `<button class="btn" data-x="${v.at}">${tr("Restore")}</button>`}</div>`).join("");
const at = await openModal(`<h3>${tr("Version history")}</h3>
<p>${tr("Each save is kept here, up to 30 versions. Changes within five minutes count as one.")}</p>
<div class="hist-list">${rows || `<div class="muted small">${tr("No earlier versions yet. They appear after your next changes.")}</div>`}</div>
<div class="modal-actions"><button class="btn" data-x="cancel" autofocus>${tr("Close")}</button></div>`,
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x === "cancel" ? null : x); });
if (!at) return;
let data;
try { data = await api(`api/history/${encodeURIComponent(current.id)}/${at}`); } catch (e) { toast(e.message, true); return; }
select(null);
const id = current.id;
mutate(() => {
for (const k of Object.keys(current)) delete current[k];
Object.assign(current, migrate(data), { id });
});
renderAll();
toast(tr("Restored the version from {0}. Press ⌘Z to undo.", when(Number(at))));
}
function setMenu(open) {
$("#menu").hidden = !open;
$("#btn-menu").setAttribute("aria-expanded", String(open));
if (open) $("#menu button")?.focus();
}
$("#btn-edit").onclick = () => setEdit(!editMode);
$("#btn-kiosk").onclick = () => enterKiosk();
$("#btn-app").onclick = () => installDialog();
$("#btn-app").hidden = IN_APP || standalone();
$("#btn-undo").onclick = undo;
$("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value);
$("#btn-new").onclick = async () => {
const choice = await templateDialog();
if (!choice) return;
const d = choice.template === "devices" ? starterDashboard() : TEMPLATES[choice.template]?.build() || newDashboard(choice.name);
d.name = choice.name;
store.dashboards.push(d);
scheduleSave();
setCurrent(d.id);
setEdit(true);
};
$("#btn-menu").onclick = (e) => { e.stopPropagation(); setMenu($("#menu").hidden); };
document.addEventListener("click", (e) => { if (!e.target.closest("#menu")) setMenu(false); });
$("#menu").addEventListener("click", async (e) => {
const act = e.target.closest("[data-menu]")?.dataset.menu;
if (!act) return;
setMenu(false);
if (act === "rename") {
const n = await askText(tr("Rename dashboard"), current.name, tr("Rename"));
if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); }
} else if (act === "assign") {
assignDialog();
} else if (act === "history") {
historyDialog();
} else if (act === "undo-history") {
undoHistoryDialog();
} else if (act === "share") {
shareDialog();
} else if (act === "suggest") {
suggestionsDialog();
} else if (act === "install") {
installDialog();
} else if (act === "lovelace") {
lovelaceDialog();
} else if (act === "starter") {
if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; }
const d = starterDashboard();
store.dashboards.push(d); scheduleSave(); setCurrent(d.id);
toast(tr("Created “{0}” from your devices.", d.name));
} else if (act === "duplicate") {
const d = clone(current); d.id = uid(); d.name += ` ${tr("copy")}`;
d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); });
store.dashboards.push(d); scheduleSave(); setCurrent(d.id);
} else if (act === "export") {
const blob = new Blob([JSON.stringify(current, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${current.name.replace(/[^\w-]+/g, "_")}.dashboard.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
} else if (act === "import") {
$("#import-file").click();
} else if (act === "delete") {
if (store.dashboards.length === 1) { toast(tr("This is your only dashboard. Create another one before deleting it."), true); return; }
const ok = await confirmDestructive(tr("Delete “{0}”?", current.name), tr("The dashboard and its widgets will be removed. This can’t be undone."), tr("Delete"));
if (!ok) return;
store.dashboards = store.dashboards.filter((d) => d.id !== current.id);
scheduleSave(); setCurrent(store.dashboards[0].id);
}
});
$("#import-file").onchange = async (e) => {
const file = e.target.files[0];
e.target.value = "";
if (!file) return;
try {
const d = migrate(JSON.parse(await file.text()));
if (!d.tabs.every((t) => Array.isArray(t.widgets))) throw new Error(tr("the file has no widgets list"));
d.id = uid();
d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); });
d.name = d.name || file.name;
store.dashboards.push(d); scheduleSave(); setCurrent(d.id);
toast(tr("Imported “{0}”.", d.name));
} catch (err) { toast(tr("Couldn’t import: {0}.", err.message), true); }
};
navEl.addEventListener("click", (e) => {
const op = e.target.closest("[data-tabop]");
if (op) { tabOp(op.dataset.tabop); return; }
const b = e.target.closest("[data-tab]");
if (b) setTab(b.dataset.tab);
});
$("#palette-list").addEventListener("click", async (e) => {
const b = e.target.closest("[data-add]");
if (b) { addWidget(b.dataset.add); return; }
if (e.target.closest("[data-suggest]")) { suggestionsDialog(); return; }
const pa = e.target.closest("[data-preset-add]");
if (pa) { addPreset(pa.dataset.presetAdd); return; }
const pd = e.target.closest("[data-preset-del]");
if (pd) {
const p = store.presets.find((x) => x.id === pd.dataset.presetDel);
if (!(await confirmDestructive(tr("Delete preset “{0}”", p.name), tr("Widgets you already added from it stay."), tr("Delete")))) return;
store.presets = store.presets.filter((x) => x !== p);
scheduleSave(); renderPalette();
}
});
const insp = $("#inspector");
insp.addEventListener("input", onInspectorInput);
insp.addEventListener("change", onInspectorInput);
insp.addEventListener("click", (e) => {
const seg = e.target.closest(".seg button[data-path], .pos9 button[data-path]");
if (seg) { applyField(seg, seg.dataset.v, true); return; }
const p = e.target.closest("[data-preset]");
if (p) {
mutate(() => {
const keep = { surface: current.theme.surface, radius: current.theme.radius, bgImage: current.theme.bgImage };
current.theme = { ...DEFAULT_THEME, ...PRESETS[p.dataset.preset], ...keep, preset: p.dataset.preset };
delete current.theme.swatch;
});
renderInspector(); return;
}
const del = e.target.closest("[data-ent-del]");
if (del && del.dataset.path === "guest") {
mutate(() => current.guest[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
checkGuest(true); renderInspector(); return;
}
if (e.target.closest("[data-guest-toggle]")) { setGuestManual(!guestManual()); lsSet(`ds.guestskip.${current.id}`, "0"); renderInspector(); return; }
if (del) {
const w = editTarget(); // may be a card inside a scroll or swipe card
mutate(() => (w.cfg[del.dataset.k] || []).splice(Number(del.dataset.entDel), 1), null, { rerender: false });
renderWidget(selectedWidget()); renderInspector(); return;
}
const childEdit = e.target.closest("[data-child-edit]"), childBtn = e.target.closest("[data-child-op]");
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 pick = e.target.closest("[data-icon-pick]");
if (pick) { iconPicker(document.getElementById(pick.dataset.iconPick)); 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]");
if (k) {
if (k.dataset.kiosk === "here") enterKiosk();
else window.open(new URL(`#kiosk&view=${current.id}`, BASE).href, "_blank", "noopener");
return;
}
const al = e.target.closest("[data-align]");
if (al) { alignSelection(al.dataset.align); return; }
const tool = e.target.closest("[data-tool]");
if (tool && selectedId) toolAction(tool.dataset.tool, multi.size ? [...multi] : [selectedId]);
});
function toolAction(name, ids) {
if (name === "preset") savePreset(ids[0]);
else if (name === "copyto") copyTo(ids);
else if (ids.length > 1) name === "dup" ? duplicateMany(ids) : removeMany(ids);
else name === "dup" ? duplicate(ids[0]) : remove(ids[0]);
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (modalClose) { modalClose(null); return; }
if (!$("#menu").hidden) { setMenu(false); $("#btn-menu").focus(); return; }
if (kiosk) { kioskPrompt(); return; }
}
if (!editMode || modalClose) return;
const inField = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName);
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === "z" && !inField) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
if (mod && e.key.toLowerCase() === "y" && !inField) { e.preventDefault(); redo(); return; }
if (inField || !selectedId) return;
if (multi.size) {
const ws = selection();
if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); removeMany(ws.map((w) => w.id)); }
else if (mod && e.key.toLowerCase() === "d") { e.preventDefault(); duplicateMany(ws.map((w) => w.id)); }
else if (e.key === "Escape") select(null);
else if (e.key.startsWith("Arrow") && !e.shiftKey) {
e.preventDefault();
const [dx, dy] = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
const minX = Math.min(...ws.map((w) => w.x)), maxR = Math.max(...ws.map((w) => w.x + w.w)), minY = Math.min(...ws.map((w) => w.y));
if (minX + dx < 0 || maxR + dx > current.grid.cols || minY + dy < 0) return;
mutate(() => { ws.forEach((w) => { w.x += dx; w.y += dy; }); [...ws].sort((a, b) => a.y - b.y).forEach(resolveCollisions); }, `kbd-multi`);
}
return;
}
const w = tab().widgets.find((x) => x.id === selectedId);
if (!w) return;
if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); remove(w.id); }
else if (mod && e.key.toLowerCase() === "d") { e.preventDefault(); duplicate(w.id); }
else if (e.key === "Escape") select(null);
else if (e.key.startsWith("Arrow")) {
e.preventDefault();
const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
mutate(() => {
if (e.shiftKey) { w.w = Math.max(1, Math.min(current.grid.cols - w.x, w.w + d[0])); w.h = Math.max(1, w.h + d[1]); }
else { w.x = Math.max(0, Math.min(current.grid.cols - w.w, w.x + d[0])); w.y = Math.max(0, w.y + d[1]); }
resolveCollisions(w);
}, `kbd-${w.id}`);
select(w.id);
}
});
let resizeRaf = 0;
window.addEventListener("resize", () => { cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(renderGrid); });
matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", () => renderGrid());
phoneLandscape.addEventListener?.("change", () => renderGrid());
phoneScreen.addEventListener?.("change", () => renderGrid()); // widgets shown only on phones or only on larger screens
// clocks, screen saver and camera refresh
setInterval(() => {
gridEl.querySelectorAll("[data-clock]").forEach((el) => { el.innerHTML = clockHTML(el.dataset.h24 === "1", el.dataset.sec === "1", el.dataset.date === "1"); });
if (!$("#saver").hidden) updateSaver();
rotateTick();
gridEl.querySelectorAll("[data-countdown]").forEach((el) => {
const left = Math.max(0, (new Date(el.dataset.countdown) - Date.now()) / 1000), dur = Number(el.dataset.dur) || 1;
el.textContent = window.HAFmt.hms(left);
const ring = el.parentElement.querySelector("[data-ring]");
if (ring) ring.setAttribute("stroke-dasharray", `${(Number(ring.dataset.ring) * Math.min(1, left / dur)).toFixed(1)} ${ring.dataset.ring}`);
});
const now = Date.now();
document.querySelectorAll("#grid img[data-cam], #doorbell img[data-cam]").forEach((img) => {
const every = Number(img.dataset.refresh) * 1000;
if (!img._last) img._last = now;
if (now - img._last >= every) { img._last = now; img.src = `api/camera/${encodeURIComponent(img.dataset.cam)}?t=${now}`; }
});
}, 1000);
// keep "x min ago" labels and graph time windows fresh
setInterval(() => { applyNight(); checkGuest(); }, 60000);
setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000);
// ------------------------------------------------------------ boot
function paintChromeIcons() {
$("#btn-new").innerHTML = Icons.svg("plus");
$("#eb-add").insertAdjacentHTML("afterbegin", Icons.svg("plus"));
$("#btn-undo").innerHTML = Icons.svg("undo");
$("#btn-redo").innerHTML = Icons.svg("redo");
$("#btn-menu").innerHTML = Icons.svg("more");
$("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk"));
$("#btn-app").innerHTML = Icons.svg("phone");
$("#phone-menu").innerHTML = Icons.svg("more");
document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
}
ha.onStatus = (ok) => {
const c = $("#conn");
c.classList.toggle("ok", ok);
c.textContent = ok ? tr("Connected") : offlineText();
showOffline(!ok);
// An update restarts the app, which drops the connection: check right after reconnecting.
if (ok) checkVersion();
};
/** If the app was updated while this page was open, load the new version. */
let updateNoticeShown = false;
async function checkVersion() {
if (APP_VERSION === "dev") return;
let latest;
try { latest = (await api("api/version")).version; } catch { return; }
if (!latest || latest === APP_VERSION) return;
if (editMode) { // never yank the editor away; just say so once
if (!updateNoticeShown) toast(tr("Dashboard Studio was updated to {0}. Reload the page to use it.", latest));
updateNoticeShown = true;
return;
}
toast(tr("Dashboard Studio was updated to {0}. Reloading…", latest));
if (savePending) { clearTimeout(saveTimer); await save(); } // keep edits still waiting to be saved
setTimeout(() => location.reload(), 2500);
}
setInterval(checkVersion, 5 * 60 * 1000);
ha.onChange = onStateChange;
ha.onRegistry = () => { renderGrid(); if (editMode) { renderInspector(); renderPalette(); } };
/** Translate the static page: elements marked data-i18n, plus every aria-label and title. */
function translateStatic() {
document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = tr(el.textContent.trim()); });
for (const attr of ["aria-label", "title"]) document.querySelectorAll(`[${attr}]`).forEach((el) => el.setAttribute(attr, tr(el.getAttribute(attr))));
}
async function boot() {
translateStatic();
paintChromeIcons();
renderPalette();
await keepIngressSession();
loadCachedStates();
if ("serviceWorker" in navigator && APP_VERSION !== "dev") navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
try {
const r = await fetch(new URL("api/dashboards", BASE));
store = await r.json();
store.dashboards = (store.dashboards || []).map(migrate);
store.assign = { devices: {}, users: {}, ...(store.assign || {}) };
} catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); }
const firstReady = new Promise((res) => {
const prev = ha.onStatus;
ha.onStatus = (ok) => { prev(ok); if (ok) res(); };
setTimeout(res, 4000);
});
ha.connect();
if (!store.dashboards?.length) {
await firstReady;
store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } };
save();
}
me = await api("api/whoami").catch(() => null);
const shared = sharedParams();
const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1] || shared.get("ds");
// Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here.
const dev = store.assign.devices[DEVICE_ID], forUser = me?.id && store.assign.users[me.id];
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") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
const action = shared.get("ds_action");
if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// 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();
})();