Files
ha-dashboard-studio/dashboard_studio/web/app.js
T
veenm f9822a9c49 Dashboard Studio 1.11.0: energy card, climate popup, car and appliance redesigns, Home Connect
- Energy card: live power flow between solar, grid, battery and home,
  with line width and speed following the power, battery level and
  today's totals; sign of grid and battery sensors can be flipped
- Climate popup (room card thermostat and any climate entity): dial,
  -/+ that batch quick taps, modes, presets and fan modes, live updates
- Car card redesigned around 3x4 with a drawn car or photo, compact,
  wide and tall layouts
- Appliance card redesigned with a progress ring; Home Connect support:
  all operation states, auto-fill from the operation state sensor via
  the device registry, readable program names, door state, pause/
  resume/stop buttons, power switch and alerts. Fixes ready/inactive
  being shown as finished/running
- Horizontal scroll card: width in pixels or cards per view, and a
  width per card
- Swipe card: no lost tap after a touch swipe, no broken swipes over
  images, lost pointer capture ends the swipe, trackpad swiping

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:06:35 +01:00

1792 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Dashboard Studio editor + runtime. */
(function () {
const { esc, domainOf, isOn, friendly, fmtState, relTime, clockHTML, cap } = window.HAFmt;
const Widgets = window.Widgets;
const Icons = window.Icons;
const tr = window.I18n.t, LOCALE = window.I18n.locale;
// Filled in by the server; "dev" when index.html is opened some other way.
const APP_VERSION = (document.querySelector('meta[name="ds-version"]')?.content || "dev").replace("__VERSION__", "dev");
const REPO_URL = "https://git.melvanveen.nl/veenm/ha-dashboard-studio";
/** Option labels: a direct translation when there is one, else the capitalised value. */
const optLabel = (o) => (tr(o) !== o ? tr(o) : tr(cap(String(o).replace("-", " "))));
const $ = (s, el = document) => el.querySelector(s);
const uid = () => Math.random().toString(36).slice(2, 10);
const clone = (o) => JSON.parse(JSON.stringify(o));
// Ingress serves us under /api/hassio_ingress/<token>/ — keep every URL relative to that.
const BASE = new URL(location.pathname.endsWith("/") ? location.pathname : location.pathname + "/", location.origin);
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } };
const prefersDark = () => matchMedia("(prefers-color-scheme: dark)").matches;
const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
// ------------------------------------------------------------ HA connection
class HA {
constructor() {
this.states = {};
this.nextId = 1;
this.pending = new Map();
this.connected = false;
this.onChange = () => {};
this.onStatus = () => {};
this.onRegistry = () => {};
this.ready = new Promise((r) => (this._markReady = r));
}
connect() {
const url = new URL("ws", BASE);
url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = (this.ws = new WebSocket(url));
ws.onmessage = (ev) => this.handle(JSON.parse(ev.data));
ws.onclose = () => {
this._reg = null;
if (this.connected) this.ready = new Promise((r) => (this._markReady = r));
this.connected = false;
this.onStatus(false);
for (const p of this.pending.values()) p.reject?.(new Error("disconnected"));
this.pending.clear();
setTimeout(() => this.connect(), 3000);
};
}
send(msg, onEvent) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject, onEvent });
this.ws.send(JSON.stringify({ ...msg, id }));
});
}
async handle(m) {
if (m.type === "ready") {
const states = await this.send({ type: "get_states" });
this.states = Object.fromEntries(states.map((s) => [s.entity_id, s]));
this.send({ type: "subscribe_events", event_type: "state_changed" }, (ev) => {
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
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 || [];
}
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, exitCorner: "top-right", hideCursor: true };
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 }, 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.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: "" } };
}
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;
let activeTabId = null;
/** The tab whose widgets are on screen. */
const tab = () => 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);
/** 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 = [];
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));
if (undoStack.length > 100) undoStack.shift();
redoStack.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;
lastMutKey = null;
scheduleSave(); updateUndoButtons(); renderAll();
}
function undo() { if (undoStack.length) { redoStack.push(JSON.stringify(current)); restore(undoStack.pop()); } }
function redo() { if (redoStack.length) { undoStack.push(JSON.stringify(current)); restore(redoStack.pop()); } }
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" };
function applyTheme() {
const t = 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`);
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 width = 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 : 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 };
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) {
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.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 = 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;
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)");
function renderGrid() {
if (!current) return;
document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.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 && w.id === selectedId);
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 (livePopup && livePopup.eid === entityId) livePopup.draw(); // an open climate popup follows the device
if (!current) return;
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
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("");
}
function renderPalette() {
$("#palette-list").innerHTML = Object.entries(Widgets)
.map(([type, d]) => `<button class="pal-item" data-add="${type}">${Icons.svg(d.icon)}<span>${esc(tr(d.label))}</span></button>`).join("");
}
function renderAll() {
document.body.classList.toggle("editing", editMode);
$("#btn-edit").textContent = editMode ? tr("Done") : tr("Edit");
renderDashSelect();
renderGrid();
if (editMode) renderInspector();
}
// ------------------------------------------------------------ inspector
function entityOptions(domains) {
return Object.values(ha.states)
.filter((s) => !domains || domains.includes(domainOf(s.entity_id)))
.sort((a, b) => a.entity_id.localeCompare(b.entity_id))
.map((s) => `<option value="${esc(s.entity_id)}">${esc(friendly(s))}</option>`).join("");
}
const listId = (domains) => `dl-${(domains || ["all"]).join("-")}`;
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(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 "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"><span class="icon-prev" aria-hidden="true">${value ? (Icons.has(value) ? Icons.svg(value) : esc(value)) : ""}</span>
<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" list="${listId(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 = listId(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" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")}
<input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`;
}
case "area": {
const areas = ha.reg?.areas;
if (!areas) return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="muted small">${tr("Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.")}</div></div>`;
return `<div class="field">${label}<select class="input" id="${id}" ${data}><option value="">${tr("Choose a room")}</option>${areas.map((a) => `<option value="${esc(a.area_id)}" ${a.area_id === value ? "selected" : ""}>${esc(a.name)}</option>`).join("")}</select></div>`;
}
case "pos9": // a 3×3 grid for placing content in the card
return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="pos9" role="group" aria-labelledby="${id}">
${f.o.map((o) => `<button type="button" ${data} data-v="${o}" aria-pressed="${o === value}" aria-label="${esc(optLabel(o))}" title="${esc(optLabel(o))}"><i></i></button>`).join("")}</div></div>`;
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() {
const groups = new Set(["all"]);
for (const d of Object.values(Widgets)) for (const f of d.fields) if (f.domains) groups.add(f.domains.join("-"));
return [...groups].map((g) => `<datalist id="dl-${g}">${entityOptions(g === "all" ? null : g.split("-"))}</datalist>`).join("") +
`<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");
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 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)}
<div class="insp-actions"><button class="btn" data-tool="dup">${Icons.svg("copy")}${tr("Duplicate")}</button><button class="btn danger" data-tool="del">${Icons.svg("trash")}${tr("Delete")}</button></div>`;
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")}
<div class="tab-list">${current.tabs.map((t, i) => `<div class="tab-item">
<span class="icon-prev" aria-hidden="true">${Icons.has(t.icon) ? Icons.svg(t.icon) : esc(t.icon || "")}</span>
<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" 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>
${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")}
${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")}
<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>
<div class="section"><div class="section-title">${tr("Language")}</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")}
</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>`;
}
/** 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;
}
renderInspector();
}
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" list="dl-all" 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>
${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 === "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; });
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 === "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());
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
if (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;
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: "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) {
const 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 };
}
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);
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 = { ...clone(src), id: uid() };
(copy.cfg.children || []).forEach((ch) => (ch.id = uid()));
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."));
}
function select(id) {
if (id !== selectedId) childIndex = null;
selectedId = id;
gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", el.dataset.id === id));
renderInspector();
}
// ------------------------------------------------------------ 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 (selectedId !== w.id) select(w.id);
const mode = e.target.closest(".w-resize") ? "resize" : "move";
const ph = document.createElement("div");
ph.className = "placeholder";
gridEl.appendChild(ph);
placeEl(ph, w);
drag = { w, el, ph, mode, 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();
});
gridEl.addEventListener("pointermove", (e) => {
if (!drag) 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);
const pos = positions(); pos[drag.w.id] = n;
updateGridHeight(pos);
});
function endDrag() {
if (!drag) return;
const { w, ph, el, orig, next } = drag;
ph.remove();
el.classList.remove("dragging");
drag = null;
if (orig.x === next.x && orig.y === next.y && orig.w === next.w && orig.h === next.h) { placeEl(el, w); return; }
mutate(() => { Object.assign(w, next); resolveCollisions(w); });
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) {
const tool = e.target.closest("[data-tool]");
if (tool) {
const id = tool.closest(".w").dataset.id;
tool.dataset.tool === "dup" ? duplicate(id) : remove(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 === "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") {
const c = findWidget(a.closest(".w").dataset.id).cfg;
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; } }
if (c.service && c.service.includes(".")) { const [d, s] = c.service.split("."); ha.callService(d, s, 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; }
a.animate?.([{ transform: "scale(.96)" }, { transform: "scale(1)" }], 160);
}
});
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;
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);
});
// ------------------------------------------------------------ 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);
});
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; });
}
function moreInfo(eid) {
const s = ha.states[eid];
if (!s) 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");
}
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();
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");
$("#saver").hidden = true;
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="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);
}
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) {
$("#saver").hidden = true;
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);
}
function showSaver() {
if (!kiosk || modalClose) return;
updateSaver();
$("#saver").hidden = false;
}
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(); });
// ------------------------------------------------------------ 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}`);
undoStack.length = redoStack.length = 0;
selectedId = null;
updateUndoButtons();
renderAll();
}
function setEdit(on) {
editMode = on;
lsSet("ds.edit", on ? "1" : "0");
if (!on) selectedId = null;
renderAll();
}
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 name = await askText(tr("New dashboard"), tr("New dashboard"), tr("Create"));
if (!name) return;
const d = newDashboard(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 === "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", (e) => {
const b = e.target.closest("[data-add]");
if (b) addWidget(b.dataset.add);
});
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) {
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 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 tool = e.target.closest("[data-tool]");
if (tool && selectedId) tool.dataset.tool === "dup" ? duplicate(selectedId) : remove(selectedId);
});
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;
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());
// 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();
const now = Date.now();
gridEl.querySelectorAll("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(() => { 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");
$("#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") : tr("Offline");
// 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(); };
/** 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();
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 hashId = (location.hash.match(/view=([\w-]+)/) || [])[1];
// 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") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
setTimeout(phoneHint, 1500);
// After an update, point at what changed (once per device).
const seen = lsGet("ds.seenVersion");
if (APP_VERSION !== "dev" && seen && seen !== APP_VERSION) setTimeout(() => toast(tr("Updated to {0}. See what’s new under About in the settings.", APP_VERSION)), 2000);
if (APP_VERSION !== "dev") lsSet("ds.seenVersion", APP_VERSION);
}
boot();
})();