Files
ha-dashboard-studio/dashboard_studio/web/app.js
T
veenm aa75a572c5 Dashboard Studio 1.9.0: real swiping, tap for next photo, bigger vacuum card
- Swipe card: pointer-driven drag for mouse and touch with resistance
  at the ends and flick detection, keyboard arrows, cards fill the whole
  card, page dots float and hide after a few seconds; updates wait until
  a swipe ends and the release never presses a button underneath
- Tapping the clock background or a Photos card skips to the next photo
- Media card: removing players works inside scroll/swipe cards, and
  pre-1.5 single-player cards are migrated into the list
- Vacuum card grows with its size: robot with battery ring, cleaning
  pulse and charging badge, stat tiles, suction power and labelled
  buttons; compact layout on small cards

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

1604 lines
88 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.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", "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/.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;
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] ||= {}),
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 (!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 "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("")}
<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>
<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>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener" style="width:100%;margin-top:10px;text-decoration:none">${Icons.svg("globe")}${tr("Source code and changes")}</a>
</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 === "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;
// 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")) 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); }
}
}
}
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() };
(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 === "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"); });
}
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();
});
}
/** 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);
}
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 === "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; }
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;
/** 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 });
}
boot();
})();