/* 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// — keep every URL relative to that. const BASE = new URL(location.pathname.endsWith("/") ? location.pathname : location.pathname + "/", location.origin); const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } }; const prefersDark = () => matchMedia("(prefers-color-scheme: dark)").matches; const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches; // ------------------------------------------------------------ HA connection class HA { constructor() { this.states = {}; this.nextId = 1; this.pending = new Map(); this.connected = false; this.onChange = () => {}; this.onStatus = () => {}; this.onRegistry = () => {}; this.ready = new Promise((r) => (this._markReady = r)); } connect() { const url = new URL("ws", BASE); url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; const ws = (this.ws = new WebSocket(url)); ws.onmessage = (ev) => this.handle(JSON.parse(ev.data)); ws.onclose = () => { this._reg = null; if (this.connected) this.ready = new Promise((r) => (this._markReady = r)); this.connected = false; this.onStatus(false); for (const p of this.pending.values()) p.reject?.(new Error("disconnected")); this.pending.clear(); setTimeout(() => this.connect(), 3000); }; } send(msg, onEvent) { const id = this.nextId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject, onEvent }); this.ws.send(JSON.stringify({ ...msg, id })); }); } async handle(m) { if (m.type === "ready") { const states = await this.send({ type: "get_states" }); this.states = Object.fromEntries(states.map((s) => [s.entity_id, s])); this.send({ type: "subscribe_events", event_type: "state_changed" }, (ev) => { const { entity_id, new_state } = ev.data; if (new_state) this.states[entity_id] = new_state; else delete this.states[entity_id]; this.onChange(entity_id); }); this.connected = true; this._markReady(); this.registry().then(() => this.onRegistry()).catch(() => {}); // rooms for the room card this.onStatus(true); this.onChange(null); return; } if (m.type === "error" && m.id == null) { // The server closes and we reconnect every few seconds; don't repeat the same message. if (m.message !== this.lastError || Date.now() - this.lastErrorAt > 60000) toast(m.message, true); this.lastError = m.message; this.lastErrorAt = Date.now(); return; } const p = this.pending.get(m.id); if (!p) return; if (m.type === "event") { p.onEvent?.(m.event); return; } if (m.type === "result") { if (!p.onEvent) this.pending.delete(m.id); if (m.success) p.resolve(m.result); else p.reject(new Error(m.error?.message || "Request failed")); } } callService(domain, service, data = {}, entityId) { if (!this.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return Promise.resolve(); } const msg = { type: "call_service", domain, service, service_data: data }; if (entityId) msg.target = { entity_id: entityId }; return this.send(msg).catch((e) => toast(tr("{0} failed: {1}", `${domain}.${service}`, e.message), true)); } /** Events from calendar entities, via calendar.get_events with return_response. */ async calendarEvents(entityIds, start, end) { await this.ready; const res = await this.send({ type: "call_service", domain: "calendar", service: "get_events", target: { entity_id: entityIds }, service_data: { start_date_time: start.toISOString(), end_date_time: end.toISOString() }, return_response: true, }); return res?.response || {}; } /** Areas (rooms) and which entities are in them, from the area, entity and device registries. */ registry() { this._reg ||= Promise.all([ this.send({ type: "config/area_registry/list" }), this.send({ type: "config/entity_registry/list" }), this.send({ type: "config/device_registry/list" }), ]).then(([areas, ents, devs]) => { const devArea = Object.fromEntries((devs || []).map((d) => [d.id, d.area_id])); const byArea = {}; for (const e of ents || []) { if (e.disabled_by || e.hidden_by || e.entity_category) continue; // skip config/diagnostic and hidden entities const area = e.area_id || devArea[e.device_id]; if (area) (byArea[area] ||= []).push(e.entity_id); } this.reg = { areas: (areas || []).sort((a, b) => a.name.localeCompare(b.name)), byArea }; return this.reg; }).catch((err) => { this._reg = null; throw err; }); return this._reg; } /** Today's and the coming days' forecast, via weather.get_forecasts with return_response. */ async forecast(entityId, type = "daily") { await this.ready; const res = await this.send({ type: "call_service", domain: "weather", service: "get_forecasts", target: { entity_id: entityId }, service_data: { type }, return_response: true, }); return res?.response?.[entityId]?.forecast || []; } async history(entityIds, hours) { await this.ready; const res = await this.send({ type: "history/history_during_period", start_time: new Date(Date.now() - hours * 3600e3).toISOString(), entity_ids: entityIds, minimal_response: true, no_attributes: true, significant_changes_only: false, }); const out = {}; for (const [eid, rows] of Object.entries(res || {})) { out[eid] = rows.map((r) => [(r.lu ?? r.lc ?? 0) * 1000, Number(r.s)]); } return out; } } const ha = new HA(); // ------------------------------------------------------------ themes // "System" keeps the adaptive light/dark tokens from style.css. const COLOR_KEYS = ["bg", "tile", "text", "text2", "accent", "onAccent", "separator", "fill", "lit"]; const PRESETS = { System: { swatch: ["#f2f2f7", "#1c1c1e"] }, Light: { bg: "#f2f2f7", tile: "#ffffff", text: "#1d1d1f", text2: "#6e6e73", accent: "#0066cc", separator: "rgba(60,60,67,0.16)", fill: "rgba(120,120,128,0.14)", lit: "#ffedc7", shadow: true, blur: 0 }, Dark: { bg: "#0f0f11", tile: "#1c1c1e", text: "#f5f5f7", text2: "#a1a1a6", accent: "#0a84ff", separator: "rgba(84,84,88,0.55)", fill: "rgba(118,118,128,0.24)", lit: "#ffe2a8", shadow: false, blur: 0 }, Nord: { bg: "#2e3440", tile: "#3b4252", text: "#eceff4", text2: "#c2c9d6", accent: "#88c0d0", separator: "rgba(216,222,233,0.14)", fill: "rgba(216,222,233,0.12)", lit: "#f3e3bf", shadow: false, blur: 0 }, Dusk: { bg: "linear-gradient(160deg, #1d2b53 0%, #4b2a59 55%, #9a4d55 100%)", tile: "rgba(255,255,255,0.12)", text: "#ffffff", text2: "rgba(255,255,255,0.78)", accent: "#ffcf6b", separator: "rgba(255,255,255,0.18)", fill: "rgba(255,255,255,0.16)", lit: "#ffe2a8", shadow: false, blur: 20 }, Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", onAccent: "#0b1f14", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 }, // Text on tiles is 5.3:1 or better in every theme; bright accents get dark text (onAccent). Graphite: { bg: "#18181b", tile: "#27272a", text: "#fafafa", text2: "#a1a1aa", accent: "#f59e0b", onAccent: "#1c1204", separator: "rgba(250,250,250,0.1)", fill: "rgba(250,250,250,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Indigo: { bg: "#0b1020", tile: "#151c33", text: "#eef1ff", text2: "#a7b0d6", accent: "#8aa4ff", onAccent: "#0b1020", separator: "rgba(238,241,255,0.1)", fill: "rgba(238,241,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Rose: { bg: "#1d1216", tile: "#2c1d23", text: "#fdf2f5", text2: "#d4b3bf", accent: "#ff7aa6", onAccent: "#2a0a15", separator: "rgba(253,242,245,0.1)", fill: "rgba(253,242,245,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 }, Ocean: { bg: "linear-gradient(160deg, #0b2a3c 0%, #0e4d64 60%, #137a7f 100%)", tile: "rgba(255,255,255,0.1)", text: "#f2fbfc", text2: "rgba(242,251,252,0.8)", accent: "#5ce1e6", onAccent: "#04282b", separator: "rgba(255,255,255,0.16)", fill: "rgba(255,255,255,0.14)", lit: "#ffe2a8", shadow: false, blur: 18 }, Sunrise: { bg: "linear-gradient(160deg, #fff4e6 0%, #ffe1e8 100%)", tile: "#ffffff", text: "#2b1d14", text2: "#7a5640", accent: "#c2410c", separator: "rgba(43,29,20,0.1)", fill: "rgba(43,29,20,0.07)", lit: "#ffe0a3", shadow: true, blur: 0 }, Lavender: { bg: "#f3f0fa", tile: "#ffffff", text: "#231a3a", text2: "#675b84", accent: "#5b3fc4", separator: "rgba(35,26,58,0.1)", fill: "rgba(35,26,58,0.07)", lit: "#ffe9c2", shadow: true, blur: 0 }, Mint: { bg: "#eaf6f0", tile: "#ffffff", text: "#10291d", text2: "#4a6657", accent: "#0b7a53", separator: "rgba(16,41,29,0.1)", fill: "rgba(16,41,29,0.07)", lit: "#ffeec2", shadow: true, blur: 0 }, Sand: { bg: "#f3ebdf", tile: "#fffaf2", text: "#2d261d", text2: "#6b5e4c", accent: "#9a5416", separator: "rgba(45,38,29,0.1)", fill: "rgba(45,38,29,0.07)", lit: "#ffe2a8", shadow: true, blur: 0 }, "High contrast": { bg: "#000000", tile: "#141414", text: "#ffffff", text2: "#ffffff", accent: "#ffd60a", onAccent: "#000000", separator: "rgba(255,255,255,0.7)", fill: "rgba(255,255,255,0.2)", lit: "#ffd60a", shadow: false, blur: 0, outline: true }, }; const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true }; const DEFAULT_GRID = { cols: 12, rowHeight: 72, gap: 14, maxWidth: 1440 }; const DEFAULT_KIOSK = { saver: 0, exitCorner: "top-right", hideCursor: true }; const newTab = (name, icon = "home") => ({ id: uid(), name, icon, widgets: [] }); const newDashboard = (name) => ({ id: uid(), name, theme: { ...DEFAULT_THEME }, grid: { ...DEFAULT_GRID }, kiosk: { ...DEFAULT_KIOSK }, nav: { show: false, style: "icons" }, tabs: [newTab(tr("Overview"))] }); // Bring dashboards saved by older versions up to the current shape. function migrate(d) { const t = d.theme || {}; if (!t.preset) { t.preset = "Custom"; if (t.card && !t.tile) t.tile = t.card; if (t.border && !t.separator) t.separator = t.border; } d.theme = { ...DEFAULT_THEME, ...t }; d.grid = { ...DEFAULT_GRID, ...(d.grid || {}) }; d.kiosk = { ...DEFAULT_KIOSK, ...(d.kiosk || {}) }; d.nav = { show: false, style: "icons", ...(d.nav || {}) }; if (!Array.isArray(d.tabs) || !d.tabs.length) d.tabs = [{ ...newTab(tr("Overview")), widgets: d.widgets || [] }]; delete d.widgets; // Media cards from before 1.5 kept one player in cfg.entity; move it into the list so it can be edited. const fix = (w) => { if (w.type === "media" && w.cfg.entity) { w.cfg.entities = [...new Set([w.cfg.entity, ...(w.cfg.entities || [])])]; delete w.cfg.entity; } (w.cfg.children || []).forEach(fix); }; d.tabs.forEach((t) => t.widgets.forEach(fix)); return d; } function makeWidget(type, x = 0, y = 0) { const def = Widgets[type]; const cfg = {}; for (const f of def.fields) if ("d" in f) cfg[f.k] = typeof f.d === "string" && f.t !== "seg" ? tr(f.d) : f.d; if (def.fields.some((f) => f.t === "entities")) cfg.entities = []; if (def.container) cfg.children = []; return { id: uid(), type, x, y, w: def.size[0], h: def.size[1], cfg, style: { transparent: !!def.transparent, bg: "", color: "", accent: "" } }; } function starterDashboard() { const d = newDashboard(tr("Home")); const ids = Object.keys(ha.states); const first = (dom, n = 1) => ids.filter((e) => domainOf(e) === dom).slice(0, n); const put = (type, x, y, w, h, cfg) => { const wd = makeWidget(type, x, y); Object.assign(wd, { w, h }); Object.assign(wd.cfg, cfg); d.tabs[0].widgets.push(wd); return wd; }; const numeric = (e) => !isNaN(Number(ha.states[e].state)); put("heading", 0, 0, 8, 1, { text: tr("Home"), subtitle: tr("Everything at a glance") }); const weather = first("weather")[0]; put("clock", 8, 0, 4, 3, { weather: weather || "" }); if (weather) put("weather", 0, 1, 4, 2, { entity: weather }); const lights = first("light", 3); if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] }); const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress|vacuum|robot|filter|cleaning|cleaned/.test(e)).slice(0, 2); sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e })); const tiles = [...lights.slice(1, 2), ...first("switch", 1)]; tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e })); // A car is recognisable as a lock with a sibling "_battery" sensor. const carLock = first("lock", 20).find((l) => ha.states[`sensor.${l.split(".")[1].replace(/_[^_]+$/, "")}_battery`]); let y = 5; if (carLock) { const base = carLock.split(".")[1].replace(/_[^_]+$/, ""); const pick = (dom, suffix) => (ha.states[`${dom}.${base}_${suffix}`] ? `${dom}.${base}_${suffix}` : ""); put("car", 0, y, 6, 3, { name: cap(base.replace(/_/g, " ")).replace(/\b\w/g, (m) => m.toUpperCase()), level: pick("sensor", "battery"), range: pick("sensor", "range"), charging: pick("binary_sensor", "charging"), power: pick("sensor", "charging_power"), plug: pick("binary_sensor", "plug"), limit: pick("number", "charge_limit"), lock: carLock, climate: pick("climate", "climate"), location: pick("device_tracker", "location"), odometer: pick("sensor", "odometer"), }); } const sw = [...first("switch", 3).slice(1), ...first("input_boolean", 1), ...first("fan", 1), ...lights.slice(2)]; if (sw.length) put("list", carLock ? 6 : 8, y, carLock ? 6 : 4, 3, { title: tr("Quick controls"), entities: sw }); const graphSensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && ha.states[e].attributes.device_class === "temperature").slice(0, 2); if (graphSensors.length) put("graph", 0, carLock ? y + 3 : y, carLock ? 12 : 8, 3, { entities: graphSensors, title: tr("Temperature, last 24 hours") }); const players = first("media_player", 4); const appliances = [["washer", /(washer|washing_machine|wasmachine)_(status|state|operation_state|program_state)$/, "Washing machine"], ["dryer", /(dryer|droger|wasdroger)_(status|state|operation_state|program_state)$/, "Dryer"], ["dishwasher", /(dishwasher|vaatwasser|vaatwasmachine)_(status|state|operation_state|program_state)$/, "Dishwasher"]] .map(([kind, re, name]) => ({ kind, name, status: ids.find((e) => domainOf(e) === "sensor" && re.test(e)) })).filter((a) => a.status); if (players.length || appliances.length) { let y2 = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)), x2 = 0; if (players.length) { put("media", 0, y2, 4, 3, { entities: players }); x2 = 4; } for (const a of appliances) { const base = a.status.replace(/_(status|state|operation_state|program_state)$/, ""); const pick = (...suffixes) => suffixes.map((sfx) => `${base}_${sfx}`).find((e) => ha.states[e]) || ""; if (x2 + 4 > 12) { x2 = 0; y2 += 3; } put("appliance", x2, y2, 4, 2, { name: tr(a.name), kind: a.kind, status: a.status, remaining: pick("remaining_time", "time_remaining", "remaining", "end_time", "finish_time"), progress: pick("progress", "program_progress"), program: pick("program", "selected_program", "active_program"), power: pick("power") }); x2 += 4; } } if (ids.some((e) => e.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in ha.states[e].attributes)) { const bottom = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)); put("waste", 0, bottom, 4, 4, {}); } return d; } // ------------------------------------------------------------ app state let store = { version: 1, dashboards: [], assign: { devices: {}, users: {} } }; // Each browser gets its own id, so a wall tablet can be told which dashboard to open. const DEVICE_ID = lsGet("ds.device") || (() => { const id = uid() + uid(); lsSet("ds.device", id); return id; })(); let me = null; // the signed-in Home Assistant user, from the server let current = null; let editMode = lsGet("ds.edit") === "1"; let selectedId = null; let activeTabId = null; /** The tab whose widgets are on screen. */ const tab = () => current.tabs.find((t) => t.id === activeTabId) || current.tabs[0]; /** Cards inside a container card (scroll, swipe) live in its cfg.children. */ let childIndex = null; function findWidget(id, list = tab().widgets) { for (const w of list) { if (w.id === id) return w; const inner = w.cfg?.children && findWidget(id, w.cfg.children); if (inner) return inner; } return null; } const selectedWidget = () => tab().widgets.find((x) => x.id === selectedId); /** What the inspector edits: a card inside the selected container, or the selected widget. */ const editTarget = () => { const w = selectedWidget(); return (childIndex != null && w?.cfg.children?.[childIndex]) || w; }; const undoStack = [], redoStack = []; let lastMutKey = null, lastMutAt = 0; const caches = {}; // per-widget runtime cache (graph history) const gridEl = $("#grid"), stageEl = $("#stage"), canvasEl = $("#canvas"), navEl = $("#tabnav"); let metrics = { colW: 0, rh: 72, gap: 14, cols: 12, compact: false }; // ------------------------------------------------------------ persistence let saveTimer = null, savePending = false; function scheduleSave() { savePending = true; $("#save-status").textContent = tr("Saving…"); clearTimeout(saveTimer); saveTimer = setTimeout(save, 700); } async function save() { savePending = false; try { const r = await fetch(new URL("api/dashboards", BASE), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(store) }); if (!r.ok) throw new Error(r.status); $("#save-status").textContent = tr("Saved"); } catch { $("#save-status").textContent = tr("Not saved"); toast(tr("Couldn’t save. Your changes are kept here and will save on the next edit."), true); } } /** Apply a change to the current dashboard with undo support. * `key` coalesces rapid edits (typing) into one undo step. */ function mutate(fn, key, { rerender = true } = {}) { const now = Date.now(); if (!(key && key === lastMutKey && now - lastMutAt < 1200)) { undoStack.push(JSON.stringify(current)); if (undoStack.length > 100) undoStack.shift(); redoStack.length = 0; } lastMutKey = key; lastMutAt = now; fn(); scheduleSave(); updateUndoButtons(); if (rerender) renderGrid(); } function restore(snapshot) { const idx = store.dashboards.findIndex((d) => d.id === current.id); current = JSON.parse(snapshot); store.dashboards[idx] = current; if (!tab().widgets.some((w) => w.id === selectedId)) selectedId = null; lastMutKey = null; scheduleSave(); updateUndoButtons(); renderAll(); } function undo() { if (undoStack.length) { redoStack.push(JSON.stringify(current)); restore(undoStack.pop()); } } function redo() { if (redoStack.length) { undoStack.push(JSON.stringify(current)); restore(redoStack.pop()); } } function updateUndoButtons() { $("#btn-undo").disabled = !undoStack.length; $("#btn-redo").disabled = !redoStack.length; } // ------------------------------------------------------------ rendering const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", onAccent: "--d-on-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" }; function applyTheme() { const t = current.theme, s = stageEl.style; stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain"); stageEl.classList.add(`surface-${t.surface || "cards"}`); for (const k of COLOR_KEYS) { if (t.preset !== "System" && t[k]) s.setProperty(VAR_FOR[k], t[k]); else s.removeProperty(VAR_FOR[k]); } const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)"; if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`); s.setProperty("--d-radius", `${t.radius ?? 20}px`); s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`); stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System"); s.setProperty("--d-blur", `${t.blur || 0}px`); if (t.preset === "System") s.removeProperty("--d-shadow"); else s.setProperty("--d-shadow", t.shadow ? "0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.06)" : "none"); } function computeMetrics() { const g = current.grid; const pad = window.innerWidth < 560 ? 12 : 20; const avail = canvasEl.clientWidth - pad * 2; const width = g.maxWidth ? Math.min(avail, Number(g.maxWidth)) : avail; // Phone dashboards are drawn exactly as designed; others reflow into two columns on narrow screens. const compact = !editMode && width < 560 && !current.mobile; const cols = compact ? 2 : Math.max(1, Number(g.cols) || 12); const gap = compact ? Math.min(Number(g.gap) || 0, 12) : Number(g.gap) || 0; const rh = Number(g.rowHeight) || 72; metrics = { width, cols, gap, rh, colW: (width - gap * (cols - 1)) / cols, compact }; gridEl.style.width = `${width}px`; gridEl.style.margin = `${editMode ? 4 : 8}px auto ${pad}px`; } // Compact widths: two columns in reading order. Small widgets take one column, the rest the full width. // ------------------------------------------------------------ visibility // A widget can carry conditions ("show only when the washer is running"); widgets may // also hide themselves (autoHide). Editing always shows everything. function ruleMatches(r) { const st = String(ha.states[r.entity]?.state ?? "unavailable").toLowerCase(); const v = String(r.value ?? "").trim().toLowerCase(); switch (r.op) { case "is not": return st !== v; case "above": return Number(st) > Number(v); case "below": return Number(st) < Number(v); default: return st === v; } } function isVisible(w) { if (Widgets[w.type]?.autoHide?.(w.cfg, ctxFor(w))) return false; const rules = (w.visibility?.rules || []).filter((r) => r.entity); if (!rules.length) return true; return w.visibility.mode === "any" ? rules.some(ruleMatches) : rules.every(ruleMatches); } const ruleEntities = (w) => (w.visibility?.rules || []).map((r) => r.entity).filter(Boolean); let lastVisible = {}; function positions() { const map = {}; const shown = editMode ? tab().widgets : tab().widgets.filter(isVisible); if (!metrics.compact) { if (editMode) { for (const w of shown) map[w.id] = w; return map; } // Rows that only hidden widgets used close up, so nothing leaves a hole. const rows = (list) => { const set = new Set(); for (const w of list) for (let r = w.y; r < w.y + w.h; r++) set.add(r); return set; }; const vis = rows(shown), gone = [...rows(tab().widgets.filter((w) => !shown.includes(w)))].filter((r) => !vis.has(r)); for (const w of shown) map[w.id] = { x: w.x, y: w.y - gone.filter((r) => r < w.y).length, w: w.w, h: w.h }; return map; } const sorted = [...shown].sort((a, b) => a.y - b.y || a.x - b.x); const small = (w) => w.w <= Math.floor(current.grid.cols / 4) && w.type !== "heading"; let y = 0, halfOpen = null; for (const w of sorted) { if (small(w)) { if (halfOpen) { map[w.id] = { x: 1, y: halfOpen.y, w: 1, h: halfOpen.h }; y = halfOpen.y + halfOpen.h; halfOpen = null; } else { const h = Math.max(2, w.h); map[w.id] = { x: 0, y, w: 1, h }; halfOpen = { y, h }; } } else { if (halfOpen) { y = halfOpen.y + halfOpen.h; halfOpen = null; } const h = w.type === "clock" ? Math.min(w.h, 2) : w.h; map[w.id] = { x: 0, y, w: 2, h }; y += h; } } return map; } function rectFor(p) { const { colW, gap, rh } = metrics; return { left: p.x * (colW + gap), top: p.y * (rh + gap), width: p.w * colW + (p.w - 1) * gap, height: p.h * rh + (p.h - 1) * gap }; } function placeEl(el, p) { const r = rectFor(p); Object.assign(el.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` }); } function updateGridHeight(pos) { const rows = Object.values(pos).reduce((m, p) => Math.max(m, p.y + p.h), 0) + (editMode ? 4 : 0); gridEl.style.height = `${Math.max(rows * (metrics.rh + metrics.gap) - metrics.gap, 0)}px`; const cw = metrics.colW + metrics.gap, ch = metrics.rh + metrics.gap; const svg = ``; 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) => ``).join("") + (editMode ? `` : ""); } function setTab(id) { activeTabId = id; lsSet(`ds.tab.${current.id}`, id); selectedId = null; renderGrid(); if (editMode) renderInspector(); stageEl.scrollTop = 0; } function addTab() { const t = newTab(tr("Tab {0}", current.tabs.length + 1), "tile"); mutate(() => { current.tabs.push(t); current.nav.show = true; }, null, { rerender: false }); setTab(t.id); } async function tabOp(op, id) { const i = current.tabs.findIndex((t) => t.id === id); if (op === "add") return addTab(); if (op === "up" || op === "down") { const j = op === "up" ? i - 1 : i + 1; if (j < 0 || j >= current.tabs.length) return; mutate(() => { [current.tabs[i], current.tabs[j]] = [current.tabs[j], current.tabs[i]]; }); renderInspector(); } else if (op === "del") { if (current.tabs.length === 1) { toast(tr("A dashboard needs at least one tab."), true); return; } const t = current.tabs[i]; if (t.widgets.length && !(await confirmDestructive(tr("Delete tab “{0}”?", t.name), tr("Its {0} widgets will be deleted too. You can undo this with ⌘Z.", t.widgets.length), tr("Delete")))) return; mutate(() => { current.tabs.splice(i, 1); }, null, { rerender: false }); setTab(current.tabs[Math.max(0, i - 1)].id); } } const phoneScreen = matchMedia("(max-width: 600px)"); function renderGrid() { if (!current) return; document.body.classList.toggle("phone-ui", !!current.mobile && !editMode && phoneScreen.matches); renderNav(); applyTheme(); computeMetrics(); const pos = positions(); gridEl.innerHTML = ""; lastVisible = {}; for (const w of tab().widgets) { lastVisible[w.id] = isVisible(w); if (!pos[w.id]) continue; const el = document.createElement("div"); el.className = "w"; el.dataset.id = w.id; gridEl.appendChild(el); placeEl(el, pos[w.id]); renderWidget(w, el); } updateGridHeight(pos); } async function api(path) { const r = await fetch(new URL(path, BASE)); if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`); return r.json(); } const ctxFor = (w) => ({ ha, w, api, cache: (caches[w.id] ||= {}), rerender: () => renderWidget(w), isVisible: (child) => editMode || isVisible(child), // Containers draw placeholders (.w.child); this fills each with its card. renderChildren: (el) => el.querySelectorAll(".w.child[data-id]").forEach((ph) => { const ch = (w.cfg.children || []).find((c) => c.id === ph.dataset.id); if (ch) renderWidget(ch, ph, { child: true }); }), }); function renderWidget(w, el = gridEl.querySelector(`.w[data-id="${w.id}"]`), { child = false } = {}) { if (!el) return; const def = Widgets[w.type]; if (!def) { el.innerHTML = `
${tr("Unknown widget “{0}”.", esc(w.type))}
`; 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 = `
${tr("This widget couldn’t be drawn.")}
`; } const hiddenNow = editMode && !child && !isVisible(w); el.classList.toggle("cond-hidden", hiddenNow); el.innerHTML = `
${html}
` + (hiddenNow ? `
${Icons.svg("eye-off")}${tr("Hidden now")}
` : "") + (editMode && !child ? `
` : ""); 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) => ``).join(""); } function renderPalette() { $("#palette-list").innerHTML = Object.entries(Widgets) .map(([type, d]) => ``).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) => ``).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 = ``; switch (f.t) { case "check": return `
`; case "seg": return `
${esc(f.l)}
${f.o.map((o) => ``).join("")}
`; case "select": return `
${label}
`; case "number": return `
${label}
`; case "textarea": case "json": return `
${label}
`; case "color": return `
${label}
`; case "icon": return `
${label}
`; case "entity": { const s = ha.states[value]; return `
${label} ${s ? `
${esc(friendly(s))} · ${esc(fmtState(s))}
` : value ? `
${tr("No entity with this ID")}
` : ""}
`; } case "entities": { const lid = listId(f.domains); const list = Array.isArray(value) ? value : []; return `
${esc(f.l)} ${list.map((e, i) => `
`).join("")}
`; } case "area": { const areas = ha.reg?.areas; if (!areas) return `
${esc(f.l)}
${tr("Rooms couldn’t be loaded from Home Assistant. Add the entities below instead.")}
`; return `
${label}
`; } case "pos9": // a 3×3 grid for placing content in the card return `
${esc(f.l)}
${f.o.map((o) => ``).join("")}
`; case "albums": return `
${esc(f.l)}
${tr("Loading albums…")}
`; default: return `
${label}
`; } } 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) => `${entityOptions(g === "all" ? null : g.split("-"))}`).join("") + `${Icons.names.map((n) => ``; } 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()}
${Icons.svg(cdef.icon)} ${esc(tr(cdef.label))}
${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}
`; return; } if (w) { const def = Widgets[w.type]; el.innerHTML = `${datalists()}
${Icons.svg(def.icon)} ${esc(tr(def.label))}
${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")} ${def.container ? childrenHTML(w) : ""}
${tr("Size and position")}
${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `
`).join("")}
${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") : ""}
${tr("Style")}
${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}
${visibilityHTML(w)}
`; fillAlbums(el); return; } const t = current.theme, k = current.kiosk; el.innerHTML = `
${tr("Dashboard")}
${fieldHTML({ k: "name", t: "text", l: "Name" }, current.name, "dash")} ${Icons.names.map((n) => `
${tr("Tabs")}
${fieldHTML({ k: "show", t: "check", l: "Show tabs in a side menu" }, current.nav.show, "nav")} ${fieldHTML({ k: "style", t: "seg", l: "Menu style", o: ["icons", "labels"] }, current.nav.style || "icons", "nav")}
${current.tabs.map((t, i) => `
`).join("")}
${tr("Appearance")}
${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 ``; }).join("")}

${tr("System follows your device’s light or dark appearance.")}

${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")}
${tr("Colors")}${t.preset !== "Custom" ? ` · ${tr("editing switches to Custom")}` : ""}
${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")}
${tr("Grid")}
${fieldHTML({ k: "mobile", t: "check", l: "Designed for phones" }, !!current.mobile, "layout")}

${tr("A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.")}

${GRID_FIELDS.slice(0, 2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
${GRID_FIELDS.slice(2).map((f) => fieldHTML(f, current.grid[f.k], "grid")).join("")}
${tr("Kiosk")}
${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")}

${tr("On a wall tablet, open kiosk in a new tab to hide the Home Assistant sidebar too.")}

${tr("Language")}
${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")}
${tr("About")}
Dashboard Studio
${tr("Version {0}", esc(APP_VERSION))}
${Icons.svg("globe")}${tr("Source code")}

${tr("Select a widget to edit it.")}

`; } /** 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) => ``).join("") : `
${tr("No albums found in Immich.")}
`; } catch (e) { box.innerHTML = `
${esc(e.message)}
`; } } } /** 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 `
${tr("Cards")}
${kids.length ? "" : `

${tr("Add the cards this container should hold.")}

`}
${kids.map((c, i) => `
`).join("")}
`; } 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) => `` + store.dashboards.map((d) => ``).join(""); const devRow = (id, d, mine) => `
${mine ? "" : ``}
`; const others = Object.entries(a.devices).filter(([id]) => id !== DEVICE_ID); const html = `

${tr("Who sees which dashboard")}

${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.")}

${tr("This device")}
${devRow(DEVICE_ID, a.devices[DEVICE_ID], true)} ${others.length ? `
${tr("Other devices")}
${others.map(([id, d]) => devRow(id, d, false)).join("")}` : ""}
${tr("People")}
${people.size ? [...people].map(([id, name]) => `
${esc(name)}${me?.id === id ? ` (${tr("you")})` : ""}
`).join("") : `

${tr("People appear here once they have opened Dashboard Studio, or when they have a person entity.")}

`} `; const card = $("#modal .modal-card"); const done = openModal(html, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x === "forget") { delete a.devices[e.target.closest("[data-dev]").dataset.dev]; e.target.closest(".assign-row").remove(); scheduleSave(); } if (x === "done") close(); }); card.classList.add("wide"); const update = (e) => { const f = e.target, key = f.dataset.a, row = f.closest(".assign-row"); if (!key || !row) return; const v = f.type === "checkbox" ? f.checked : f.value; if (row.dataset.user) { if (v) a.users[row.dataset.user] = v; else delete a.users[row.dataset.user]; } else { const d = (a.devices[row.dataset.dev] ||= {}); d[key] = v; } scheduleSave(); }; card.addEventListener("change", update); card.addEventListener("input", (e) => { if (e.target.dataset.a === "name") update(e); }); await done; card.classList.remove("wide"); card.removeEventListener("change", update); } /** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */ async function whatsNew() { let md = ""; try { md = await fetch(new URL("api/changelog", BASE)).then((r) => r.text()); } catch { /* shown as empty below */ } const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1"); let html = "", inList = false; for (const line of md.split("\n")) { if (/^- /.test(line)) { if (!inList) { html += ""; inList = false; } if (/^## /.test(line)) html += `

${inline(line.slice(3))}${line.slice(3).trim() === APP_VERSION ? ` · ${tr("this version")}` : ""}

`; } if (inList) html += ""; const card = $("#modal .modal-card"); const done = openModal(`

${tr("What’s new")}

${html || `

${tr("The changelog couldn’t be loaded.")}

`}
`, (e, close) => { if (e.target.closest("[data-x]")) close(); }); card.classList.add("wide"); card.querySelector(".changelog").scrollIntoView({ block: "start" }); // start at the newest version, not at the Close button card.scrollTop = 0; await done; card.classList.remove("wide"); } function toast(msg, err) { const el = document.createElement("div"); el.className = "toast" + (err ? " err" : ""); el.textContent = msg; $("#toasts").appendChild(el); setTimeout(() => el.remove(), err ? 6000 : 3500); } // ------------------------------------------------------------ kiosk // No visible controls. Tapping one screen corner 5 times quickly opens a // short-lived exit prompt; Escape does the same on a keyboard. const CORNER_PX = 90, CORNER_TAPS = 5, CORNER_WINDOW_MS = 2500, PROMPT_MS = 8000; let kiosk = false, swallowClick = false, wakeLock = null, idleTimer = 0, saverTimer = 0; let cornerTaps = [], promptOpen = false; async function requestWakeLock() { try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; } } const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " "); function enterKiosk({ fullscreen = true } = {}) { if (editMode) setEdit(false); kiosk = true; document.body.classList.add("kiosk"); if (fullscreen) toggleFullscreen(true); requestWakeLock(); history.replaceState(null, "", `#kiosk&view=${current.id}`); renderGrid(); poke(); toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS)); } function exitKiosk() { kiosk = false; document.body.classList.remove("kiosk", "idle"); $("#saver").hidden = true; clearTimeout(idleTimer); clearTimeout(saverTimer); wakeLock?.release?.(); wakeLock = null; if (document.fullscreenElement) document.exitFullscreen?.(); history.replaceState(null, "", location.pathname); renderGrid(); } function toggleFullscreen(quiet) { if (document.fullscreenElement) { document.exitFullscreen?.(); return; } const p = document.documentElement.requestFullscreen?.(); if (!p) { if (!quiet) toast(tr("Full screen isn’t available in this browser."), true); return; } p.catch(() => { if (!quiet) toast(tr("Home Assistant’s frame blocks full screen. Use “New tab” in the kiosk settings instead."), true); }); } /** The exit prompt closes itself if nobody answers, so the screen goes back to the dashboard. */ async function kioskPrompt() { if (promptOpen || modalClose) return; promptOpen = true; const timer = setTimeout(() => modalClose?.(null), PROMPT_MS); const choice = await openModal(`

${tr("Leave kiosk mode?")}

${tr("This closes by itself in a few seconds.")}

`, (e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); clearTimeout(timer); promptOpen = false; if (choice === "exit") exitKiosk(); else if (choice === "full") toggleFullscreen(false); } function inExitCorner(e) { const c = current.kiosk.exitCorner || "top-right"; const x = c.endsWith("left") ? e.clientX : window.innerWidth - e.clientX; const y = c.startsWith("top") ? e.clientY : window.innerHeight - e.clientY; return x < CORNER_PX && y < CORNER_PX; } /** Any activity: wake from the screen saver, count corner taps, reset idle timers. */ function poke(e) { if (!kiosk) return; if (!$("#saver").hidden) { $("#saver").hidden = true; if (e?.type === "pointerdown") swallowClick = true; // the waking tap shouldn't also press a tile } else if (e?.type === "pointerdown" && !modalClose && inExitCorner(e)) { swallowClick = true; // corner taps never press whatever widget sits there const now = Date.now(); cornerTaps = [...cornerTaps.filter((t) => now - t < CORNER_WINDOW_MS), now]; if (cornerTaps.length >= CORNER_TAPS) { cornerTaps = []; kioskPrompt(); } } document.body.classList.remove("idle"); clearTimeout(idleTimer); if (current.kiosk.hideCursor) idleTimer = setTimeout(() => document.body.classList.add("idle"), 3000); clearTimeout(saverTimer); const mins = Number(current.kiosk.saver) || 0; if (mins > 0) saverTimer = setTimeout(showSaver, mins * 60000); } function showSaver() { if (!kiosk || modalClose) return; updateSaver(); $("#saver").hidden = false; } function updateSaver() { const d = new Date(); $("#saver .time").textContent = d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }); $("#saver .date").textContent = d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" }); } for (const ev of ["pointerdown", "pointermove", "keydown", "wheel"]) document.addEventListener(ev, poke, { capture: true, passive: true }); document.addEventListener("click", (e) => { if (swallowClick) { swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true); document.addEventListener("visibilitychange", () => { if (kiosk && document.visibilityState === "visible") requestWakeLock(); }); // ------------------------------------------------------------ phone dashboards // The top bar is hidden; touching and holding an empty spot brings it back for a while. let peekTimer = 0, holdTimer2 = 0; canvasEl.addEventListener("pointerdown", (e) => { if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return; holdTimer2 = setTimeout(() => { document.body.classList.add("peek"); clearTimeout(peekTimer); peekTimer = setTimeout(() => document.body.classList.remove("peek"), 10000); navigator.vibrate?.(10); }, 600); }); for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); }); function phoneHint() { if (!document.body.classList.contains("phone-ui") || lsGet("ds.peekHint")) return; lsSet("ds.peekHint", "1"); toast(tr("Touch and hold an empty spot to show the menu.")); } // ------------------------------------------------------------ chrome wiring function setCurrent(id) { current = store.dashboards.find((d) => d.id === id) || store.dashboards[0]; lsSet("ds.current", current.id); activeTabId = lsGet(`ds.tab.${current.id}`); undoStack.length = redoStack.length = 0; selectedId = null; updateUndoButtons(); renderAll(); } function setEdit(on) { editMode = on; lsSet("ds.edit", on ? "1" : "0"); if (!on) selectedId = null; renderAll(); } function setMenu(open) { $("#menu").hidden = !open; $("#btn-menu").setAttribute("aria-expanded", String(open)); if (open) $("#menu button")?.focus(); } $("#btn-edit").onclick = () => setEdit(!editMode); $("#btn-kiosk").onclick = () => enterKiosk(); $("#btn-undo").onclick = undo; $("#btn-redo").onclick = redo; $("#dash-select").onchange = (e) => setCurrent(e.target.value); $("#btn-new").onclick = async () => { const name = await askText(tr("New dashboard"), tr("New dashboard"), tr("Create")); if (!name) return; const d = newDashboard(name); store.dashboards.push(d); scheduleSave(); setCurrent(d.id); setEdit(true); }; $("#btn-menu").onclick = (e) => { e.stopPropagation(); setMenu($("#menu").hidden); }; document.addEventListener("click", (e) => { if (!e.target.closest("#menu")) setMenu(false); }); $("#menu").addEventListener("click", async (e) => { const act = e.target.closest("[data-menu]")?.dataset.menu; if (!act) return; setMenu(false); if (act === "rename") { const n = await askText(tr("Rename dashboard"), current.name, tr("Rename")); if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); } } else if (act === "assign") { assignDialog(); } else if (act === "starter") { if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } const d = starterDashboard(); store.dashboards.push(d); scheduleSave(); setCurrent(d.id); toast(tr("Created “{0}” from your devices.", d.name)); } else if (act === "duplicate") { const d = clone(current); d.id = uid(); d.name += ` ${tr("copy")}`; d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); }); store.dashboards.push(d); scheduleSave(); setCurrent(d.id); } else if (act === "export") { const blob = new Blob([JSON.stringify(current, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${current.name.replace(/[^\w-]+/g, "_")}.dashboard.json`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } else if (act === "import") { $("#import-file").click(); } else if (act === "delete") { if (store.dashboards.length === 1) { toast(tr("This is your only dashboard. Create another one before deleting it."), true); return; } const ok = await confirmDestructive(tr("Delete “{0}”?", current.name), tr("The dashboard and its widgets will be removed. This can’t be undone."), tr("Delete")); if (!ok) return; store.dashboards = store.dashboards.filter((d) => d.id !== current.id); scheduleSave(); setCurrent(store.dashboards[0].id); } }); $("#import-file").onchange = async (e) => { const file = e.target.files[0]; e.target.value = ""; if (!file) return; try { const d = migrate(JSON.parse(await file.text())); if (!d.tabs.every((t) => Array.isArray(t.widgets))) throw new Error(tr("the file has no widgets list")); d.id = uid(); d.tabs.forEach((t) => { t.id = uid(); t.widgets.forEach((w) => (w.id = uid())); }); d.name = d.name || file.name; store.dashboards.push(d); scheduleSave(); setCurrent(d.id); toast(tr("Imported “{0}”.", d.name)); } catch (err) { toast(tr("Couldn’t import: {0}.", err.message), true); } }; navEl.addEventListener("click", (e) => { const op = e.target.closest("[data-tabop]"); if (op) { tabOp(op.dataset.tabop); return; } const b = e.target.closest("[data-tab]"); if (b) setTab(b.dataset.tab); }); $("#palette-list").addEventListener("click", (e) => { const b = e.target.closest("[data-add]"); if (b) addWidget(b.dataset.add); }); const insp = $("#inspector"); insp.addEventListener("input", onInspectorInput); insp.addEventListener("change", onInspectorInput); insp.addEventListener("click", (e) => { const seg = e.target.closest(".seg button[data-path], .pos9 button[data-path]"); if (seg) { applyField(seg, seg.dataset.v, true); return; } const p = e.target.closest("[data-preset]"); if (p) { mutate(() => { const keep = { surface: current.theme.surface, radius: current.theme.radius, bgImage: current.theme.bgImage }; current.theme = { ...DEFAULT_THEME, ...PRESETS[p.dataset.preset], ...keep, preset: p.dataset.preset }; delete current.theme.swatch; }); renderInspector(); return; } const del = e.target.closest("[data-ent-del]"); if (del) { const w = editTarget(); // may be a card inside a scroll or swipe card mutate(() => (w.cfg[del.dataset.k] || []).splice(Number(del.dataset.entDel), 1), null, { rerender: false }); renderWidget(selectedWidget()); renderInspector(); return; } const childEdit = e.target.closest("[data-child-edit]"), childBtn = e.target.closest("[data-child-op]"); if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; } if (childBtn) { childOp(childBtn.dataset.childOp, Number(childBtn.dataset.i)); return; } if (e.target.closest("[data-child-back]")) { childIndex = null; renderInspector(); return; } if (e.target.closest("[data-whatsnew]")) { whatsNew(); return; } const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]"); if (ruleAdd || ruleDel) { const w = tab().widgets.find((x) => x.id === selectedId); mutate(() => { w.visibility ||= { mode: "all", rules: [] }; if (ruleAdd) w.visibility.rules.push({ entity: "", op: "is", value: "" }); else w.visibility.rules.splice(Number(ruleDel.dataset.ruleDel), 1); }); renderInspector(); if (ruleAdd) $(`#inspector [data-path="rule"][data-idx="${w.visibility.rules.length - 1}"][data-k="entity"]`)?.focus(); return; } const op = e.target.closest("[data-tabop]"); if (op) { tabOp(op.dataset.tabop, op.dataset.tabId); return; } const k = e.target.closest("[data-kiosk]"); if (k) { if (k.dataset.kiosk === "here") enterKiosk(); else window.open(new URL(`#kiosk&view=${current.id}`, BASE).href, "_blank", "noopener"); return; } const tool = e.target.closest("[data-tool]"); if (tool && selectedId) tool.dataset.tool === "dup" ? duplicate(selectedId) : remove(selectedId); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { if (modalClose) { modalClose(null); return; } if (!$("#menu").hidden) { setMenu(false); $("#btn-menu").focus(); return; } if (kiosk) { kioskPrompt(); return; } } if (!editMode || modalClose) return; const inField = /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName); const mod = e.ctrlKey || e.metaKey; if (mod && e.key.toLowerCase() === "z" && !inField) { e.preventDefault(); e.shiftKey ? redo() : undo(); return; } if (mod && e.key.toLowerCase() === "y" && !inField) { e.preventDefault(); redo(); return; } if (inField || !selectedId) return; const w = tab().widgets.find((x) => x.id === selectedId); if (!w) return; if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); remove(w.id); } else if (mod && e.key.toLowerCase() === "d") { e.preventDefault(); duplicate(w.id); } else if (e.key === "Escape") select(null); else if (e.key.startsWith("Arrow")) { e.preventDefault(); const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; mutate(() => { if (e.shiftKey) { w.w = Math.max(1, Math.min(current.grid.cols - w.x, w.w + d[0])); w.h = Math.max(1, w.h + d[1]); } else { w.x = Math.max(0, Math.min(current.grid.cols - w.w, w.x + d[0])); w.y = Math.max(0, w.y + d[1]); } resolveCollisions(w); }, `kbd-${w.id}`); select(w.id); } }); let resizeRaf = 0; window.addEventListener("resize", () => { cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(renderGrid); }); matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", () => renderGrid()); // clocks, screen saver and camera refresh setInterval(() => { gridEl.querySelectorAll("[data-clock]").forEach((el) => { el.innerHTML = clockHTML(el.dataset.h24 === "1", el.dataset.sec === "1", el.dataset.date === "1"); }); if (!$("#saver").hidden) updateSaver(); const now = Date.now(); gridEl.querySelectorAll("img[data-cam]").forEach((img) => { const every = Number(img.dataset.refresh) * 1000; if (!img._last) img._last = now; if (now - img._last >= every) { img._last = now; img.src = `api/camera/${encodeURIComponent(img.dataset.cam)}?t=${now}`; } }); }, 1000); // keep "x min ago" labels and graph time windows fresh setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000); // ------------------------------------------------------------ boot function paintChromeIcons() { $("#btn-new").innerHTML = Icons.svg("plus"); $("#btn-undo").innerHTML = Icons.svg("undo"); $("#btn-redo").innerHTML = Icons.svg("redo"); $("#btn-menu").innerHTML = Icons.svg("more"); $("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk")); document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon))); } ha.onStatus = (ok) => { const c = $("#conn"); c.classList.toggle("ok", ok); c.textContent = ok ? tr("Connected") : tr("Offline"); // An update restarts the app, which drops the connection: check right after reconnecting. if (ok) checkVersion(); }; /** If the app was updated while this page was open, load the new version. */ let updateNoticeShown = false; async function checkVersion() { if (APP_VERSION === "dev") return; let latest; try { latest = (await api("api/version")).version; } catch { return; } if (!latest || latest === APP_VERSION) return; if (editMode) { // never yank the editor away; just say so once if (!updateNoticeShown) toast(tr("Dashboard Studio was updated to {0}. Reload the page to use it.", latest)); updateNoticeShown = true; return; } toast(tr("Dashboard Studio was updated to {0}. Reloading…", latest)); if (savePending) { clearTimeout(saveTimer); await save(); } // keep edits still waiting to be saved setTimeout(() => location.reload(), 2500); } setInterval(checkVersion, 5 * 60 * 1000); ha.onChange = onStateChange; ha.onRegistry = () => { renderGrid(); if (editMode) renderInspector(); }; /** Translate the static page: elements marked data-i18n, plus every aria-label and title. */ function translateStatic() { document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = tr(el.textContent.trim()); }); for (const attr of ["aria-label", "title"]) document.querySelectorAll(`[${attr}]`).forEach((el) => el.setAttribute(attr, tr(el.getAttribute(attr)))); } async function boot() { translateStatic(); paintChromeIcons(); renderPalette(); try { const r = await fetch(new URL("api/dashboards", BASE)); store = await r.json(); store.dashboards = (store.dashboards || []).map(migrate); store.assign = { devices: {}, users: {}, ...(store.assign || {}) }; } catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); } const firstReady = new Promise((res) => { const prev = ha.onStatus; ha.onStatus = (ok) => { prev(ok); if (ok) res(); }; setTimeout(res, 4000); }); ha.connect(); if (!store.dashboards?.length) { await firstReady; store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } }; save(); } me = await api("api/whoami").catch(() => null); const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1]; // Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here. const dev = store.assign.devices[DEVICE_ID], forUser = me?.id && store.assign.users[me.id]; const exists = (id) => id && store.dashboards.some((d) => d.id === id); setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists)); if (location.hash.includes("kiosk") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false }); setTimeout(phoneHint, 1500); // After an update, point at what changed (once per device). const seen = lsGet("ds.seenVersion"); if (APP_VERSION !== "dev" && seen && seen !== APP_VERSION) setTimeout(() => toast(tr("Updated to {0}. See what’s new under About in the settings.", APP_VERSION)), 2000); if (APP_VERSION !== "dev") lsSet("ds.seenVersion", APP_VERSION); } boot(); })();