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();
|
||||
|
||||
@@ -245,6 +245,19 @@
|
||||
"About": "Over", "Version {0}": "Versie {0}", "Source code and changes": "Broncode en wijzigingen",
|
||||
"Dashboard Studio was updated to {0}. Reload the page to use it.": "Dashboard Studio is bijgewerkt naar {0}. Herlaad de pagina om de nieuwe versie te gebruiken.",
|
||||
"Dashboard Studio was updated to {0}. Reloading…": "Dashboard Studio is bijgewerkt naar {0}. Opnieuw laden…",
|
||||
"Graphite": "Grafiet", "Indigo": "Indigo", "Rose": "Roze", "Ocean": "Oceaan", "Sunrise": "Zonsopgang", "Lavender": "Lavendel", "Mint": "Munt", "Sand": "Zand", "High contrast": "Hoog contrast",
|
||||
"Horizontal scroll": "Horizontaal scrollen", "Swipe cards": "Veegkaarten", "Card width (px)": "Kaartbreedte (px)",
|
||||
"Scroll left": "Scroll naar links", "Scroll right": "Scroll naar rechts", "Next card every (seconds)": "Volgende kaart elke (seconden)",
|
||||
"Show page dots": "Toon paginapuntjes", "Card {0} of {1}": "Kaart {0} van {1}", "Cards": "Kaarten",
|
||||
"None of these cards are showing right now.": "Geen van deze kaarten is nu zichtbaar.", "Add cards in the inspector.": "Voeg kaarten toe in de inspector.",
|
||||
"Add the cards this container should hold.": "Voeg de kaarten toe die hierin moeten komen.", "Card type": "Soort kaart", "Add": "Toevoegen",
|
||||
"Remove from container": "Verwijder uit deze kaart",
|
||||
"Who sees which dashboard…": "Wie ziet welk dashboard…", "Who sees which dashboard": "Wie ziet welk dashboard",
|
||||
"Choose the dashboard that opens first. A device’s choice comes before a person’s; otherwise the last dashboard used on that device opens.": "Kies welk dashboard als eerste opent. De keuze voor een apparaat gaat voor die van een persoon; anders opent het dashboard dat op dat apparaat het laatst is gebruikt.",
|
||||
"This device": "Dit apparaat", "Other devices": "Andere apparaten", "People": "Personen", "Last used": "Laatst gebruikt",
|
||||
"Name this device, e.g. Kitchen tablet": "Geef dit apparaat een naam, bijv. Keukentablet", "Unnamed device": "Apparaat zonder naam",
|
||||
"Device name": "Apparaatnaam", "Forget this device": "Vergeet dit apparaat", "you": "jij", "Dashboard for {0}": "Dashboard voor {0}",
|
||||
"People appear here once they have opened Dashboard Studio, or when they have a person entity.": "Personen verschijnen hier zodra ze Dashboard Studio hebben geopend, of als ze een persoonsentiteit hebben.",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
dishwasher: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 8h16M8 5.5h.01M11 5.5h.01M8 12v5M12 12v5M16 12v5",
|
||||
eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
|
||||
"eye-off": "M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.1M6.6 6.6A17.6 17.6 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2",
|
||||
hscroll: "M3 6h5v12H3zM10 6h5v12h-5zM17 6h4M17 18h4M21 6v12",
|
||||
swipe: "M6 4h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 21h.01M12 21h.01M15 21h.01",
|
||||
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
|
||||
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
|
||||
chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button>
|
||||
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
|
||||
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
|
||||
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
|
||||
<hr>
|
||||
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
|
||||
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
|
||||
|
||||
@@ -210,6 +210,14 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
|
||||
.modal-card p { margin: 6px 0 0; color: var(--text-2); }
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; }
|
||||
.modal-actions .btn { min-width: 96px; }
|
||||
.modal-card.wide { width: 560px; }
|
||||
.assign-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.assign-row .input { flex: 1; min-width: 0; }
|
||||
.assign-row select.input { flex: 1.2; }
|
||||
.assign-name { flex: 1; min-width: 0; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.assign-kiosk { display: flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; }
|
||||
.assign-kiosk input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
|
||||
@media (max-width: 560px) { .assign-row[data-dev] { flex-wrap: wrap; } .assign-row[data-dev] .input { flex-basis: 100%; } }
|
||||
.attr-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 14px; }
|
||||
.attr-table td { border-top: 1px solid var(--separator); padding: 7px 4px; vertical-align: top; word-break: break-word; }
|
||||
.attr-table td:first-child { color: var(--text-2); width: 40%; }
|
||||
@@ -264,7 +272,7 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
|
||||
.w.lit-lamp .badge { background: rgba(255, 255, 255, 0.55); color: var(--lit-text); }
|
||||
.surface-plain .w.lit-lamp, .surface-outline .w.lit-lamp { background: transparent; color: var(--w-color, var(--d-text)); --d-text-2: var(--text-2); --d-fill: var(--fill); }
|
||||
.surface-plain .w.lit-lamp .badge, .surface-outline .w.lit-lamp .badge { background: color-mix(in srgb, var(--lamp, var(--d-lit)) 45%, var(--d-lit)); color: var(--lit-text); }
|
||||
.w.lit-on .badge { background: var(--accent-w); color: #fff; }
|
||||
.w.lit-on .badge { background: var(--accent-w); color: var(--d-on-accent, #fff); }
|
||||
|
||||
/* edit mode */
|
||||
body.editing #grid { background-image: var(--cell-bg); background-repeat: repeat; min-height: calc(100% - 16px); }
|
||||
@@ -383,7 +391,7 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
|
||||
[aria-checked="true"] > .switch { background: var(--ok); }
|
||||
[aria-checked="true"] > .switch::after { transform: translateX(18px); }
|
||||
.btn-widget { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; height: 100%; width: 100%; gap: 8px; }
|
||||
.btn-widget:active .badge { background: var(--accent-w); color: #fff; }
|
||||
.btn-widget:active .badge { background: var(--accent-w); color: var(--d-on-accent, #fff); }
|
||||
.text-w { white-space: pre-wrap; overflow: auto; height: 100%; line-height: 1.45; }
|
||||
.frame { width: 100%; height: 100%; border: 0; display: block; }
|
||||
.img-w { width: 100%; height: 100%; display: block; }
|
||||
@@ -495,12 +503,52 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
/* appliance */
|
||||
.appl { display: flex; flex-direction: column; height: 100%; gap: 8px; justify-content: space-between; }
|
||||
.appl-status { margin-left: auto; font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
|
||||
.appl.phase-running .appl-status { background: var(--d-accent); color: #fff; }
|
||||
.appl.phase-running .appl-status { background: var(--d-accent); color: var(--d-on-accent, #fff); }
|
||||
.theme-outline .w:not(.transparent) { border-color: var(--d-separator); }
|
||||
.appl.phase-done .appl-status { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
|
||||
.appl-big { font-size: 22px; font-weight: 700; }
|
||||
.appl-big .wt { font-size: 14px; font-weight: 400; }
|
||||
.appl .car-fill { background: var(--d-accent); }
|
||||
|
||||
/* containers: horizontal scroll and swipe */
|
||||
.w.child { position: relative; flex: none; height: 100%; }
|
||||
.hs { position: relative; height: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||
.hs-title { flex: none; padding: 0 4px; }
|
||||
.hs-track {
|
||||
flex: 1; min-height: 0; display: flex; gap: var(--d-gap, 14px); overflow-x: auto; overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory; scroll-padding: 0 2px; scrollbar-width: none; overscroll-behavior-x: contain;
|
||||
}
|
||||
.hs-track::-webkit-scrollbar { display: none; }
|
||||
.hs-track > .w.child { width: var(--item-w, 260px); scroll-snap-align: start; }
|
||||
.hs-track:focus-visible { box-shadow: var(--focus); border-radius: var(--d-radius, 20px); }
|
||||
.hs-nav {
|
||||
position: absolute; top: 50%; z-index: 4; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: var(--glass); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); color: var(--text);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); font-size: 18px; opacity: 0; transition: opacity .15s;
|
||||
}
|
||||
.hs-nav.prev { left: 6px; } .hs-nav.next { right: 6px; }
|
||||
.hs:hover .hs-nav, .hs-nav:focus-visible { opacity: 1; }
|
||||
.hs.at-start .hs-nav.prev, .hs.at-end .hs-nav.next { opacity: 0; pointer-events: none; }
|
||||
@media (hover: none) { .hs-nav { display: none; } }
|
||||
.i.back { transform: rotate(90deg); } .i.fwd { transform: rotate(-90deg); }
|
||||
.sw { height: 100%; display: flex; flex-direction: column; gap: 8px; }
|
||||
.sw-track { flex: 1; min-height: 0; display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; overscroll-behavior-x: contain; border-radius: var(--d-radius, 20px); }
|
||||
.sw-track::-webkit-scrollbar { display: none; }
|
||||
.sw-track > .w.child { width: 100%; scroll-snap-align: center; scroll-snap-stop: always; }
|
||||
.sw-dots { flex: none; display: flex; justify-content: center; gap: 2px; }
|
||||
.sw-dot { width: 22px; height: 22px; display: grid; place-items: center; }
|
||||
.sw-dot::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--d-text-2); opacity: .45; transition: width .2s, opacity .2s; }
|
||||
.sw-dot[aria-selected="true"]::after { width: 18px; border-radius: 4px; opacity: 1; background: var(--d-text); }
|
||||
.btn.back { margin: 0 0 10px; padding-left: 8px; }
|
||||
.child-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.child-item { display: flex; gap: 4px; align-items: center; }
|
||||
.child-open { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 0; border-radius: 10px; background: var(--fill); cursor: pointer; text-align: left; }
|
||||
.child-open:hover { background: var(--fill-strong); }
|
||||
.child-open .grow { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.child-open .grow span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.child-open .i { font-size: 18px; flex: none; }
|
||||
.child-item .btn.icon { width: 30px; min-width: 30px; flex: none; }
|
||||
|
||||
/* photos */
|
||||
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
||||
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
|
||||
|
||||
@@ -831,6 +831,90 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- containers
|
||||
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
|
||||
// ctx.renderChildren fills them using the normal widget renderer.
|
||||
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
|
||||
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
|
||||
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
|
||||
|
||||
W.hscroll = {
|
||||
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
|
||||
fields: [
|
||||
{ k: "title", t: "text", l: "Title", d: "" },
|
||||
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
|
||||
],
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
|
||||
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
|
||||
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
|
||||
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
|
||||
},
|
||||
after(el, c, ctx) {
|
||||
ctx.renderChildren(el);
|
||||
const track = el.querySelector(".hs-track"), box = el.querySelector(".hs");
|
||||
if (!track) return;
|
||||
// Keep the scroll position when a card inside updates and everything is redrawn.
|
||||
track.scrollLeft = ctx.cache.scroll || 0;
|
||||
const edges = () => {
|
||||
box.classList.toggle("at-start", track.scrollLeft < 4);
|
||||
box.classList.toggle("at-end", track.scrollLeft + track.clientWidth > track.scrollWidth - 4);
|
||||
};
|
||||
track.addEventListener("scroll", () => { ctx.cache.scroll = track.scrollLeft; edges(); }, { passive: true });
|
||||
edges();
|
||||
},
|
||||
};
|
||||
|
||||
W.swipe = {
|
||||
label: "Swipe cards", icon: "swipe", size: [4, 3], container: true, transparent: true, flush: true,
|
||||
fields: [
|
||||
{ k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" },
|
||||
{ k: "dots", t: "check", l: "Show page dots", d: true },
|
||||
],
|
||||
render(c, ctx) {
|
||||
const kids = slots(c, ctx);
|
||||
if (!kids.length) return noKids(c);
|
||||
const dots = c.dots !== false && kids.length > 1
|
||||
? `<div class="sw-dots" role="tablist" aria-label="${t("Cards")}">${kids.map((_, i) => `<button class="sw-dot wbtn" role="tab" data-act="sw-go" data-v="${i}" aria-label="${t("Card {0} of {1}", i + 1, kids.length)}" aria-selected="false"></button>`).join("")}</div>` : "";
|
||||
return `<div class="sw ${dots ? "with-dots" : ""}"><div class="sw-track">${kids.map(slot).join("")}</div>${dots}</div>`;
|
||||
},
|
||||
after(el, c, ctx) {
|
||||
ctx.renderChildren(el);
|
||||
const track = el.querySelector(".sw-track");
|
||||
if (!track) return;
|
||||
const cache = ctx.cache, n = track.children.length;
|
||||
const page = () => Math.round(track.scrollLeft / Math.max(1, track.clientWidth));
|
||||
const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page())));
|
||||
track.scrollLeft = (Math.min(cache.page || 0, n - 1)) * track.clientWidth;
|
||||
mark();
|
||||
// Only settled positions count (scrollend), so an autoplay step in flight isn't overwritten.
|
||||
track.addEventListener("scroll", mark, { passive: true });
|
||||
const settle = () => { cache.page = page(); mark(); };
|
||||
if ("onscrollend" in window) track.addEventListener("scrollend", settle);
|
||||
else { let t0; track.addEventListener("scroll", () => { clearTimeout(t0); t0 = setTimeout(settle, 150); }, { passive: true }); }
|
||||
|
||||
// One timer per card that survives redraws (cards inside update often); it always
|
||||
// works on the latest track, which each redraw records in the cache.
|
||||
cache.track = track;
|
||||
track.addEventListener("pointerdown", () => { cache.pausedUntil = Date.now() + 20000; }, { passive: true });
|
||||
const every = Math.max(3, Number(c.autoplay) || 0) * 1000;
|
||||
if (!(Number(c.autoplay) > 0) || n < 2) { clearInterval(cache.timer); cache.timer = null; return; }
|
||||
if (cache.timer && cache.every === every) return;
|
||||
clearInterval(cache.timer);
|
||||
cache.every = every;
|
||||
cache.timer = setInterval(() => {
|
||||
const tr = cache.track;
|
||||
if (!tr?.isConnected || Date.now() < (cache.pausedUntil || 0) || document.body.classList.contains("editing")) return;
|
||||
const count = tr.children.length, next = ((cache.page || 0) + 1) % count;
|
||||
cache.page = next; // a redraw mid-animation then lands on the new card, not the old one
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
tr.scrollTo({ left: next * tr.clientWidth, behavior: reduce ? "auto" : "smooth" });
|
||||
}, every);
|
||||
},
|
||||
};
|
||||
|
||||
window.Widgets = W;
|
||||
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user