Dashboard Studio 1.7.0: scroll and swipe cards, more themes, per-device dashboards
- Container cards: horizontal scroll (snap, arrows, keeps position) and swipe (dots, optional autoplay that survives redraws and respects reduced motion), with add/edit/reorder of the cards inside - Nine new themes, contrast-checked, including High contrast; bright accents get dark text - Choose which dashboard opens per device (optionally in kiosk mode) and per Home Assistant user, via /api/whoami and the ingress user headers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+167
-18
@@ -19,6 +19,7 @@
|
||||
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 {
|
||||
@@ -131,14 +132,24 @@
|
||||
|
||||
// ------------------------------------------------------------ themes
|
||||
// "System" keeps the adaptive light/dark tokens from style.css.
|
||||
const COLOR_KEYS = ["bg", "tile", "text", "text2", "accent", "separator", "fill", "lit"];
|
||||
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", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 },
|
||||
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 };
|
||||
@@ -170,6 +181,7 @@
|
||||
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: "" } };
|
||||
}
|
||||
|
||||
@@ -233,13 +245,29 @@
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ app state
|
||||
let store = { version: 1, dashboards: [] };
|
||||
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)
|
||||
@@ -299,7 +327,7 @@
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering
|
||||
const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" };
|
||||
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;
|
||||
@@ -312,6 +340,8 @@
|
||||
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");
|
||||
@@ -473,9 +503,17 @@
|
||||
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] ||= {}) });
|
||||
const ctxFor = (w) => ({
|
||||
ha, w, api, cache: (caches[w.id] ||= {}),
|
||||
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}"]`)) {
|
||||
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; }
|
||||
@@ -492,11 +530,11 @@
|
||||
}
|
||||
let html;
|
||||
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
|
||||
const hiddenNow = editMode && !isVisible(w);
|
||||
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 ? `<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>` : "");
|
||||
(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);
|
||||
}
|
||||
@@ -504,6 +542,7 @@
|
||||
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] : [];
|
||||
}
|
||||
@@ -623,10 +662,21 @@
|
||||
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") : ""}
|
||||
@@ -706,6 +756,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 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"];
|
||||
@@ -772,8 +856,8 @@
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
const w = tab().widgets.find((x) => x.id === selectedId);
|
||||
const key = `${selectedId}|${path}|${k}|${idx ?? ""}`;
|
||||
const w = editTarget();
|
||||
const key = `${w?.id}|${path}|${k}|${idx ?? ""}`;
|
||||
|
||||
if (idx === "new") {
|
||||
if (!isChange || !v) return;
|
||||
@@ -807,7 +891,7 @@
|
||||
else if (path === "dash") { current.name = v; renderDashSelect(); }
|
||||
}, key, { rerender: ["pos", "theme", "grid"].includes(path) });
|
||||
|
||||
if (path === "cfg" || path === "style") renderWidget(w);
|
||||
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();
|
||||
@@ -870,6 +954,7 @@
|
||||
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);
|
||||
@@ -883,6 +968,7 @@
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (id !== selectedId) childIndex = null;
|
||||
selectedId = id;
|
||||
gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", el.dataset.id === id));
|
||||
renderInspector();
|
||||
@@ -977,14 +1063,22 @@
|
||||
ha.callService("climate", "set_temperature", { temperature: t }, eid);
|
||||
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
|
||||
else if (act === "mp-select") {
|
||||
const w = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id);
|
||||
const box = a.closest(".w"), w = findWidget(box.dataset.id);
|
||||
caches[w.id].sel = a.dataset.v;
|
||||
renderWidget(w);
|
||||
gridEl.querySelector(`.w[data-id="${w.id}"] [data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus();
|
||||
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 === "sw-go") {
|
||||
const track = a.closest(".sw").querySelector(".sw-track");
|
||||
track.scrollTo({ left: Number(a.dataset.v) * track.clientWidth, behavior: reducedMotion() ? "auto" : "smooth" });
|
||||
}
|
||||
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
|
||||
else if (act === "button") {
|
||||
const c = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id).cfg;
|
||||
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); }
|
||||
@@ -1064,6 +1158,50 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** 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" : "");
|
||||
@@ -1219,6 +1357,8 @@
|
||||
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();
|
||||
@@ -1294,6 +1434,10 @@
|
||||
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
|
||||
renderWidget(w); 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);
|
||||
@@ -1416,6 +1560,7 @@
|
||||
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) => {
|
||||
@@ -1427,12 +1572,16 @@
|
||||
|
||||
if (!store.dashboards?.length) {
|
||||
await firstReady;
|
||||
store = { version: 1, dashboards: [starterDashboard()] };
|
||||
store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } };
|
||||
save();
|
||||
}
|
||||
me = await api("api/whoami").catch(() => null);
|
||||
const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1];
|
||||
setCurrent(hashId || lsGet("ds.current"));
|
||||
if (location.hash.includes("kiosk")) enterKiosk({ fullscreen: false });
|
||||
// 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();
|
||||
|
||||
Reference in New Issue
Block a user