Dashboard Studio 1.9.0: real swiping, tap for next photo, bigger vacuum card
- Swipe card: pointer-driven drag for mouse and touch with resistance at the ends and flick detection, keyboard arrows, cards fill the whole card, page dots float and hide after a few seconds; updates wait until a swipe ends and the release never presses a button underneath - Tapping the clock background or a Photos card skips to the next photo - Media card: removing players works inside scroll/swipe cards, and pre-1.5 single-player cards are migrated into the list - Vacuum card grows with its size: robot with battery ring, cleaning pulse and charging badge, stat tiles, suction power and labelled buttons; compact layout on small cards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+128
-40
@@ -358,7 +358,8 @@
|
||||
const wx = s ? `<button class="clock-wx wbtn" data-act="more" data-e="${esc(c.weather)}" aria-label="${esc(friendly(s))}: ${esc(st(s.state))}, ${esc(s.attributes.temperature ?? "–")}°">
|
||||
<span class="clock-wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span><span class="clock-wx-temp num">${esc(s.attributes.temperature ?? "–")}°</span>
|
||||
<span class="clock-wx-text">${esc(st(s.state))}</span><span class="clock-hl num" data-hl></span></button>` : "";
|
||||
return `<div class="clock pos-${pos} ${bg ? "on-image" : ""}" style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
|
||||
return `<div class="clock pos-${pos} ${bg ? "on-image" : ""}" ${bg ? 'data-act="photo-next"' : ""} style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
|
||||
${bg ? `<button class="sr-only" data-act="photo-next">${t("Next photo")}</button>` : ""}
|
||||
${bg ? `<div class="photos clock-bg" aria-hidden="true"><img class="ph-layer" alt=""><img class="ph-layer" alt=""></div><div class="clock-dim"></div>` : ""}
|
||||
<div class="clock-content"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div></div>`;
|
||||
},
|
||||
@@ -477,37 +478,66 @@
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- vacuum
|
||||
// One layout that grows with the card: compact on small cards; a robot with a battery ring,
|
||||
// stats and labelled buttons on bigger ones (see the container queries in style.css).
|
||||
W.vacuum = {
|
||||
label: "Vacuum", icon: "vacuum", size: [4, 3],
|
||||
label: "Vacuum", icon: "vacuum", size: [4, 5],
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Vacuum", domains: ["vacuum"] },
|
||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||
{ k: "map", t: "entity", l: "Map camera (optional)", domains: ["camera", "image"] },
|
||||
{ k: "refresh", t: "number", l: "Refresh the map every (seconds)", d: 5, when: (c) => !!c.map },
|
||||
{ k: "battery", t: "entity", l: "Battery sensor (if not on the vacuum)", domains: ["sensor"] },
|
||||
{ k: "area", t: "entity", l: "Cleaned area", domains: ["sensor"] },
|
||||
{ k: "duration", t: "entity", l: "Cleaning time", domains: ["sensor"] },
|
||||
{ k: "entities", t: "entities", l: "More sensors, e.g. filter or brush life", domains: ["sensor", "binary_sensor"] },
|
||||
{ k: "fan", t: "check", l: "Show suction power", d: true },
|
||||
],
|
||||
entities: (c) => [c.entity, c.battery].filter(Boolean),
|
||||
entities: (c) => [c.entity, c.battery, c.area, c.duration, ...(c.entities || [])].filter(Boolean),
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a vacuum");
|
||||
const a = s.attributes, e = esc(c.entity);
|
||||
const cleaning = s.state === "cleaning", busy = cleaning || s.state === "returning";
|
||||
const a = s.attributes, e = esc(c.entity), name = friendly(s, c.name);
|
||||
const cleaning = s.state === "cleaning", returning = s.state === "returning";
|
||||
const batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level);
|
||||
const status = a.status && String(a.status).toLowerCase() !== s.state ? `${st(s.state)} · ${a.status}` : st(s.state);
|
||||
const hasBatt = isFinite(batt);
|
||||
const charging = s.state === "docked" && hasBatt && batt < 100;
|
||||
const headline = cleaning ? t("Cleaning…") : returning ? t("On its way to the dock") : charging ? t("Charging") : st(s.state);
|
||||
const detail = a.status && !headline.toLowerCase().includes(String(a.status).toLowerCase()) ? a.status : "";
|
||||
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
|
||||
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
|
||||
return `<div class="vac ${s.state === "error" ? "is-error" : ""}">
|
||||
<div class="tile-row"><span class="badge">${Icons.svg("vacuum")}</span>
|
||||
<span class="tile-text"><span class="name">${esc(friendly(s, c.name))}</span><span class="state">${esc(status)}</span></span>
|
||||
${isFinite(batt) ? `<span class="vac-batt" aria-label="${t("Battery {0}%", Math.round(batt))}">${Icons.svg("battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
|
||||
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : `<div class="vac-spacer"></div>`}
|
||||
${speeds.length ? `<div class="chips" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="chip wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
|
||||
<div class="ctrls">
|
||||
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="vacuum.${cleaning ? "pause" : "start"}" aria-label="${cleaning ? t("Pause") : t("Start cleaning")}">${Icons.svg(cleaning ? "pause" : "play")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.return_to_base" aria-label="${t("Send back to the dock")}" ${s.state === "docked" ? "disabled" : ""}>${Icons.svg("home")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.locate" aria-label="${t("Locate (makes a sound)")}">${Icons.svg("pin")}</button>
|
||||
${busy ? `<span class="wt vac-hint">${cleaning ? t("Cleaning…") : t("On its way to the dock")}</span>` : ""}
|
||||
|
||||
// battery ring around the robot
|
||||
const R = 46, C = 2 * Math.PI * R, frac = hasBatt ? Math.max(0, Math.min(1, batt / 100)) : 0;
|
||||
const ring = `<svg class="vac-ring" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="${R}" class="track"/>
|
||||
${hasBatt ? `<circle cx="50" cy="50" r="${R}" class="fill ${batt <= 20 ? "low" : ""}" stroke-dasharray="${(C * frac).toFixed(1)} ${C.toFixed(1)}"/>` : ""}</svg>`;
|
||||
|
||||
const stat = (label, value, ico) => `<div class="vac-stat">${Icons.svg(ico)}<span class="vac-stat-v num">${esc(value)}</span><span class="vac-stat-l">${esc(label)}</span></div>`;
|
||||
const stats = [
|
||||
hasBatt ? stat(t("Battery"), `${Math.round(batt)}%`, "battery") : "",
|
||||
c.area && ha.states[c.area] ? stat(t("Cleaned area"), fmtState(ha.states[c.area]), "tile") : "",
|
||||
c.duration && ha.states[c.duration] ? stat(t("Cleaning time"), fmtState(ha.states[c.duration]), "clock") : "",
|
||||
...(c.entities || []).map((x) => ha.states[x] && stat(friendly(ha.states[x]).replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i"), ""), fmtState(ha.states[x]), "pulse")),
|
||||
].filter(Boolean).join("");
|
||||
|
||||
const btn = (svc, label, ico, primary, disabled) => `<button class="vac-btn wbtn ${primary ? "primary" : ""}" data-act="svc" data-e="${e}" data-v="vacuum.${svc}" ${disabled ? "disabled" : ""}>${Icons.svg(ico)}<span>${esc(label)}</span></button>`;
|
||||
|
||||
return `<div class="vac state-${esc(s.state)}">
|
||||
<div class="vac-head"><span class="badge">${Icons.svg("vacuum")}</span>
|
||||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></span>
|
||||
${hasBatt ? `<span class="vac-batt">${Icons.svg(charging ? "bolt" : "battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
|
||||
<div class="vac-hero">
|
||||
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">`
|
||||
: `<div class="vac-robot ${cleaning ? "is-cleaning" : ""}">${ring}<span class="vac-pulse" aria-hidden="true"></span><span class="vac-robot-ico">${Icons.svg("vacuum")}</span>
|
||||
${charging ? `<span class="vac-charge" aria-hidden="true">${Icons.svg("bolt")}</span>` : ""}</div>
|
||||
<div class="vac-hero-text"><span class="vac-headline">${esc(headline)}</span>${hasBatt ? `<span class="wt">${t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
|
||||
</div>
|
||||
${stats ? `<div class="vac-stats">${stats}</div>` : ""}
|
||||
${speeds.length ? `<div class="seg vac-fan" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
|
||||
<div class="vac-actions">
|
||||
${btn(cleaning ? "pause" : "start", cleaning ? t("Pause") : returning ? t("Resume") : t("Start"), cleaning ? "pause" : "play", true)}
|
||||
${btn("return_to_base", t("Dock"), "home", false, s.state === "docked")}
|
||||
${btn("locate", t("Locate"), "pin")}
|
||||
</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false),
|
||||
@@ -694,7 +724,8 @@
|
||||
render(c) {
|
||||
const empty = c.source === "immich" ? (c.albums?.length ? "" : t("Choose one or more Immich albums in the inspector.")) : urlList(c).length ? "" : t("Add image URLs in the inspector.");
|
||||
if (empty) return `<div class="pad"><div class="empty">${empty}</div></div>`;
|
||||
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||||
return `<div class="photos" data-act="photo-next" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||||
<button class="sr-only" data-act="photo-next">${t("Next photo")}</button>
|
||||
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
|
||||
},
|
||||
after(el, c, ctx) {
|
||||
@@ -742,6 +773,8 @@
|
||||
next.src = item.src;
|
||||
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(o.interval) || 20) * 1000);
|
||||
};
|
||||
// Tapping the card skips ahead; the automatic timer restarts from there.
|
||||
cache.next = cache.items.length > 1 ? () => { clearTimeout(cache.timer); show(); } : null;
|
||||
show();
|
||||
}
|
||||
|
||||
@@ -936,46 +969,101 @@
|
||||
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 },
|
||||
{ k: "dots", t: "check", l: "Show page dots while swiping", 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>`;
|
||||
return `<div class="sw" role="group" aria-roledescription="carousel" aria-label="${t("Swipe cards")}">
|
||||
<div class="sw-track" tabindex="0">${kids.map(slot).join("")}</div>${dots}</div>`;
|
||||
},
|
||||
// Swiping is done by hand (pointer events + transform), so it works with a mouse as well as touch,
|
||||
// follows the finger, resists at the ends and settles on the nearest card or in the flick direction.
|
||||
after(el, c, ctx) {
|
||||
ctx.renderChildren(el);
|
||||
const track = el.querySelector(".sw-track");
|
||||
const sw = el.querySelector(".sw"), 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 }); }
|
||||
const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
let page = Math.min(cache.page || 0, n - 1);
|
||||
|
||||
// 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 });
|
||||
let hideTimer;
|
||||
const showDots = () => {
|
||||
sw.classList.add("dots-on");
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(() => { if (!sw.querySelector(":focus-visible")) sw.classList.remove("dots-on"); }, 2500);
|
||||
};
|
||||
const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page)));
|
||||
const go = (p, animate = true) => {
|
||||
page = Math.max(0, Math.min(n - 1, p));
|
||||
cache.page = page;
|
||||
track.style.transition = animate && !reduce() ? "transform .38s cubic-bezier(.2,.8,.2,1)" : "none";
|
||||
track.style.transform = `translateX(${-page * 100}%)`;
|
||||
mark();
|
||||
};
|
||||
cache.go = (p) => { go(p); showDots(); };
|
||||
cache.n = n;
|
||||
go(page, false);
|
||||
|
||||
let drag = null;
|
||||
track.addEventListener("pointerdown", (e) => {
|
||||
if ((e.pointerType === "mouse" && e.button !== 0) || e.target.closest("input, select, textarea") || n < 2) return;
|
||||
drag = { x: e.clientX, y: e.clientY, t: performance.now(), dx: 0, id: e.pointerId, on: false };
|
||||
cache.pausedUntil = Date.now() + 20000;
|
||||
});
|
||||
track.addEventListener("pointermove", (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
||||
if (!drag.on) {
|
||||
if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) { // horizontal: it's a swipe
|
||||
drag.on = true; cache.busy = true;
|
||||
track.setPointerCapture(e.pointerId);
|
||||
track.style.transition = "none";
|
||||
sw.classList.add("dragging");
|
||||
showDots();
|
||||
} else if (Math.abs(dy) > 8) { drag = null; return; } // vertical: let the page scroll
|
||||
else return;
|
||||
}
|
||||
drag.dx = dx;
|
||||
const atEdge = (page === 0 && dx > 0) || (page === n - 1 && dx < 0);
|
||||
track.style.transform = `translateX(calc(${-page * 100}% + ${atEdge ? dx / 3 : dx}px))`;
|
||||
});
|
||||
const end = () => {
|
||||
const d = drag;
|
||||
drag = null;
|
||||
if (!d?.on) return;
|
||||
sw.classList.remove("dragging");
|
||||
cache.busy = false;
|
||||
const speed = d.dx / Math.max(1, performance.now() - d.t); // px per ms
|
||||
let p = page;
|
||||
if (d.dx < -sw.clientWidth / 4 || speed < -0.35) p++;
|
||||
else if (d.dx > sw.clientWidth / 4 || speed > 0.35) p--;
|
||||
go(p);
|
||||
showDots();
|
||||
cache.swallowClick = true; // the release after a swipe must not press a button underneath
|
||||
if (cache.dirty) { cache.dirty = false; setTimeout(() => ctx.rerender(), 400); }
|
||||
};
|
||||
track.addEventListener("pointerup", end);
|
||||
track.addEventListener("pointercancel", end);
|
||||
track.addEventListener("click", (e) => { if (cache.swallowClick) { cache.swallowClick = false; e.stopPropagation(); e.preventDefault(); } }, true);
|
||||
track.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); cache.go(page + 1); }
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); cache.go(page - 1); }
|
||||
});
|
||||
sw.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse") showDots(); });
|
||||
sw.addEventListener("focusin", () => { if (sw.querySelector(":focus-visible")) showDots(); });
|
||||
|
||||
// One autoplay timer per card that survives redraws; it always drives the latest card.
|
||||
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" });
|
||||
if (Date.now() < (cache.pausedUntil || 0) || cache.busy || document.body.classList.contains("editing") || reduce()) return;
|
||||
cache.go(((cache.page || 0) + 1) % cache.n); // cache.go always drives the latest drawing of this card
|
||||
}, every);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user