Files
ha-dashboard-studio/dashboard_studio/web/app.js
T
veenm a5823b4726 Dashboard Studio 2.2.0: guest invites, personal looks, more themes, Haven widgets and tiles
- Vacuum card: the robot drawn beside its dock by state, last clean, maintenance
- Formula One card: the official F1 look with a user-supplied logo
- Nine more themes; per-person theme, text size and language on Admin, People
- Guest invites: an expiring link and QR code for one dashboard, served on a
  LAN-only guest port, with the Home Assistant connection filtered both ways
- Haven 2.2: home screen widgets, three quick settings tiles, several homes,
  and self-updates from the Haven that Dashboard Studio serves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:31:50 +01:00

3778 lines
234 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 }, // taste-skill-ignore: the Dusk theme's own background, chosen by the user
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 }, // taste-skill-ignore: the Ocean theme's own background, chosen by the user
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 }, // taste-skill-ignore: the Sunrise theme's own background, chosen by the user
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 },
Midnight: { bg: "#000000", tile: "#0f0f10", text: "#f2f2f2", text2: "#a3a3a8", accent: "#4da3ff", onAccent: "#001b33", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Espresso: { bg: "#1a1411", tile: "#261d18", text: "#f3ebe4", text2: "#c0ada0", accent: "#e0a35a", onAccent: "#24170b", separator: "rgba(243,235,228,0.12)", fill: "rgba(243,235,228,0.1)", lit: "#ffd9a0", shadow: false, blur: 0 },
Ember: { bg: "#151515", tile: "#212121", text: "#f5f5f5", text2: "#aaaaaa", accent: "#ff7a45", onAccent: "#2a0e02", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffd9a8", shadow: false, blur: 0 },
Lagoon: { bg: "#0c1a1c", tile: "#142729", text: "#e8f5f4", text2: "#9fbcb9", accent: "#3cc9b8", onAccent: "#04201d", separator: "rgba(232,245,244,0.12)", fill: "rgba(232,245,244,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
Paper: { bg: "#f4f1ea", tile: "#fbfaf6", text: "#22201c", text2: "#5f5a52", accent: "#1f5f8b", separator: "rgba(34,32,28,0.12)", fill: "rgba(34,32,28,0.07)", lit: "#ffe6b0", shadow: false, blur: 0 },
Arctic: { bg: "#eef3f8", tile: "#ffffff", text: "#10202e", text2: "#4a5d6e", accent: "#0b6bcb", separator: "rgba(16,32,46,0.1)", fill: "rgba(16,32,46,0.06)", lit: "#ffecc4", shadow: true, blur: 0 },
Moss: { bg: "#e9ede4", tile: "#f7f9f4", text: "#1d2619", text2: "#4f5c48", accent: "#3f6f2a", separator: "rgba(29,38,25,0.12)", fill: "rgba(29,38,25,0.07)", lit: "#f6e7b4", shadow: false, blur: 0 },
Sakura: { bg: "#fbf0f2", tile: "#ffffff", text: "#2a1a1f", text2: "#6e5560", accent: "#c2185b", separator: "rgba(42,26,31,0.1)", fill: "rgba(42,26,31,0.06)", lit: "#ffe5c4", shadow: true, blur: 0 },
Concrete: { bg: "#e4e4e2", tile: "#f2f2f0", text: "#1c1c1b", text2: "#555553", accent: "#2b2b2a", onAccent: "#ffffff", separator: "rgba(28,28,27,0.12)", fill: "rgba(28,28,27,0.07)", lit: "#ffe9b8", shadow: false, 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;
// Haven, the Android app (haven/ in the repository), shows this page in its own window and adds window.HavenApp.
// It only ever shows dashboards: no builder and no admin screen, for admins too.
const IN_APP = !!window.HavenApp;
const HAVEN_ID = "nl.melvanveen.haven";
// The public download in the repository, so a phone can get Haven before it is signed in to Home Assistant.
const HAVEN_DOWNLOAD = `${REPO_URL}/raw/branch/main/dashboard_studio/web/haven.apk`;
const havenLink = () => new URL("./", BASE).href;
const havenIntent = () => `intent://open?url=${encodeURIComponent(havenLink())}#Intent;scheme=haven;package=${HAVEN_ID};end`;
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 Haven, 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: Haven always works.
body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download Haven 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 Haven. Or open Haven and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without Haven."), "muted small") : "");
actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "")
+ `<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${tr("Download Haven")}</a>`
+ `<a class="btn primary" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</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>`;
}
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
${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.HavenApp.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. */
function wsSession(token) {
return new Promise((resolve) => {
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/websocket`);
const done = (v) => { clearTimeout(t); try { ws.close(); } catch { /* closed */ } resolve(v); };
const t = setTimeout(() => done(null), 10000);
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === "auth_required") ws.send(JSON.stringify({ type: "auth", access_token: token }));
else if (m.type === "auth_ok") ws.send(JSON.stringify({ id: 1, type: "supervisor/api", endpoint: "/ingress/session", method: "post" }));
else if (m.id === 1) done(m.success ? m.result?.session : null);
else if (m.type === "auth_invalid") done(null);
};
ws.onerror = () => done(null);
});
}
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}` } });
// Only administrators may use that address; Home Assistant's websocket gives everyone a session.
const session = r.ok ? (await r.json())?.data?.session : await wsSession(tokens.access_token);
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
// Admins (Home Assistant's administrators) get three screens: Dashboard, Builder and Admin. Everyone else
// only sees the dashboards an admin chose for them, always in kiosk mode. The server enforces the same.
let role = lsGet("ds.role") === "basic" ? "basic" : "admin";
const isAdmin = () => role === "admin";
let mode = "dashboard"; // dashboard, builder or admin
let previewAs = null; // Admin → People → Preview: { id, name, ids }
let lockedKiosk = false; // people who aren't admins can't leave kiosk mode
/** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */
/** A person's own look, chosen by an admin: a theme, a text size, a language. */
const lookOf = () => (previewAs ? store.looks?.[previewAs.id] : isAdmin() ? store.looks?.[me?.id] : store.look) || {};
const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true };
const visibleFor = (uid) => { const a = accessFor(uid); return store.dashboards.filter((d) => a.all || (a.dashboards || []).includes(d.id)).map((d) => d.id); };
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() {
if (!isAdmin()) return; // the server refuses it anyway
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 own = lookOf().theme, base = own && PRESETS[own] ? { ...current.theme, ...PRESETS[own], preset: own, bgImage: "" } : current.theme;
const t = themeVariant(nightDark ? { ...base, ...PRESETS.Dark, preset: "Dark" } : base), s = stageEl.style;
document.body.classList.toggle("text-large", lookOf().scale === "large");
document.body.classList.toggle("text-xl", lookOf().scale === "xl");
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.HavenApp?.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);
}
// The first heading on the tab is the screen's title.
const h = gridEl.querySelector("h2.h-main");
if (h) { const h1 = document.createElement("h1"); h1.className = h.className; h1.style.cssText = h.style.cssText; h1.textContent = h.textContent; h.replaceWith(h1); }
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.filter((d) => !previewAs || previewAs.ids.includes(d.id)).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);
renderModeSeg();
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; // taste-skill-ignore: the System swatch: half light, half dark, with a hard edge
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();
});
}
/** 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.HavenApp?.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();
if (!lockedKiosk) toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS));
}
function exitKiosk() {
if (lockedKiosk) return;
kiosk = false;
document.body.classList.remove("kiosk", "idle");
hideSaver();
closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null;
window.HavenApp?.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 * (lockedKiosk ? 2 : 1));
// People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see.
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
const choice = await openModal(lockedKiosk
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
: `<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?.startsWith("dash:")) { setCurrent(choice.slice(5)); lsSet("ds.current", current.id); }
else if (choice === "exit") exitKiosk();
else if (choice === "haven") window.HavenApp.reset();
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();
if (mode === "admin") renderAdmin();
}
// ------------------------------------------------------------ screens
function renderModeSeg() {
const seg = $("#mode-seg");
seg.hidden = !isAdmin() || IN_APP || !!previewAs;
seg.querySelectorAll("[data-mode]").forEach((b) => { const on = b.dataset.mode === mode; b.classList.toggle("on", on); b.setAttribute("aria-selected", String(on)); });
}
function syncMode() {
document.body.classList.toggle("admin-mode", mode === "admin");
$("#admin").hidden = mode !== "admin";
if (!previewAs) lsSet("ds.mode", mode);
}
function setMode(m) {
if (!isAdmin() || IN_APP || previewAs) m = "dashboard";
if (m === "admin") {
if (kiosk) exitKiosk();
if (editMode) { editMode = false; openSheet(null); selectedId = null; multi.clear(); }
mode = "admin";
syncMode();
renderAll();
renderAdmin();
return;
}
mode = m;
setEdit(m === "builder");
}
/** Dashboards from before 2.0 had "opens first" per person in assign.users; that's now part of access. */
function migrateAccess() {
store.access ||= { everyone: { all: true }, users: {} };
store.access.users ||= {};
const old = store.assign?.users || {};
if (!Object.keys(old).length) return;
for (const [u, id] of Object.entries(old)) store.access.users[u] ||= { all: true, home: id };
store.assign.users = {};
scheduleSave();
}
/** A language an admin chose for this person; this device's own choice still comes first. */
function syncPersonLang() {
const want = lookOf().lang || "";
if ((lsGet("ds.personLang") || "") === want) return;
lsSet("ds.personLang", want);
if (window.I18n.preference() === "auto" && (want || window.I18n.lang) !== window.I18n.lang) location.reload();
}
/** People who aren't admins, with no dashboard chosen for them yet. */
function showEmpty() {
const el = $("#empty-note");
el.querySelector(".en-ico").innerHTML = Icons.svg("layout");
el.querySelector(".en-text").textContent = tr("An admin hasn’t chosen a dashboard for you yet. Ask someone who manages Home Assistant to pick one for you on the Admin screen in Dashboard Studio.");
el.hidden = false;
document.body.classList.add("empty");
}
function setEdit(on) {
editMode = on;
if (on) mode = "builder";
else if (mode === "builder") mode = "dashboard";
syncMode();
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();
}
$("#mode-seg").onclick = (e) => { const b = e.target.closest("[data-mode]"); if (b) setMode(b.dataset.mode); };
$("#btn-kiosk").onclick = () => enterKiosk();
$("#btn-app").onclick = () => installDialog();
$("#btn-app").hidden = standalone() && !IN_APP; // in Haven it holds Change address
$("#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);
dashboardAction(act);
});
/** What can be done with the current dashboard, from the builder's ⋯ menu or a card on the Admin screen. */
async function dashboardAction(act) {
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 === "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);
// ------------------------------------------------------------ admin screen
// Dashboards, the people who use them and what each may see, devices, Haven, and who changed what.
const ADMIN_SECTIONS = [
["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"],
["guests", "qr", "Guests"], ["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
];
let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards";
let peopleList = null, devicesSeen = null, lastInvite = null;
const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never"));
const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase());
async function loadPeople(force) {
if (!peopleList || force) peopleList = await api("api/people").catch(() => []);
return peopleList;
}
/** Who may see a dashboard, in a few words. */
function seenByText(d) {
const basics = (peopleList || []).filter((p) => !p.admin);
const who = basics.filter((p) => visibleFor(p.id).includes(d.id));
if (!basics.length) return tr("Admins");
if (who.length === basics.length) return tr("Everyone");
if (!who.length) return tr("Admins only");
return who.map((p) => p.name).join(", ");
}
const ADMIN = {
async dashboards() {
await loadPeople();
const cards = store.dashboards.map((d) => {
const widgets = d.tabs.reduce((n, t) => n + t.widgets.length, 0);
const bg = /^#|gradient/.test(d.theme?.bg || "") && d.theme.preset !== "System" ? d.theme.bg : "var(--fill)";
return `<article class="adm-card" data-dash="${d.id}">
<div class="ac-top"><div class="ac-swatch" style="background:${esc(bg)}">${Icons.svg(d.mobile ? "phone" : "layout")}</div>
<div class="grow"><h3>${esc(d.name)}</h3><div class="muted small">${d.tabs.length === 1 ? tr("1 tab") : tr("{0} tabs", d.tabs.length)} · ${widgets === 1 ? tr("1 widget") : tr("{0} widgets", widgets)}${d.mobile ? ` · ${tr("Phone")}` : ""}</div></div></div>
<div class="ac-who">${Icons.svg("people")}<span>${esc(seenByText(d))}</span></div>
<div class="ac-actions"><button class="btn" data-a="open">${tr("Open")}</button><button class="btn primary" data-a="edit">${Icons.svg("edit")}${tr("Build")}</button>
<button class="btn icon" data-a="more" aria-label="${esc(tr("More for {0}", d.name))}">${Icons.svg("more")}</button></div>
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("Dashboards")}</h2><p class="muted">${tr("Build a dashboard, then choose under People who sees it.")}</p></div>
<div class="adm-actions"><button class="btn primary" data-a="new">${Icons.svg("plus")}${tr("New dashboard")}</button>
<button class="btn" data-a="starter">${Icons.svg("sparkles")}${tr("From my devices")}</button>
<button class="btn" data-a="import">${Icons.svg("plus")}${tr("Import file")}</button>
<button class="btn" data-a="lovelace">${Icons.svg("import")}${tr("Import from Home Assistant")}</button></div></header>
<div class="adm-cards">${cards}</div>`;
},
async people() {
const list = await loadPeople(true);
const acc = store.access;
const dashChecks = (key, entry) => store.dashboards.map((d) => `<label class="chip-check"><input type="checkbox" data-k="dash" data-who="${key}" value="${d.id}" ${entry.all || (entry.dashboards || []).includes(d.id) ? "checked" : ""} ${entry.all ? "disabled" : ""}><span>${esc(d.name)}</span></label>`).join("");
const homeSel = (key, entry) => `<select class="input" data-k="home" data-who="${key}" aria-label="${tr("Opens first")}"><option value="">${tr("The first one")}</option>${store.dashboards.filter((d) => entry.all || (entry.dashboards || []).includes(d.id)).map((d) => `<option value="${d.id}" ${entry.home === d.id ? "selected" : ""}>${esc(d.name)}</option>`).join("")}</select>`;
const accessBlock = (key, entry, own) => `<div class="p-access">
<div class="seg seg-sm" role="radiogroup" aria-label="${tr("Sees")}">
${key !== "everyone" ? `<button data-k="mode" data-who="${key}" data-v="same" aria-pressed="${!own}">${tr("Like everyone else")}</button>` : ""}
<button data-k="mode" data-who="${key}" data-v="all" aria-pressed="${(own || key === "everyone") && !!entry.all}">${tr("All dashboards")}</button>
<button data-k="mode" data-who="${key}" data-v="some" aria-pressed="${(own || key === "everyone") && !entry.all}">${tr("Only these")}</button></div>
${(own || key === "everyone") && !entry.all ? `<div class="adm-chips">${dashChecks(key, entry)}</div>` : ""}
${own || key === "everyone" ? `<label class="field"><span>${tr("Opens first")}</span>${homeSel(key, entry)}</label>` : ""}
</div>`;
const lookBlock = (uid) => {
const l = store.looks?.[uid] || {};
const sel = (k, opts, label) => `<label class="field"><span>${tr(label)}</span><select class="input" data-k="look-${k}" data-who="${esc(uid)}">${opts.map(([v, n]) => `<option value="${v}" ${(l[k] || "") === v ? "selected" : ""}>${esc(tr(n))}</option>`).join("")}</select></label>`;
return `<div class="p-look">
${sel("theme", [["", "The dashboard’s theme"], ...Object.keys(PRESETS).filter((n) => n !== "System").map((n) => [n, n])], "Theme")}
${sel("scale", [["", "Normal"], ["large", "Large"], ["xl", "Extra large"]], "Text")}
${sel("lang", [["", "Automatic"], ["en", "English"], ["nl", "Nederlands"]], "Language")}</div>`;
};
const rows = list.map((p) => {
const own = acc.users[p.id];
const entry = own || acc.everyone || { all: true };
return `<article class="person" data-user="${esc(p.id)}">
<div class="p-head"><span class="adm-avatar ${p.admin ? "admin" : ""}">${initials(p.name)}</span>
<div class="grow"><div class="p-name">${esc(p.name)} ${p.admin ? `<span class="p-badge">${tr("Admin")}</span>` : ""}${me?.id === p.id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</div>
<div class="muted small">${p.admin ? tr("Home Assistant administrator · sees and builds everything") : tr("Sees {0} · always in kiosk mode", visibleFor(p.id).length === store.dashboards.length ? tr("all dashboards") : tr("{0} of {1} dashboards", visibleFor(p.id).length, store.dashboards.length))} · ${esc(tr("last opened {0}", ago(p.seen)))}</div></div>
${p.admin ? "" : `<button class="btn" data-a="preview">${Icons.svg("eye")}<span class="label-wide">${tr("Preview")}</span></button>`}</div>
${p.admin ? "" : accessBlock(p.id, entry, !!own)}
${lookBlock(p.id)}
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("People")}</h2>
<p class="muted">${tr("Home Assistant’s administrators are admins here: they see every dashboard and can build. Everyone else only sees the dashboards chosen here, full screen in kiosk mode, on any device.")}</p></div></header>
<article class="person everyone"><div class="p-head"><span class="adm-avatar">${Icons.svg("people")}</span>
<div class="grow"><div class="p-name">${tr("Everyone else")}</div><div class="muted small">${tr("People without their own choice below, and anyone added to Home Assistant later.")}</div></div></div>
${accessBlock("everyone", acc.everyone || { all: true }, true)}</article>
${rows || `<p class="muted">${tr("Couldn’t read Home Assistant’s people.")}</p>`}
<p class="muted small">${tr("To add someone, create a person with a login in Home Assistant (Settings, People). Turn off Administrator there to make them a basic user.")}</p>`;
},
async devices() {
[devicesSeen] = await Promise.all([api("api/devices").catch(() => ({})), loadPeople()]);
const a = store.assign.devices;
const ids = [...new Set([DEVICE_ID, ...Object.keys(devicesSeen), ...Object.keys(a)])]
.sort((x, y) => (x === DEVICE_ID ? -1 : y === DEVICE_ID ? 1 : (devicesSeen[y]?.seen || 0) - (devicesSeen[x]?.seen || 0)));
const opts = (val) => `<option value="">${tr("The person’s choice")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const rows = ids.map((id) => {
const seen = devicesSeen[id] || {}, set = a[id] || {};
const icon = { phone: "phone", tablet: "tv", computer: "laptop" }[seen.kind] || "laptop";
const what = [seen.display || (id === DEVICE_ID ? me?.display : ""), seen.app === "haven" ? "Haven" : seen.app ? tr("Browser") : "", seen.seen ? ago(seen.seen) : ""].filter(Boolean).join(" · ");
return `<div class="dev-row" data-dev="${id}"><span class="adm-avatar">${Icons.svg(icon)}</span>
<div class="grow"><input class="input" data-k="name" value="${esc(set.name || "")}" placeholder="${id === DEVICE_ID ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<div class="muted small">${id === DEVICE_ID ? `<b>${tr("This device")}</b> · ` : ""}${esc(what)}</div></div>
<select class="input" data-k="dashboard" aria-label="${tr("Dashboard")}">${opts(set.dashboard)}</select>
<label class="assign-kiosk" title="${tr("Admins on this device start in kiosk mode too")}"><input type="checkbox" data-k="kiosk" ${set.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${id === DEVICE_ID ? `<span class="dev-gap"></span>` : `<button class="btn icon" data-a="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
}).join("");
const named = ids.filter((id) => a[id]?.name);
return `<header class="adm-head"><div><h2>${tr("Devices")}</h2>
<p class="muted">${tr("Every phone, tablet and computer that opened Dashboard Studio. Give a wall tablet its own dashboard: that comes before the person’s choice.")}</p></div></header>
<div class="adm-list">${rows}</div>
<section class="adm-panel"><h3>${Icons.svg("megaphone")}${tr("Send a message to the screens")}</h3>
<p class="muted small">${tr("It shows full screen on dashboards in kiosk mode, like an announcement from an automation.")}</p>
<div class="an-form"><input class="input" id="an-title" placeholder="${tr("Title, e.g. Dinner")}" aria-label="${tr("Title")}">
<input class="input" id="an-msg" placeholder="${tr("Message, e.g. Dinner is ready!")}" aria-label="${tr("Message")}">
<select class="input" id="an-to" aria-label="${tr("To")}"><option value="">${tr("All screens")}</option>${named.map((id) => `<option value="${id}">${esc(a[id].name)}</option>`).join("")}</select>
<button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`;
},
async guests() {
let data;
try { data = await api("api/invites"); } catch (e) { return `<p class="muted">${esc(e.message)}</p>`; }
const host = lsGet("ds.guestHost") || location.hostname;
const link = (tok) => (data.port ? `http://${host}:${data.port}/g/${tok}/` : "");
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
const fresh = data.invites.find((i) => i.token === lastInvite && !i.ended);
const rows = data.invites.map((i) => `<div class="act-row inv-row ${i.ended ? "ended" : ""}" data-token="${esc(i.token)}"><span class="adm-avatar">${Icons.svg("qr")}</span>
<div class="grow"><div><b>${esc(i.name || tr("Guests"))}</b> · ${esc(i.dashboardName)}</div>
<div class="muted small">${i.ended ? esc(tr("Ended {0}", when(i.until))) : esc(tr("Until {0}", when(i.until)))}${i.by ? ` · ${esc(tr("by {0}", i.by))}` : ""}</div></div>
${i.ended ? "" : `<button class="btn" data-a="inv-show">${tr("QR code")}</button><button class="btn danger" data-a="inv-end">${tr("End")}</button>`}</div>`).join("");
return `<header class="adm-head"><div><h2>${tr("Guests")}</h2>
<p class="muted">${tr("An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.")}</p></div></header>
${data.port ? "" : `<section class="adm-panel notice"><h3>${Icons.svg("wifi")}${tr("Turn on guest links first")}</h3>
<p class="muted small">${tr("In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.")}</p></section>`}
<section class="adm-panel"><h3>${Icons.svg("plus")}${tr("New invite")}</h3>
<div class="inv-form">
<label class="field"><span>${tr("For")}</span><input class="input" id="inv-name" placeholder="${tr("e.g. Babysitter, Grandma and Grandpa")}"></label>
<label class="field"><span>${tr("Dashboard")}</span><select class="input" id="inv-dash">${store.dashboards.map((d) => `<option value="${d.id}">${esc(d.name)}</option>`).join("")}</select></label>
<label class="field"><span>${tr("Works until")}</span><select class="input" id="inv-for">
<option value="4">${tr("4 hours from now")}</option><option value="tonight">${tr("Tonight, 23:59")}</option><option value="24" selected>${tr("24 hours from now")}</option>
<option value="weekend">${tr("After the weekend (Monday 10:00)")}</option><option value="168">${tr("A week from now")}</option></select></label>
<button class="btn primary" data-a="inv-new" ${data.port ? "" : "disabled"}>${tr("Make invite")}</button></div>
<label class="field inv-host"><span>${tr("Address for the link")}</span><input class="input" id="inv-host" value="${esc(host)}" aria-describedby="inv-host-hint"></label>
<p class="muted small" id="inv-host-hint">${tr("The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.")}</p>
${fresh ? `<div class="inv-fresh"><div class="share-qr">${window.QR.svg(link(fresh.token), { label: tr("QR code for the invite") })}</div>
<div class="share-row"><input class="input" readonly value="${esc(link(fresh.token))}" id="inv-link"><button class="btn" data-a="inv-copy">${Icons.svg("copy")}${tr("Copy")}</button></div></div>` : ""}</section>
<div class="adm-list" style="margin-top:18px">${rows || `<p class="muted">${tr("No invites yet.")}</p>`}</div>`;
},
async haven() {
const addr = location.origin;
return `<header class="adm-head haven-head"><img src="static/haven-icon.svg" alt=""><div><h2>${tr("Haven app")}</h2>
<p class="muted">${tr("Haven is the Android app for the people in your home. It only shows dashboards: no builder and no settings. Everyone sees what you chose for them under People.")}</p></div></header>
<div class="haven-grid">
<section class="adm-panel haven-step"><div class="hs-num">1</div><h3>${tr("Install Haven on the phone")}</h3>
<p class="muted small">${tr("Scan this code with the phone’s camera, or download it on the phone itself. Android asks once to allow apps from this source.")}</p>
<div class="share-qr">${window.QR.svg(HAVEN_DOWNLOAD, { label: tr("QR code to download Haven") })}</div>
<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${Icons.svg("arrow-down")}${tr("Download Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">2</div><h3>${tr("Connect to Home Assistant")}</h3>
<p class="muted small">${tr("Open Haven and enter this address. It is the address you use for Home Assistant; on other networks use its outside address, for example Home Assistant Cloud.")}</p>
<div class="share-row"><input class="input" id="haven-addr" readonly value="${esc(addr)}" aria-label="${tr("Address")}"><button class="btn" data-a="copy-addr">${Icons.svg("copy")}${tr("Copy")}</button></div>
<p class="muted small">${tr("On the phone itself, Open in Haven fills it in.")}</p>
<a class="btn" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">3</div><h3>${tr("Sign in")}</h3>
<p class="muted small">${tr("Everyone signs in with their own Home Assistant login, once. Haven then opens straight on their dashboard and stays signed in.")}</p></section>
</div>
<p class="muted small">${tr("Haven needs Android 7 or newer. On iPhone, use the Home Assistant app instead.")}</p>`;
},
async activity() {
const rows = await api("api/activity").catch(() => []);
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
return `<header class="adm-head"><div><h2>${tr("Activity")}</h2><p class="muted">${tr("Who changed which dashboard. Restore an earlier version from the dashboard’s version history.")}</p></div></header>
<div class="adm-list">${rows.map((r) => `<div class="act-row" ${r.deleted ? "" : `data-dash="${r.dashboard}"`}>
<span class="adm-avatar">${r.by ? initials(r.by) : Icons.svg("edit")}</span>
<div class="grow"><div>${r.by ? tr("{0} changed {1}", `<b>${esc(r.by)}</b>`, `<b>${esc(r.name)}</b>`) : tr("{0} was changed", `<b>${esc(r.name)}</b>`)}${r.deleted ? ` <span class="muted small">(${tr("deleted")})</span>` : ""}</div>
<div class="muted small">${esc(when(r.at))} · ${esc(ago(r.at))}</div></div>
${r.deleted ? "" : `<button class="btn" data-a="history">${tr("Versions")}</button>`}</div>`).join("") || `<p class="muted">${tr("No changes yet.")}</p>`}</div>`;
},
async about() {
return `<header class="adm-head"><div><h2>${tr("About")}</h2></div></header>
<section class="adm-panel"><h3>Dashboard Studio ${esc(APP_VERSION)}</h3>
<p class="muted">${tr("You’re signed in as {0}, a Home Assistant administrator, so you can use the Dashboard, Builder and Admin screens.", esc(me?.display || me?.name || ""))}</p>
<div class="modal-actions" style="justify-content:flex-start"><button class="btn" data-a="whatsnew">${Icons.svg("sparkles")}${tr("What’s new")}</button>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener">${Icons.svg("globe")}${tr("Source code")}</a></div></section>`;
},
};
async function renderAdmin(section = adminSection) {
adminSection = section;
lsSet("ds.admin", section);
$("#admin .adm-nav").innerHTML = ADMIN_SECTIONS.map(([k, icon, label]) => `<button role="tab" data-sec="${k}" aria-selected="${k === section}" class="${k === section ? "on" : ""}">${Icons.svg(icon)}<span>${tr(label)}</span></button>`).join("");
const body = $("#admin .adm-body");
const html = await ADMIN[section]();
if (adminSection !== section || mode !== "admin") return; // moved on while it loaded
const y = body.scrollTop;
body.innerHTML = html;
body.scrollTop = y;
}
/** Change what a person (or "everyone") may see. */
function setAccess(who, fn) {
const acc = store.access;
if (who === "everyone") acc.everyone = fn({ all: true, ...(acc.everyone || {}) });
else { const v = fn(acc.users[who] ? { ...acc.users[who] } : { ...(acc.everyone || { all: true }) }); if (v) acc.users[who] = v; else delete acc.users[who]; }
scheduleSave();
renderAdmin("people");
}
/** Admin → People → Preview: the dashboards this person sees, as they see them. */
function startPreview(p) {
const ids = visibleFor(p.id);
if (!ids.length) { toast(tr("{0} can’t see any dashboard yet.", p.name), true); return; }
const home = accessFor(p.id).home;
previewAs = { ...p, ids };
mode = "dashboard";
syncMode();
setCurrent(ids.includes(home) ? home : ids[0]);
setEdit(false);
const bar = $("#preview-bar");
bar.querySelector(".pv-text").textContent = tr("Previewing as {0}: {1} dashboards. On their devices it’s full screen, in kiosk mode.", p.name, ids.length);
bar.hidden = false;
document.body.classList.add("previewing");
}
$("#pv-stop").onclick = () => {
previewAs = null;
$("#preview-bar").hidden = true;
document.body.classList.remove("previewing");
setMode("admin");
renderAdmin("people");
};
$("#admin").addEventListener("click", async (e) => {
const sec = e.target.closest("[data-sec]");
if (sec) { renderAdmin(sec.dataset.sec); $("#admin .adm-body").scrollTop = 0; return; }
const k = e.target.closest("button[data-k]");
if (k?.dataset.k === "mode") {
const v = k.dataset.v;
setAccess(k.dataset.who, (cur) => (v === "same" ? null : v === "all" ? { all: true, home: cur.home } : { all: false, dashboards: cur.all ? store.dashboards.map((d) => d.id) : cur.dashboards || [], home: cur.home }));
return;
}
const b = e.target.closest("[data-a]");
if (!b) return;
const act = b.dataset.a, card = b.closest("[data-dash]"), dash = card?.dataset.dash;
if (act === "open" || act === "edit") { setCurrent(dash); setMode(act === "edit" ? "builder" : "dashboard"); return; }
if (act === "more") {
const d = store.dashboards.find((x) => x.id === dash);
const pick = await openModal(`<h3>${esc(d.name)}</h3><div class="act-list">
${[["rename", "edit", "Rename…"], ["duplicate", "copy", "Duplicate"], ["history", "history", "Version history…"], ["share", "share", "Share…"], ["export", "exit", "Export as JSON"], ["delete", "trash", "Delete dashboard…"]]
.map(([a, i, l]) => `<button class="btn ${a === "delete" ? "danger" : ""}" data-x="${a}">${Icons.svg(i)}${tr(l)}</button>`).join("")}</div>
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`,
(ev, close) => { const x = ev.target.closest("[data-x]")?.dataset.x; if (x) close(x === "close" ? null : x); });
if (!pick) return;
setCurrent(dash);
await dashboardAction(pick);
if (mode === "admin") renderAdmin();
return;
}
if (act === "new") { $("#btn-new").click(); return; }
if (["starter", "import", "lovelace"].includes(act)) { await dashboardAction(act); if (act === "starter") renderAdmin(); return; }
if (act === "preview") { const p = peopleList.find((x) => x.id === b.closest("[data-user]").dataset.user); if (p) startPreview(p); return; }
if (act === "forget") {
const id = b.closest("[data-dev]").dataset.dev;
delete store.assign.devices[id];
scheduleSave();
await fetch(new URL(`api/devices/${encodeURIComponent(id)}`, BASE), { method: "DELETE" }).catch(() => {});
renderAdmin("devices");
return;
}
if (act === "announce") {
const message = $("#an-msg").value.trim();
if (!message) { $("#an-msg").focus(); return; }
const to = $("#an-to").value;
await ha.send({ type: "fire_event", event_type: "dashboard_studio_announce", event_data: { title: $("#an-title").value.trim() || undefined, message, ...(to ? { device: to } : {}) } })
.then(() => { toast(tr("Sent.")); $("#an-msg").value = ""; })
.catch((err) => toast(tr("Couldn’t send it: {0}", err.message), true));
return;
}
if (act === "copy-addr") { navigator.clipboard?.writeText($("#haven-addr").value).then(() => toast(tr("Copied."))).catch(() => $("#haven-addr").select()); return; }
if (act === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; }
if (act === "whatsnew") whatsNew();
if (act === "inv-new") {
const v = $("#inv-for").value, now = new Date();
let until;
if (v === "tonight") { until = new Date(now); until.setHours(23, 59, 0, 0); }
else if (v === "weekend") { until = new Date(now); until.setDate(now.getDate() + ((8 - now.getDay()) % 7 || 7)); until.setHours(10, 0, 0, 0); }
else until = new Date(now.getTime() + Number(v) * 3600e3);
try {
const r = await fetch(new URL("api/invites", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: $("#inv-name").value.trim(), dashboard: $("#inv-dash").value, until: until.getTime() / 1000 }) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
lastInvite = (await r.json()).token;
} catch (err) { toast(tr("Couldn’t make the invite: {0}", err.message), true); return; }
renderAdmin("guests");
return;
}
if (act === "inv-show") { lastInvite = b.closest("[data-token]").dataset.token; renderAdmin("guests"); $("#admin .adm-body").scrollTop = 0; return; }
if (act === "inv-end") {
const tok = b.closest("[data-token]").dataset.token;
if (!(await confirmDestructive(tr("End this invite?"), tr("The link stops working at once, also on phones that have it open."), tr("End")))) return;
await fetch(new URL(`api/invites/${encodeURIComponent(tok)}`, BASE), { method: "DELETE" }).catch(() => {});
if (lastInvite === tok) lastInvite = null;
renderAdmin("guests");
return;
}
if (act === "inv-copy") { navigator.clipboard?.writeText($("#inv-link").value).then(() => toast(tr("Copied."))).catch(() => $("#inv-link").select()); return; }
});
$("#admin").addEventListener("change", (e) => {
const f = e.target, k = f.dataset.k;
if (f.id === "inv-host") { lsSet("ds.guestHost", f.value.trim()); renderAdmin("guests"); return; }
if (f.closest("[data-dev]")) {
const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {});
d[k] = f.type === "checkbox" ? f.checked : f.value;
if (!d.name && !d.dashboard && !d.kiosk) delete store.assign.devices[id];
scheduleSave();
return;
}
if (k?.startsWith("look-")) {
const uid = f.dataset.who, key = k.slice(5);
store.looks ||= {};
const l = { ...(store.looks[uid] || {}) };
if (f.value) l[key] = f.value; else delete l[key];
if (Object.keys(l).length) store.looks[uid] = l; else delete store.looks[uid];
scheduleSave();
if (uid === me?.id) { applyTheme(); syncPersonLang(); }
return;
}
if (k === "dash") {
const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value);
setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined }));
} else if (k === "home") setAccess(f.dataset.who, (cur) => ({ ...cur, home: f.value || undefined }));
});
// device names save while typing
$("#admin").addEventListener("input", (e) => {
const f = e.target;
if (f.dataset.k !== "name" || !f.closest("[data-dev]")) return;
const id = f.closest("[data-dev]").dataset.dev;
(store.assign.devices[id] ||= {}).name = f.value;
scheduleSave();
});
// ------------------------------------------------------------ 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], #mode-seg [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);
// Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload.
let storeKey = "";
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices, s.look]);
async function refreshStore() {
if (editMode || mode === "admin" || savePending || previewAs || modalClose) return;
let next;
try { next = await api(`api/dashboards?device=${DEVICE_ID}`); } catch { return; }
const key = keyOf(next);
if (key === storeKey) return;
storeKey = key;
if (!$("#empty-note").hidden || (!isAdmin() && !next.dashboards?.length)) { location.reload(); return; }
next.dashboards = next.dashboards.map(migrate);
next.assign = { devices: {}, users: {}, ...(next.assign || {}) };
store = next;
const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id));
setCurrent(keep);
syncPersonLang();
}
setInterval(refreshStore, 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() {
document.body.classList.toggle("in-haven", IN_APP);
translateStatic();
paintChromeIcons();
renderPalette();
await keepIngressSession();
// In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page.
if (IN_APP && (await fetch(new URL("api/version", BASE)).then((r) => r.status === 401).catch(() => false))) { window.HavenApp.reconnect?.(); return; }
loadCachedStates();
if ("serviceWorker" in navigator && APP_VERSION !== "dev" && !location.pathname.startsWith("/g/")) navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
me = await api("api/whoami").catch(() => null);
if (me) { role = me.admin ? "admin" : "basic"; if (!me.guest) lsSet("ds.role", role); }
document.body.classList.toggle("guest", !!me?.guest);
try {
const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE));
store = await r.json();
if (store.role) role = store.role;
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); }
// Let admins see this device on the Admin screen, to give it a dashboard.
if (!me?.guest) fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device: DEVICE_ID, app: IN_APP ? "haven" : "browser", kind: phoneScreen.matches ? "phone" : matchMedia("(pointer: coarse)").matches ? "tablet" : "computer" }) }).catch(() => {});
const firstReady = new Promise((res) => {
const prev = ha.onStatus;
ha.onStatus = (ok) => { prev(ok); if (ok) res(); };
setTimeout(res, 4000);
});
ha.connect();
if (isAdmin()) migrateAccess();
syncPersonLang();
storeKey = keyOf(store);
if (!store.dashboards?.length) {
if (!isAdmin()) { showEmpty(); return; }
await firstReady;
store = { ...store, version: 1, dashboards: [starterDashboard()] };
save();
}
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], home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
// The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
editMode = mode === "builder";
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
else setMode(mode);
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();
})();