Dashboard Studio 1.8.0: vacuum card, clock position and background, light groups
- Vacuum card: status, battery, start/pause, dock, locate, suction power and an optional live map from a camera or image entity - Clock: 3x3 text position picker and a background image from links or Immich albums (shared slideshow code with the Photos card), dimmable - Light card lists the lights of a light group with their own switches; picking a group grows the card to fit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -625,6 +625,9 @@
|
||||
${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")}
|
||||
<input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`;
|
||||
}
|
||||
case "pos9": // a 3×3 grid for placing content in the card
|
||||
return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="pos9" role="group" aria-labelledby="${id}">
|
||||
${f.o.map((o) => `<button type="button" ${data} data-v="${o}" aria-pressed="${o === value}" aria-label="${esc(optLabel(o))}" title="${esc(optLabel(o))}"><i></i></button>`).join("")}</div></div>`;
|
||||
case "albums":
|
||||
return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="albums" ${data} data-selected="${esc(JSON.stringify(value || []))}"><div class="muted small">${tr("Loading albums…")}</div></div></div>`;
|
||||
default:
|
||||
@@ -874,6 +877,8 @@
|
||||
if (path === "cfg") {
|
||||
if (idx != null) w.cfg[k][Number(idx)] = v;
|
||||
else w.cfg[k] = v;
|
||||
// A light group lists its lights underneath, which needs room.
|
||||
if (w.type === "light" && k === "entity" && Array.isArray(ha.states[v]?.attributes.entity_id) && w.h && w.h < 4) { w.h = 4; resolveCollisions(w); }
|
||||
} else if (path === "style") { (w.style ||= {})[k] = v; }
|
||||
else if (path === "pos") {
|
||||
const n = Math.max(k === "w" || k === "h" ? 1 : 0, Math.round(Number(v) || 0));
|
||||
@@ -889,7 +894,7 @@
|
||||
} else if (path === "grid") { current.grid[k] = v === "" ? 0 : v; }
|
||||
else if (path === "kiosk") { current.kiosk[k] = v; }
|
||||
else if (path === "dash") { current.name = v; renderDashSelect(); }
|
||||
}, key, { rerender: ["pos", "theme", "grid"].includes(path) });
|
||||
}, key, { rerender: ["pos", "theme", "grid"].includes(path) || (path === "cfg" && k === "entity") });
|
||||
|
||||
if (path === "cfg" || path === "style") renderWidget(selectedWidget());
|
||||
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
|
||||
@@ -903,7 +908,7 @@
|
||||
if (albums) {
|
||||
if (e.type !== "change") return;
|
||||
const ids = [...albums.querySelectorAll("input:checked")].map((i) => i.value);
|
||||
applyField({ dataset: { path: "cfg", k: "albums" }, tagName: "DIV", classList: { add() {}, remove() {} } }, ids, false);
|
||||
applyField({ dataset: { path: "cfg", k: albums.dataset.k }, tagName: "DIV", classList: { add() {}, remove() {} } }, ids, false);
|
||||
return;
|
||||
}
|
||||
if (t.dataset.sync) { // color swatch -> text field
|
||||
@@ -1062,6 +1067,7 @@
|
||||
const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10;
|
||||
ha.callService("climate", "set_temperature", { temperature: t }, eid);
|
||||
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
|
||||
else if (act === "vac-fan") ha.callService("vacuum", "set_fan_speed", { fan_speed: a.dataset.v }, eid);
|
||||
else if (act === "mp-select") {
|
||||
const box = a.closest(".w"), w = findWidget(box.dataset.id);
|
||||
caches[w.id].sel = a.dataset.v;
|
||||
@@ -1417,7 +1423,7 @@
|
||||
insp.addEventListener("input", onInspectorInput);
|
||||
insp.addEventListener("change", onInspectorInput);
|
||||
insp.addEventListener("click", (e) => {
|
||||
const seg = e.target.closest(".seg button[data-path]");
|
||||
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) {
|
||||
|
||||
@@ -258,6 +258,16 @@
|
||||
"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.",
|
||||
"Text position": "Positie van de tekst", "Background image": "Achtergrondafbeelding", "none": "Geen",
|
||||
"Darken the image (%)": "Afbeelding donkerder maken (%)",
|
||||
"top-left": "Linksboven", "top": "Boven", "top-right": "Rechtsboven", "left": "Links", "center": "Midden", "right": "Rechts",
|
||||
"bottom-left": "Linksonder", "bottom": "Onder", "bottom-right": "Rechtsonder",
|
||||
"For a light group, show each light": "Toon bij een lampengroep elke lamp", "Lights in {0}": "Lampen in {0}",
|
||||
"Vacuum": "Stofzuiger", "a vacuum": "een stofzuiger", "Map camera (optional)": "Kaartcamera (optioneel)",
|
||||
"Refresh the map every (seconds)": "Vernieuw de kaart elke (seconden)", "Battery sensor (if not on the vacuum)": "Accusensor (als de stofzuiger die niet zelf heeft)",
|
||||
"Show suction power": "Toon zuigkracht", "Battery {0}%": "Accu {0}%", "Cleaning map": "Schoonmaakkaart", "Suction power": "Zuigkracht",
|
||||
"Start cleaning": "Begin met schoonmaken", "Send back to the dock": "Terug naar het dock", "Locate (makes a sound)": "Zoeken (maakt geluid)",
|
||||
"Cleaning…": "Aan het schoonmaken…", "On its way to the dock": "Onderweg naar het dock",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
@@ -268,7 +278,7 @@
|
||||
locking: "Vergrendelen", unlocking: "Ontgrendelen", jammed: "Vastgelopen", playing: "Speelt", paused: "Gepauzeerd",
|
||||
idle: "Inactief", standby: "Stand-by", buffering: "Bufferen", heat: "Verwarmen", cool: "Koelen", auto: "Automatisch",
|
||||
heat_cool: "Verwarmen/koelen", dry: "Drogen", fan_only: "Alleen ventilator", cleaning: "Reinigen", docked: "Gedockt",
|
||||
returning: "Keert terug", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
|
||||
returning: "Keert terug", error: "Fout", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
|
||||
"clear-night": "Helder", cloudy: "Bewolkt", exceptional: "Uitzonderlijk", fog: "Mist", hail: "Hagel", lightning: "Onweer",
|
||||
"lightning-rainy": "Onweer en regen", partlycloudy: "Halfbewolkt", pouring: "Stortregen", rainy: "Regen", snowy: "Sneeuw",
|
||||
"snowy-rainy": "Natte sneeuw", sunny: "Zonnig", windy: "Winderig", "windy-variant": "Winderig", armed_away: "Ingeschakeld (afwezig)",
|
||||
|
||||
@@ -268,6 +268,7 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
|
||||
color: var(--lit-text);
|
||||
--d-text-2: var(--lit-text-2);
|
||||
--d-fill: rgba(61, 44, 10, 0.12);
|
||||
--d-separator: rgba(61, 44, 10, 0.14);
|
||||
}
|
||||
.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); }
|
||||
@@ -367,7 +368,7 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
|
||||
.clock-wx-text { font-size: 14px; color: var(--d-text-2); }
|
||||
.clock-hl { font-size: 14px; color: var(--d-text-2); }
|
||||
.clock-hl:empty { display: none; }
|
||||
@container (max-height: 150px) { .clock-wx-text, .clock-hl { display: none; } .clock-wx { margin-top: 6px; } }
|
||||
@container (max-height: 175px) { .clock-wx-text, .clock-hl { display: none; } .clock-wx { margin-top: 6px; } }
|
||||
|
||||
.cam-btn { position: absolute; inset: 0; }
|
||||
.cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
|
||||
@@ -549,6 +550,41 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.child-open .i { font-size: 18px; flex: none; }
|
||||
.child-item .btn.icon { width: 30px; min-width: 30px; flex: none; }
|
||||
|
||||
/* clock: text position and background image */
|
||||
.clock { position: relative; }
|
||||
.clock-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: inherit; text-align: inherit; max-width: 100%; }
|
||||
.clock-content .clock-time { align-items: inherit; }
|
||||
.clock.pos-top-left, .clock.pos-top, .clock.pos-top-right { justify-content: flex-start; }
|
||||
.clock.pos-bottom-left, .clock.pos-bottom, .clock.pos-bottom-right { justify-content: flex-end; }
|
||||
.clock.pos-top-left, .clock.pos-left, .clock.pos-bottom-left { align-items: flex-start; text-align: left; }
|
||||
.clock.pos-top-right, .clock.pos-right, .clock.pos-bottom-right { align-items: flex-end; text-align: right; }
|
||||
.clock-bg { position: absolute; inset: 0; margin: -16px; z-index: 0; border-radius: inherit; }
|
||||
.w.transparent .clock-bg, .surface-plain .clock-bg { margin: -6px -4px; }
|
||||
.clock-dim { position: absolute; inset: 0; margin: -16px; z-index: 0; background: linear-gradient(to bottom, rgba(0, 0, 0, calc(var(--dim) * 0.8)), rgba(0, 0, 0, var(--dim))); }
|
||||
.clock.on-image { color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); text-shadow: 0 1px 12px rgba(0, 0, 0, 0.45); }
|
||||
.clock.on-image .clock-wx { background: rgba(0, 0, 0, 0.32); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||
.pos9 { display: grid; grid-template-columns: repeat(3, 34px); gap: 4px; padding: 4px; background: var(--fill); border-radius: 10px; width: max-content; }
|
||||
.pos9 button { width: 34px; height: 28px; border: 0; border-radius: 7px; background: transparent; cursor: pointer; display: grid; place-items: center; }
|
||||
.pos9 button i { width: 8px; height: 8px; border-radius: 50%; background: var(--text-2); opacity: .4; }
|
||||
.pos9 button[aria-pressed="true"] { background: var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); }
|
||||
.pos9 button[aria-pressed="true"] i { width: 14px; height: 10px; border-radius: 3px; background: var(--accent); opacity: 1; }
|
||||
|
||||
/* light groups */
|
||||
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
|
||||
.light:has(.light-members) { justify-content: flex-start; }
|
||||
|
||||
/* vacuum */
|
||||
.vac { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||
.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); }
|
||||
.vac-batt .i { font-size: 18px; }
|
||||
.vac-map { flex: 1; min-height: 0; width: 100%; object-fit: contain; border-radius: 12px; background: var(--d-fill); }
|
||||
.vac-spacer { flex: 1; }
|
||||
.vac .ctrls { display: flex; gap: 8px; align-items: center; }
|
||||
.vac .round:disabled { opacity: .35; cursor: default; }
|
||||
.vac-hint { margin-left: 4px; }
|
||||
.vac.is-error .state { color: var(--crit); font-weight: 600; }
|
||||
@container (max-height: 220px) { .vac-map { display: none; } .vac-spacer { display: 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; }
|
||||
|
||||
+107
-42
@@ -207,10 +207,14 @@
|
||||
fields: [
|
||||
{ k: "entity", t: "entity", l: "Light", domains: ["light"] },
|
||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||
{ k: "members", t: "check", l: "For a light group, show each light", d: true },
|
||||
],
|
||||
// A light group lists its lights in the entity_id attribute; follow them too.
|
||||
entities: (c, ha) => [c.entity, ...(ha?.states[c.entity]?.attributes.entity_id || [])].filter(Boolean),
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a light");
|
||||
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id : [];
|
||||
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
|
||||
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
|
||||
const name = friendly(s, c.name);
|
||||
@@ -218,7 +222,12 @@
|
||||
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
|
||||
<span class="badge">${icon(s)}</span>
|
||||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
|
||||
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}</div>`;
|
||||
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}
|
||||
${members.length ? `<div class="list light-members" role="group" aria-label="${t("Lights in {0}", esc(name))}">${members.map((eid) => {
|
||||
const m = ha.states[eid];
|
||||
if (!m) return "";
|
||||
return `<button class="list-row wbtn" role="switch" aria-checked="${isOn(m)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(m)}</span><span class="grow">${esc(friendly(m))}</span><span class="st">${esc(fmtState(m))}</span><span class="switch"></span></button>`;
|
||||
}).join("")}</div>` : ""}</div>`;
|
||||
},
|
||||
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
||||
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
||||
@@ -324,24 +333,38 @@
|
||||
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
|
||||
}
|
||||
|
||||
const POSITIONS = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"];
|
||||
|
||||
W.clock = {
|
||||
label: "Clock", icon: "clock", size: [3, 2],
|
||||
fields: [
|
||||
{ k: "h24", t: "check", l: "24-hour time", d: true },
|
||||
{ k: "seconds", t: "check", l: "Show seconds", d: false },
|
||||
{ k: "date", t: "check", l: "Show date", d: true },
|
||||
{ k: "pos", t: "pos9", l: "Text position", o: POSITIONS, d: "center" },
|
||||
{ k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] },
|
||||
{ k: "forecast", t: "check", l: "Show today’s high and low", d: true },
|
||||
{ k: "bgSource", t: "seg", l: "Background image", o: ["none", "links", "immich"], d: "none" },
|
||||
{ k: "bgUrls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.bgSource === "links" },
|
||||
{ k: "bgAlbums", t: "albums", l: "Immich albums", when: (c) => c.bgSource === "immich" },
|
||||
{ k: "bgInterval", t: "number", l: "Change photo every (seconds)", d: 60, when: (c) => c.bgSource && c.bgSource !== "none" },
|
||||
{ k: "dim", t: "number", l: "Darken the image (%)", d: 30, when: (c) => c.bgSource && c.bgSource !== "none" },
|
||||
],
|
||||
entities: (c) => (c.weather ? [c.weather] : []),
|
||||
render(c, { ha }) {
|
||||
const s = c.weather && ha.states[c.weather];
|
||||
const bg = c.bgSource === "links" ? urlList({ urls: c.bgUrls }).length > 0 : c.bgSource === "immich" && c.bgAlbums?.length > 0;
|
||||
const pos = POSITIONS.includes(c.pos) ? c.pos : "center";
|
||||
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"><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>`;
|
||||
return `<div class="clock pos-${pos} ${bg ? "on-image" : ""}" style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
|
||||
${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>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
const box = el.querySelector(".clock-bg");
|
||||
if (box) slideshow(el, box, ctx, (ctx.cache.bg ||= {}), { source: c.bgSource, urls: c.bgUrls, albums: c.bgAlbums, shuffle: true, interval: c.bgInterval || 60 });
|
||||
if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return;
|
||||
const cache = ctx.cache;
|
||||
if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) {
|
||||
@@ -453,6 +476,43 @@
|
||||
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- vacuum
|
||||
W.vacuum = {
|
||||
label: "Vacuum", icon: "vacuum", size: [4, 3],
|
||||
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: "fan", t: "check", l: "Show suction power", d: true },
|
||||
],
|
||||
entities: (c) => [c.entity, c.battery].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 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 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>` : ""}
|
||||
</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
|
||||
// Works with any integration: a status entity, and/or a power sensor above a threshold,
|
||||
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
|
||||
@@ -637,49 +697,54 @@
|
||||
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||||
<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>`;
|
||||
},
|
||||
async after(el, c, ctx) {
|
||||
const cache = ctx.cache;
|
||||
clearTimeout(cache.timer);
|
||||
const box = el.querySelector(".photos");
|
||||
if (!box) return;
|
||||
const msg = box.querySelector(".ph-msg");
|
||||
const key = JSON.stringify([c.source, c.urls ?? c.url, c.albums, c.shuffle]);
|
||||
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
|
||||
cache.key = key; cache.at = Date.now(); cache.pos = 0;
|
||||
cache.loading = (async () => {
|
||||
if (c.source !== "immich") return urlList(c).map((src) => ({ src }));
|
||||
const lists = await Promise.all((c.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
|
||||
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
|
||||
})().then((items) => {
|
||||
if (c.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
|
||||
cache.items = items; cache.error = null;
|
||||
}).catch((e) => { cache.items = []; cache.error = e.message; });
|
||||
}
|
||||
await cache.loading;
|
||||
if (!el.isConnected) return;
|
||||
if (cache.error) { msg.innerHTML = `<div class="empty">${t("Couldn’t load photos. {0}", esc(cache.error))}</div>`; return; }
|
||||
if (!cache.items.length) { msg.innerHTML = `<div class="empty">${t("These albums have no photos yet.")}</div>`; return; }
|
||||
|
||||
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
|
||||
let front = 0;
|
||||
const show = () => {
|
||||
if (!el.isConnected) return;
|
||||
const item = cache.items[cache.pos % cache.items.length];
|
||||
cache.pos++;
|
||||
const next = layers[1 - front];
|
||||
next.onload = () => {
|
||||
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
|
||||
msg.innerHTML = "";
|
||||
dateEl.textContent = c.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
|
||||
};
|
||||
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
|
||||
next.src = item.src;
|
||||
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(c.interval) || 20) * 1000);
|
||||
};
|
||||
show();
|
||||
after(el, c, ctx) {
|
||||
return slideshow(el, el.querySelector(".photos"), ctx, ctx.cache, { source: c.source, urls: c.urls ?? c.url, albums: c.albums, shuffle: c.shuffle, interval: c.interval, showDate: c.showDate });
|
||||
},
|
||||
};
|
||||
|
||||
/** Load photos from links or Immich albums and cross-fade through them in `box` (.photos with two .ph-layer images).
|
||||
* Used by the Photos card and as a clock background; `cache` keeps the list and position across redraws. */
|
||||
async function slideshow(el, box, ctx, cache, o) {
|
||||
clearTimeout(cache.timer);
|
||||
if (!box) return;
|
||||
const msg = box.querySelector(".ph-msg");
|
||||
const key = JSON.stringify([o.source, o.urls, o.albums, o.shuffle]);
|
||||
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
|
||||
cache.key = key; cache.at = Date.now(); cache.pos = 0;
|
||||
cache.loading = (async () => {
|
||||
if (o.source !== "immich") return urlList({ urls: o.urls }).map((src) => ({ src }));
|
||||
const lists = await Promise.all((o.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
|
||||
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
|
||||
})().then((items) => {
|
||||
if (o.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
|
||||
cache.items = items; cache.error = null;
|
||||
}).catch((e) => { cache.items = []; cache.error = e.message; });
|
||||
}
|
||||
await cache.loading;
|
||||
if (!el.isConnected) return;
|
||||
const say = (text) => { if (msg) msg.innerHTML = `<div class="empty">${text}</div>`; };
|
||||
if (cache.error) { say(t("Couldn’t load photos. {0}", esc(cache.error))); return; }
|
||||
if (!cache.items.length) { say(t("These albums have no photos yet.")); return; }
|
||||
|
||||
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
|
||||
let front = 0;
|
||||
const show = () => {
|
||||
if (!el.isConnected) return;
|
||||
const item = cache.items[cache.pos % cache.items.length];
|
||||
cache.pos++;
|
||||
const next = layers[1 - front];
|
||||
next.onload = () => {
|
||||
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
|
||||
if (msg) msg.innerHTML = "";
|
||||
if (dateEl) dateEl.textContent = o.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
|
||||
};
|
||||
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
|
||||
next.src = item.src;
|
||||
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(o.interval) || 20) * 1000);
|
||||
};
|
||||
show();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- calendar
|
||||
const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"];
|
||||
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
|
||||
Reference in New Issue
Block a user