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