Files
ha-dashboard-studio/dashboard_studio/web/app.js
T
veenm 4187c8f38d Dashboard Studio 1.6.0: app icon, clock weather, icon-only tab rail
- App store icon (icon.png) and favicon, drawn by tools/make_icon.py
- Clock can show a weather entity with today's high and low from
  weather.get_forecasts
- Side menu gets an icon-only rail style (now the default): full height,
  centered icons, active marker, names as hover/focus tooltips; phones
  keep a labelled bottom tab bar

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

1410 lines
72 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;
/** 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;
// ------------------------------------------------------------ HA connection
class HA {
constructor() {
this.states = {};
this.nextId = 1;
this.pending = new Map();
this.connected = false;
this.onChange = () => {};
this.onStatus = () => {};
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 = () => {
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.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 || {};
}
/** 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", "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", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 },
};
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;
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 = [];
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/.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: [] };
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];
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;
function scheduleSave() {
$("#save-status").textContent = tr("Saving…");
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 700);
}
async function save() {
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", 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-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;
const compact = !editMode && width < 560;
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);
}
}
function renderGrid() {
if (!current) return;
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] ||= {}) });
function renderWidget(w, el = gridEl.querySelector(`.w[data-id="${w.id}"]`)) {
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 && !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 ? `<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.entities) return def.entities(w.cfg, ha);
return w.cfg.entity ? [w.cfg.entity] : [];
}
let interacting = false;
function onStateChange(entityId) {
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
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 "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) {
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("")}
<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>
<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>
<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>`;
}
}
}
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 === "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 = tab().widgets.find((x) => x.id === selectedId);
const key = `${selectedId}|${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;
} 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) });
if (path === "cfg" || path === "style") renderWidget(w);
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON")) 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" }, 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); }
}
}
}
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 = 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() };
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) {
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 === "mp-select") {
const w = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id);
caches[w.id].sel = a.dataset.v;
renderWidget(w);
gridEl.querySelector(`.w[data-id="${w.id}"] [data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus();
}
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
else if (act === "button") {
const c = tab().widgets.find((x) => x.id === 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"); });
}
function moreInfo(eid) {
const s = ha.states[eid];
if (!s) 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();
});
}
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(); });
// ------------------------------------------------------------ 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 === "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]");
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 = tab().widgets.find((x) => x.id === selectedId);
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
renderWidget(w); renderInspector(); 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");
};
ha.onChange = onStateChange;
/** 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);
} 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()] };
save();
}
const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1];
setCurrent(hashId || lsGet("ds.current"));
if (location.hash.includes("kiosk")) enterKiosk({ fullscreen: false });
}
boot();
})();