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:
2026-10-25 23:37:23 +01:00
parent 75ab27db10
commit e73c688017
7 changed files with 211 additions and 50 deletions
+9 -3
View File
@@ -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) {
+11 -1
View File
@@ -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)",
+37 -1
View File
@@ -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
View File
@@ -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()}`;