Dashboard Studio 2.2.0: guest invites, personal looks, more themes, Haven widgets and tiles
- Vacuum card: the robot drawn beside its dock by state, last clean, maintenance - Formula One card: the official F1 look with a user-supplied logo - Nine more themes; per-person theme, text size and language on Admin, People - Guest invites: an expiring link and QR code for one dashboard, served on a LAN-only guest port, with the Home Assistant connection filtered both ways - Haven 2.2: home screen widgets, three quick settings tiles, several homes, and self-updates from the Haven that Dashboard Studio serves Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+113
-13
@@ -187,17 +187,26 @@
|
||||
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 },
|
||||
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 }, // taste-skill-ignore: the Dusk theme's own background, chosen by the user
|
||||
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 },
|
||||
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 }, // taste-skill-ignore: the Ocean theme's own background, chosen by the user
|
||||
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 }, // taste-skill-ignore: the Sunrise theme's own background, chosen by the user
|
||||
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 },
|
||||
Midnight: { bg: "#000000", tile: "#0f0f10", text: "#f2f2f2", text2: "#a3a3a8", accent: "#4da3ff", onAccent: "#001b33", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
|
||||
Espresso: { bg: "#1a1411", tile: "#261d18", text: "#f3ebe4", text2: "#c0ada0", accent: "#e0a35a", onAccent: "#24170b", separator: "rgba(243,235,228,0.12)", fill: "rgba(243,235,228,0.1)", lit: "#ffd9a0", shadow: false, blur: 0 },
|
||||
Ember: { bg: "#151515", tile: "#212121", text: "#f5f5f5", text2: "#aaaaaa", accent: "#ff7a45", onAccent: "#2a0e02", separator: "rgba(255,255,255,0.1)", fill: "rgba(255,255,255,0.1)", lit: "#ffd9a8", shadow: false, blur: 0 },
|
||||
Lagoon: { bg: "#0c1a1c", tile: "#142729", text: "#e8f5f4", text2: "#9fbcb9", accent: "#3cc9b8", onAccent: "#04201d", separator: "rgba(232,245,244,0.12)", fill: "rgba(232,245,244,0.1)", lit: "#ffe2a8", shadow: false, blur: 0 },
|
||||
Paper: { bg: "#f4f1ea", tile: "#fbfaf6", text: "#22201c", text2: "#5f5a52", accent: "#1f5f8b", separator: "rgba(34,32,28,0.12)", fill: "rgba(34,32,28,0.07)", lit: "#ffe6b0", shadow: false, blur: 0 },
|
||||
Arctic: { bg: "#eef3f8", tile: "#ffffff", text: "#10202e", text2: "#4a5d6e", accent: "#0b6bcb", separator: "rgba(16,32,46,0.1)", fill: "rgba(16,32,46,0.06)", lit: "#ffecc4", shadow: true, blur: 0 },
|
||||
Moss: { bg: "#e9ede4", tile: "#f7f9f4", text: "#1d2619", text2: "#4f5c48", accent: "#3f6f2a", separator: "rgba(29,38,25,0.12)", fill: "rgba(29,38,25,0.07)", lit: "#f6e7b4", shadow: false, blur: 0 },
|
||||
Sakura: { bg: "#fbf0f2", tile: "#ffffff", text: "#2a1a1f", text2: "#6e5560", accent: "#c2185b", separator: "rgba(42,26,31,0.1)", fill: "rgba(42,26,31,0.06)", lit: "#ffe5c4", shadow: true, blur: 0 },
|
||||
Concrete: { bg: "#e4e4e2", tile: "#f2f2f0", text: "#1c1c1b", text2: "#555553", accent: "#2b2b2a", onAccent: "#ffffff", separator: "rgba(28,28,27,0.12)", fill: "rgba(28,28,27,0.07)", lit: "#ffe9b8", shadow: false, 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 },
|
||||
};
|
||||
|
||||
@@ -676,6 +685,8 @@
|
||||
let previewAs = null; // Admin → People → Preview: { id, name, ids }
|
||||
let lockedKiosk = false; // people who aren't admins can't leave kiosk mode
|
||||
/** What a person may see: { all: true } or { dashboards: [...] }, plus the one that opens first (home). */
|
||||
/** A person's own look, chosen by an admin: a theme, a text size, a language. */
|
||||
const lookOf = () => (previewAs ? store.looks?.[previewAs.id] : isAdmin() ? store.looks?.[me?.id] : store.look) || {};
|
||||
const accessFor = (uid) => store.access?.users?.[uid] || store.access?.everyone || { all: true };
|
||||
const visibleFor = (uid) => { const a = accessFor(uid); return store.dashboards.filter((d) => a.all || (a.dashboards || []).includes(d.id)).map((d) => d.id); };
|
||||
let current = null;
|
||||
@@ -821,7 +832,10 @@
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const t = themeVariant(nightDark ? { ...current.theme, ...PRESETS.Dark, preset: "Dark" } : current.theme), s = stageEl.style;
|
||||
const own = lookOf().theme, base = own && PRESETS[own] ? { ...current.theme, ...PRESETS[own], preset: own, bgImage: "" } : current.theme;
|
||||
const t = themeVariant(nightDark ? { ...base, ...PRESETS.Dark, preset: "Dark" } : base), s = stageEl.style;
|
||||
document.body.classList.toggle("text-large", lookOf().scale === "large");
|
||||
document.body.classList.toggle("text-xl", lookOf().scale === "xl");
|
||||
stageEl.classList.remove("surface-cards", "surface-outline", "surface-plain");
|
||||
stageEl.classList.add(`surface-${t.surface || "cards"}`);
|
||||
for (const k of COLOR_KEYS) {
|
||||
@@ -1318,7 +1332,7 @@
|
||||
</div>
|
||||
<div class="section"><div class="section-title">${tr("Appearance")}</div>
|
||||
<div class="presets" role="group" aria-label="${tr("Theme")}">${Object.entries(PRESETS).map(([n, p]) => {
|
||||
const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg;
|
||||
const bg = p.swatch ? `linear-gradient(135deg, ${p.swatch[0]} 50%, ${p.swatch[1]} 50%)` : p.bg; // taste-skill-ignore: the System swatch: half light, half dark, with a hard edge
|
||||
const fg = p.swatch ? "#6e6e73" : p.text;
|
||||
return `<button class="preset" data-preset="${n}" aria-pressed="${t.preset === n}" style="background:${bg};color:${fg}"><i style="background:${p.accent || "#0a84ff"}"></i><span>${tr(n)}</span></button>`;
|
||||
}).join("")}</div>
|
||||
@@ -2519,8 +2533,8 @@
|
||||
// People who aren't admins stay in kiosk mode: the corner switches between the dashboards they may see.
|
||||
const others = lockedKiosk ? store.dashboards.filter((d) => d.id !== current.id) : [];
|
||||
const choice = await openModal(lockedKiosk
|
||||
? `<h3>${tr("Dashboards")}</h3>${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
|
||||
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
|
||||
? `<h3>${tr("Dashboards")}</h3>${me?.guest && me.until ? `<p class="muted small">${esc(tr("This invite ends {0}.", new Date(me.until * 1000).toLocaleString(LOCALE, { weekday: "long", hour: "2-digit", minute: "2-digit" })))}</p>` : ""}${others.length ? `<div class="kp-dashes">${others.map((d) => `<button class="btn" data-x="dash:${d.id}">${Icons.svg("layout")}${esc(d.name)}</button>`).join("")}</div>` : `<p>${tr("This is the only dashboard you can see. An admin can add more for you.")}</p>`}
|
||||
<div class="modal-actions">${IN_APP ? `<button class="btn" data-x="haven">${tr("Change address")}</button>` : ""}<button class="btn guest-hide" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn primary" data-x="stay" autofocus>${tr("Close")}</button></div>`
|
||||
: `<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
|
||||
<div class="modal-actions"><button class="btn" data-x="guest">${guestOn ? tr("End guest mode") : tr("Guest mode")}</button><button class="btn" data-x="full">${document.fullscreenElement ? tr("Exit full screen") : tr("Full screen")}</button><button class="btn" data-x="stay" autofocus>${tr("Stay")}</button><button class="btn primary" data-x="exit">${tr("Leave kiosk")}</button></div>`,
|
||||
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); });
|
||||
@@ -2977,6 +2991,14 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
/** A language an admin chose for this person; this device's own choice still comes first. */
|
||||
function syncPersonLang() {
|
||||
const want = lookOf().lang || "";
|
||||
if ((lsGet("ds.personLang") || "") === want) return;
|
||||
lsSet("ds.personLang", want);
|
||||
if (window.I18n.preference() === "auto" && (want || window.I18n.lang) !== window.I18n.lang) location.reload();
|
||||
}
|
||||
|
||||
/** People who aren't admins, with no dashboard chosen for them yet. */
|
||||
function showEmpty() {
|
||||
const el = $("#empty-note");
|
||||
@@ -3283,10 +3305,10 @@
|
||||
// Dashboards, the people who use them and what each may see, devices, Haven, and who changed what.
|
||||
const ADMIN_SECTIONS = [
|
||||
["dashboards", "layout", "Dashboards"], ["people", "people", "People"], ["devices", "tv", "Devices"],
|
||||
["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
|
||||
["guests", "qr", "Guests"], ["haven", "phone", "Haven app"], ["activity", "history", "Activity"], ["about", "sparkles", "About"],
|
||||
];
|
||||
let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards";
|
||||
let peopleList = null, devicesSeen = null;
|
||||
let peopleList = null, devicesSeen = null, lastInvite = null;
|
||||
const ago = (at) => (at ? relTime(new Date(at * 1000).toISOString()) : tr("never"));
|
||||
const initials = (n) => esc((n || "?").trim().slice(0, 1).toUpperCase());
|
||||
async function loadPeople(force) {
|
||||
@@ -3339,6 +3361,14 @@
|
||||
${(own || key === "everyone") && !entry.all ? `<div class="adm-chips">${dashChecks(key, entry)}</div>` : ""}
|
||||
${own || key === "everyone" ? `<label class="field"><span>${tr("Opens first")}</span>${homeSel(key, entry)}</label>` : ""}
|
||||
</div>`;
|
||||
const lookBlock = (uid) => {
|
||||
const l = store.looks?.[uid] || {};
|
||||
const sel = (k, opts, label) => `<label class="field"><span>${tr(label)}</span><select class="input" data-k="look-${k}" data-who="${esc(uid)}">${opts.map(([v, n]) => `<option value="${v}" ${(l[k] || "") === v ? "selected" : ""}>${esc(tr(n))}</option>`).join("")}</select></label>`;
|
||||
return `<div class="p-look">
|
||||
${sel("theme", [["", "The dashboard’s theme"], ...Object.keys(PRESETS).filter((n) => n !== "System").map((n) => [n, n])], "Theme")}
|
||||
${sel("scale", [["", "Normal"], ["large", "Large"], ["xl", "Extra large"]], "Text")}
|
||||
${sel("lang", [["", "Automatic"], ["en", "English"], ["nl", "Nederlands"]], "Language")}</div>`;
|
||||
};
|
||||
const rows = list.map((p) => {
|
||||
const own = acc.users[p.id];
|
||||
const entry = own || acc.everyone || { all: true };
|
||||
@@ -3348,6 +3378,7 @@
|
||||
<div class="muted small">${p.admin ? tr("Home Assistant administrator · sees and builds everything") : tr("Sees {0} · always in kiosk mode", visibleFor(p.id).length === store.dashboards.length ? tr("all dashboards") : tr("{0} of {1} dashboards", visibleFor(p.id).length, store.dashboards.length))} · ${esc(tr("last opened {0}", ago(p.seen)))}</div></div>
|
||||
${p.admin ? "" : `<button class="btn" data-a="preview">${Icons.svg("eye")}<span class="label-wide">${tr("Preview")}</span></button>`}</div>
|
||||
${p.admin ? "" : accessBlock(p.id, entry, !!own)}
|
||||
${lookBlock(p.id)}
|
||||
</article>`;
|
||||
}).join("");
|
||||
return `<header class="adm-head"><div><h2>${tr("People")}</h2>
|
||||
@@ -3388,6 +3419,36 @@
|
||||
<button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`;
|
||||
},
|
||||
|
||||
async guests() {
|
||||
let data;
|
||||
try { data = await api("api/invites"); } catch (e) { return `<p class="muted">${esc(e.message)}</p>`; }
|
||||
const host = lsGet("ds.guestHost") || location.hostname;
|
||||
const link = (tok) => (data.port ? `http://${host}:${data.port}/g/${tok}/` : "");
|
||||
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
const fresh = data.invites.find((i) => i.token === lastInvite && !i.ended);
|
||||
const rows = data.invites.map((i) => `<div class="act-row inv-row ${i.ended ? "ended" : ""}" data-token="${esc(i.token)}"><span class="adm-avatar">${Icons.svg("qr")}</span>
|
||||
<div class="grow"><div><b>${esc(i.name || tr("Guests"))}</b> · ${esc(i.dashboardName)}</div>
|
||||
<div class="muted small">${i.ended ? esc(tr("Ended {0}", when(i.until))) : esc(tr("Until {0}", when(i.until)))}${i.by ? ` · ${esc(tr("by {0}", i.by))}` : ""}</div></div>
|
||||
${i.ended ? "" : `<button class="btn" data-a="inv-show">${tr("QR code")}</button><button class="btn danger" data-a="inv-end">${tr("End")}</button>`}</div>`).join("");
|
||||
return `<header class="adm-head"><div><h2>${tr("Guests")}</h2>
|
||||
<p class="muted">${tr("An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.")}</p></div></header>
|
||||
${data.port ? "" : `<section class="adm-panel notice"><h3>${Icons.svg("wifi")}${tr("Turn on guest links first")}</h3>
|
||||
<p class="muted small">${tr("In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.")}</p></section>`}
|
||||
<section class="adm-panel"><h3>${Icons.svg("plus")}${tr("New invite")}</h3>
|
||||
<div class="inv-form">
|
||||
<label class="field"><span>${tr("For")}</span><input class="input" id="inv-name" placeholder="${tr("e.g. Babysitter, Grandma and Grandpa")}"></label>
|
||||
<label class="field"><span>${tr("Dashboard")}</span><select class="input" id="inv-dash">${store.dashboards.map((d) => `<option value="${d.id}">${esc(d.name)}</option>`).join("")}</select></label>
|
||||
<label class="field"><span>${tr("Works until")}</span><select class="input" id="inv-for">
|
||||
<option value="4">${tr("4 hours from now")}</option><option value="tonight">${tr("Tonight, 23:59")}</option><option value="24" selected>${tr("24 hours from now")}</option>
|
||||
<option value="weekend">${tr("After the weekend (Monday 10:00)")}</option><option value="168">${tr("A week from now")}</option></select></label>
|
||||
<button class="btn primary" data-a="inv-new" ${data.port ? "" : "disabled"}>${tr("Make invite")}</button></div>
|
||||
<label class="field inv-host"><span>${tr("Address for the link")}</span><input class="input" id="inv-host" value="${esc(host)}" aria-describedby="inv-host-hint"></label>
|
||||
<p class="muted small" id="inv-host-hint">${tr("The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.")}</p>
|
||||
${fresh ? `<div class="inv-fresh"><div class="share-qr">${window.QR.svg(link(fresh.token), { label: tr("QR code for the invite") })}</div>
|
||||
<div class="share-row"><input class="input" readonly value="${esc(link(fresh.token))}" id="inv-link"><button class="btn" data-a="inv-copy">${Icons.svg("copy")}${tr("Copy")}</button></div></div>` : ""}</section>
|
||||
<div class="adm-list" style="margin-top:18px">${rows || `<p class="muted">${tr("No invites yet.")}</p>`}</div>`;
|
||||
},
|
||||
|
||||
async haven() {
|
||||
const addr = location.origin;
|
||||
return `<header class="adm-head haven-head"><img src="static/haven-icon.svg" alt=""><div><h2>${tr("Haven app")}</h2>
|
||||
@@ -3521,10 +3582,36 @@
|
||||
if (act === "copy-addr") { navigator.clipboard?.writeText($("#haven-addr").value).then(() => toast(tr("Copied."))).catch(() => $("#haven-addr").select()); return; }
|
||||
if (act === "history") { setCurrent(b.closest("[data-dash]").dataset.dash); await historyDialog(); return; }
|
||||
if (act === "whatsnew") whatsNew();
|
||||
if (act === "inv-new") {
|
||||
const v = $("#inv-for").value, now = new Date();
|
||||
let until;
|
||||
if (v === "tonight") { until = new Date(now); until.setHours(23, 59, 0, 0); }
|
||||
else if (v === "weekend") { until = new Date(now); until.setDate(now.getDate() + ((8 - now.getDay()) % 7 || 7)); until.setHours(10, 0, 0, 0); }
|
||||
else until = new Date(now.getTime() + Number(v) * 3600e3);
|
||||
try {
|
||||
const r = await fetch(new URL("api/invites", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: $("#inv-name").value.trim(), dashboard: $("#inv-dash").value, until: until.getTime() / 1000 }) });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
|
||||
lastInvite = (await r.json()).token;
|
||||
} catch (err) { toast(tr("Couldn’t make the invite: {0}", err.message), true); return; }
|
||||
renderAdmin("guests");
|
||||
return;
|
||||
}
|
||||
if (act === "inv-show") { lastInvite = b.closest("[data-token]").dataset.token; renderAdmin("guests"); $("#admin .adm-body").scrollTop = 0; return; }
|
||||
if (act === "inv-end") {
|
||||
const tok = b.closest("[data-token]").dataset.token;
|
||||
if (!(await confirmDestructive(tr("End this invite?"), tr("The link stops working at once, also on phones that have it open."), tr("End")))) return;
|
||||
await fetch(new URL(`api/invites/${encodeURIComponent(tok)}`, BASE), { method: "DELETE" }).catch(() => {});
|
||||
if (lastInvite === tok) lastInvite = null;
|
||||
renderAdmin("guests");
|
||||
return;
|
||||
}
|
||||
if (act === "inv-copy") { navigator.clipboard?.writeText($("#inv-link").value).then(() => toast(tr("Copied."))).catch(() => $("#inv-link").select()); return; }
|
||||
});
|
||||
|
||||
$("#admin").addEventListener("change", (e) => {
|
||||
const f = e.target, k = f.dataset.k;
|
||||
if (f.id === "inv-host") { lsSet("ds.guestHost", f.value.trim()); renderAdmin("guests"); return; }
|
||||
if (f.closest("[data-dev]")) {
|
||||
const id = f.closest("[data-dev]").dataset.dev, d = (store.assign.devices[id] ||= {});
|
||||
d[k] = f.type === "checkbox" ? f.checked : f.value;
|
||||
@@ -3532,6 +3619,16 @@
|
||||
scheduleSave();
|
||||
return;
|
||||
}
|
||||
if (k?.startsWith("look-")) {
|
||||
const uid = f.dataset.who, key = k.slice(5);
|
||||
store.looks ||= {};
|
||||
const l = { ...(store.looks[uid] || {}) };
|
||||
if (f.value) l[key] = f.value; else delete l[key];
|
||||
if (Object.keys(l).length) store.looks[uid] = l; else delete store.looks[uid];
|
||||
scheduleSave();
|
||||
if (uid === me?.id) { applyTheme(); syncPersonLang(); }
|
||||
return;
|
||||
}
|
||||
if (k === "dash") {
|
||||
const boxes = [...f.closest(".adm-chips").querySelectorAll("input:checked")].map((x) => x.value);
|
||||
setAccess(f.dataset.who, (cur) => ({ ...cur, all: false, dashboards: boxes, home: boxes.includes(cur.home) ? cur.home : undefined }));
|
||||
@@ -3588,7 +3685,7 @@
|
||||
|
||||
// Screens pick up what an admin changes (a dashboard, or who sees what) within a minute, without a reload.
|
||||
let storeKey = "";
|
||||
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices]);
|
||||
const keyOf = (s) => JSON.stringify([s.dashboards, s.access, s.assign?.devices, s.look]);
|
||||
async function refreshStore() {
|
||||
if (editMode || mode === "admin" || savePending || previewAs || modalClose) return;
|
||||
let next;
|
||||
@@ -3603,6 +3700,7 @@
|
||||
const home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
|
||||
const keep = [current?.id, store.assign.devices[DEVICE_ID]?.dashboard, home].find((id) => id && store.dashboards.some((d) => d.id === id));
|
||||
setCurrent(keep);
|
||||
syncPersonLang();
|
||||
}
|
||||
setInterval(refreshStore, 60 * 1000);
|
||||
ha.onChange = onStateChange;
|
||||
@@ -3623,9 +3721,10 @@
|
||||
// In Haven, a session that couldn't be renewed means signing in again on Home Assistant's page.
|
||||
if (IN_APP && (await fetch(new URL("api/version", BASE)).then((r) => r.status === 401).catch(() => false))) { window.HavenApp.reconnect?.(); return; }
|
||||
loadCachedStates();
|
||||
if ("serviceWorker" in navigator && APP_VERSION !== "dev") navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
|
||||
if ("serviceWorker" in navigator && APP_VERSION !== "dev" && !location.pathname.startsWith("/g/")) navigator.serviceWorker.register(new URL("sw.js", BASE), { scope: BASE.pathname }).catch(() => {});
|
||||
me = await api("api/whoami").catch(() => null);
|
||||
if (me) { role = me.admin ? "admin" : "basic"; lsSet("ds.role", role); }
|
||||
if (me) { role = me.admin ? "admin" : "basic"; if (!me.guest) lsSet("ds.role", role); }
|
||||
document.body.classList.toggle("guest", !!me?.guest);
|
||||
try {
|
||||
const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE));
|
||||
store = await r.json();
|
||||
@@ -3634,7 +3733,7 @@
|
||||
store.assign = { devices: {}, users: {}, ...(store.assign || {}) };
|
||||
} catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); }
|
||||
// Let admins see this device on the Admin screen, to give it a dashboard.
|
||||
fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
if (!me?.guest) fetch(new URL("api/devices/seen", BASE), { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device: DEVICE_ID, app: IN_APP ? "haven" : "browser", kind: phoneScreen.matches ? "phone" : matchMedia("(pointer: coarse)").matches ? "tablet" : "computer" }) }).catch(() => {});
|
||||
|
||||
const firstReady = new Promise((res) => {
|
||||
@@ -3645,6 +3744,7 @@
|
||||
ha.connect();
|
||||
|
||||
if (isAdmin()) migrateAccess();
|
||||
syncPersonLang();
|
||||
storeKey = keyOf(store);
|
||||
if (!store.dashboards?.length) {
|
||||
if (!isAdmin()) { showEmpty(); return; }
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"version": "2.2.0", "code": 2}
|
||||
@@ -723,6 +723,56 @@
|
||||
"Energy now": "Energie nu",
|
||||
"No power is flowing right now.": "Er stroomt nu geen vermogen.",
|
||||
"Battery {0}%, charging": "Batterij {0}%, laadt",
|
||||
"This invite ends {0}.": "Deze uitnodiging eindigt {0}.",
|
||||
"Guests": "Gasten",
|
||||
"Ended {0}": "Afgelopen {0}",
|
||||
"Until {0}": "Tot {0}",
|
||||
"QR code": "QR-code",
|
||||
"End": "Beëindigen",
|
||||
"An invite opens one dashboard on a guest’s phone, without a Home Assistant login, until the time you choose. Guests can only see and control what is on that dashboard, and only on your home Wi-Fi.": "Een uitnodiging opent één dashboard op de telefoon van een gast, zonder Home Assistant-account, tot het moment dat je kiest. Gasten zien en bedienen alleen wat op dat dashboard staat, en alleen via je wifi thuis.",
|
||||
"Turn on guest links first": "Zet eerst gastlinks aan",
|
||||
"In Home Assistant, open Settings, Apps, Dashboard Studio, Configuration. Under Network, give “Guest invite links” a port, for example 8199, and save. Then come back here.": "Open in Home Assistant Instellingen, Apps, Dashboard Studio, Configuratie. Geef onder Netwerk “Guest invite links” een poort, bijvoorbeeld 8199, en sla op. Kom daarna hier terug.",
|
||||
"New invite": "Nieuwe uitnodiging",
|
||||
"For": "Voor",
|
||||
"e.g. Babysitter, Grandma and Grandpa": "bijv. Oppas, Opa en oma",
|
||||
"Works until": "Werkt tot",
|
||||
"4 hours from now": "Over 4 uur",
|
||||
"Tonight, 23:59": "Vanavond, 23:59",
|
||||
"24 hours from now": "Over 24 uur",
|
||||
"After the weekend (Monday 10:00)": "Na het weekend (maandag 10:00)",
|
||||
"A week from now": "Over een week",
|
||||
"Make invite": "Uitnodiging maken",
|
||||
"Address for the link": "Adres voor de link",
|
||||
"The address of Home Assistant on your home network, such as homeassistant.local or 192.168.1.20.": "Het adres van Home Assistant in je thuisnetwerk, zoals homeassistant.local of 192.168.1.20.",
|
||||
"QR code for the invite": "QR-code voor de uitnodiging",
|
||||
"No invites yet.": "Nog geen uitnodigingen.",
|
||||
"Couldn’t make the invite: {0}": "Uitnodiging maken lukte niet: {0}",
|
||||
"End this invite?": "Deze uitnodiging beëindigen?",
|
||||
"The link stops working at once, also on phones that have it open.": "De link werkt direct niet meer, ook op telefoons waar hij open staat.",
|
||||
"The dashboard’s theme": "Thema van het dashboard",
|
||||
"Normal": "Normaal",
|
||||
"Large": "Groot",
|
||||
"Extra large": "Extra groot",
|
||||
"English": "English",
|
||||
"Nederlands": "Nederlands",
|
||||
"Cleaned": "Schoongemaakt",
|
||||
"Time": "Tijd",
|
||||
"Last clean": "Laatste schoonmaak",
|
||||
"days": "dagen",
|
||||
"hrs": "uur",
|
||||
"mins": "min",
|
||||
"Round {0} · {1}": "Ronde {0} · {1}",
|
||||
"Look": "Uiterlijk",
|
||||
"Official": "Officieel",
|
||||
"Logo image (put the official logo in Home Assistant's www folder)": "Logo-afbeelding (zet het officiële logo in de www-map van Home Assistant)",
|
||||
"Midnight": "Middernacht",
|
||||
"Espresso": "Espresso",
|
||||
"Ember": "Gloed",
|
||||
"Lagoon": "Lagune",
|
||||
"Arctic": "Arctisch",
|
||||
"Moss": "Mos",
|
||||
"Sakura": "Sakura",
|
||||
"Concrete": "Beton",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
@@ -745,6 +795,8 @@
|
||||
function detect() {
|
||||
const pref = read("ds.lang");
|
||||
if (pref && pref !== "auto") return pref;
|
||||
const person = read("ds.personLang"); // chosen for this person by an admin
|
||||
if (person) return person;
|
||||
let ha = read("selectedLanguage"); // Home Assistant stores it as a JSON string
|
||||
try { ha = ha && JSON.parse(ha); } catch { /* plain string */ }
|
||||
return String(ha || navigator.language || "en").toLowerCase().startsWith("nl") ? "nl" : "en";
|
||||
|
||||
+116
-84
@@ -908,47 +908,7 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
@container (max-height: 200px) { .room-devices { display: none; } }
|
||||
|
||||
/* vacuum: compact on small cards, a robot with stats on bigger ones */
|
||||
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; }
|
||||
.vac-head { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); white-space: nowrap; }
|
||||
.vac-batt .i { font-size: 18px; }
|
||||
.vac-hero { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; gap: 18px; }
|
||||
.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: 14px; background: var(--d-fill); }
|
||||
.vac-robot { position: relative; width: clamp(84px, 32cqh, 150px); aspect-ratio: 1; flex: none; display: grid; place-items: center; }
|
||||
.vac-ring { position: absolute; inset: 0; transform: rotate(-90deg); }
|
||||
.vac-ring circle { fill: none; stroke-width: 5; }
|
||||
.vac-ring .track { stroke: var(--d-fill); }
|
||||
.vac-ring .fill { stroke: var(--ok); stroke-linecap: round; transition: stroke-dasharray .6s; }
|
||||
.vac-ring .fill.low { stroke: var(--crit); }
|
||||
.vac-robot-ico { position: relative; z-index: 1; width: 62%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; font-size: clamp(30px, 12cqh, 54px); background: var(--d-fill); }
|
||||
.state-cleaning .vac-robot-ico, .state-returning .vac-robot-ico { background: var(--accent-w); color: var(--d-on-accent, #fff); }
|
||||
.vac-pulse { position: absolute; inset: 19%; border-radius: 50%; }
|
||||
.vac-robot.is-cleaning .vac-pulse { animation: vac-pulse 1.8s ease-out infinite; border: 2px solid var(--accent-w); }
|
||||
@keyframes vac-pulse { from { transform: scale(1); opacity: .7; } to { transform: scale(1.45); opacity: 0; } }
|
||||
.vac-charge { position: absolute; z-index: 2; right: 8%; bottom: 8%; width: 28%; aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--ok); color: #04210f; font-size: clamp(14px, 4cqh, 20px); box-shadow: 0 0 0 3px var(--w-bg, var(--d-tile)); }
|
||||
.vac-hero-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.vac-headline { font-size: clamp(18px, 6cqh, 26px); font-weight: 700; line-height: 1.15; }
|
||||
.vac-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 8px; flex: none; }
|
||||
.vac-stat { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 8px; align-items: center; padding: 10px 12px; border-radius: 14px; background: var(--d-fill); min-width: 0; }
|
||||
.vac-stat .i { grid-row: span 2; font-size: 18px; color: var(--d-text-2); }
|
||||
.vac-stat-v { font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vac-stat-l { font-size: 12px; color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vac-fan { flex: none; background: var(--d-fill); }
|
||||
.vac-fan button { color: inherit; display: grid; place-items: center; }
|
||||
.vac-fan button[aria-pressed="true"] { background: var(--d-tile); color: var(--d-text); }
|
||||
.vac-actions { display: flex; gap: 8px; flex: none; }
|
||||
.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 12px; border-radius: 14px; background: var(--d-fill); font-size: 14px; font-weight: 600; }
|
||||
.vac-btn .i { font-size: 18px; flex: none; }
|
||||
.vac-btn.primary { background: var(--d-text); color: var(--d-tile); }
|
||||
.vac-btn:disabled { opacity: .4; cursor: default; }
|
||||
.vac.state-error .state { color: var(--crit); font-weight: 600; }
|
||||
/* the more room, the more it shows */
|
||||
@container (max-width: 330px) { .vac-btn span { display: none; } .vac-hero-text { display: none; } }
|
||||
@container (max-height: 370px) { .vac-stats { display: none; } }
|
||||
@container (max-height: 330px) { .vac-fan { display: none; } }
|
||||
@container (max-height: 250px) { .vac-hero { display: none; } }
|
||||
@container (min-height: 251px) { .vac-head .vac-batt, .vac-head .state { display: none; } }
|
||||
@container (min-height: 251px) and (max-width: 330px) { .vac-head .vac-batt { display: inline-flex; } }
|
||||
|
||||
/* photos */
|
||||
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
||||
@@ -1210,30 +1170,6 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.sp-extra { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Formula One */
|
||||
.f1 { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }
|
||||
.f1-next { padding: 12px; border-radius: 14px; }
|
||||
.f1-top { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--d-text-2); }
|
||||
.f1-top .i { color: #e10600; font-size: 15px; }
|
||||
.f1-top b { margin-left: auto; color: var(--d-text); font-weight: 600; }
|
||||
.f1-race { font-size: 18px; font-weight: 700; margin-top: 4px; letter-spacing: -0.01em; }
|
||||
.f1-place { font-size: 12px; color: var(--d-text-2); }
|
||||
.f1-sessions { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; font-size: 12px; }
|
||||
.f1-sessions > div { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.f1-sessions .past { opacity: .5; }
|
||||
.f1-sessions .next { font-weight: 700; color: var(--d-text); }
|
||||
.f1-tabs { display: flex; gap: 4px; padding: 3px; border-radius: 10px; background: var(--d-fill); }
|
||||
.f1-tabs button { flex: 1; padding: 5px 4px; border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--d-text-2); }
|
||||
.f1-tabs button[aria-selected="true"] { background: var(--d-tile); color: var(--d-text); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
|
||||
.f1-list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; }
|
||||
.f1-row { display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--d-separator); }
|
||||
.f1-row:last-child { border-bottom: 0; }
|
||||
.f1-pos { font-weight: 700; color: var(--d-text-2); text-align: center; }
|
||||
.f1-row:first-child .f1-pos { color: #e10600; }
|
||||
.f1-name { display: flex; flex-direction: column; min-width: 0; }
|
||||
.f1-name b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.f1-name span { font-size: 11px; color: var(--d-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.f1-val { font-size: 13px; font-weight: 600; }
|
||||
@container (max-height: 380px) { .f1-sessions { display: none; } }
|
||||
|
||||
/* announcements */
|
||||
#announce { position: fixed; inset: 0; z-index: 95; display: grid; place-items: center; padding: 24px; background: rgba(0, 0, 0, .72); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); }
|
||||
@@ -1697,18 +1633,6 @@ body.in-haven #btn-new { display: none; }
|
||||
.room-dev.on { background: color-mix(in srgb, var(--d-text) 88%, transparent); }
|
||||
|
||||
/* vacuum: the state in words, the battery as a bar */
|
||||
.vac-hero { justify-content: flex-start; align-items: center; }
|
||||
.vac-hero-text { gap: 8px; width: 100%; }
|
||||
.vac-headline { font-size: clamp(26px, 9cqh, 40px); line-height: 1.1; }
|
||||
.vac-meter { display: block; height: 6px; border-radius: 3px; background: var(--d-fill); overflow: hidden; max-width: 320px; }
|
||||
.vac-meter i { display: block; height: 100%; border-radius: 3px; background: var(--ok); }
|
||||
.vac-meter.low i { background: var(--crit); }
|
||||
.vac-stat { padding: 10px 12px; border-radius: var(--c-r); grid-template-columns: 1fr; }
|
||||
.vac-stat .i { display: none; }
|
||||
.vac-stat-v { font-size: var(--c-fs-mid); font-weight: 400; }
|
||||
.vac-stat-l { font-size: var(--c-fs-meta); }
|
||||
.vac-btn { min-height: var(--c-ctl); border-radius: var(--c-r); font-size: var(--c-fs-body); }
|
||||
@container (max-width: 330px) { .vac-hero-text { display: flex; } }
|
||||
|
||||
/* appliance: a thin progress ring with a plain icon inside */
|
||||
.appl-ring circle { stroke-width: 4; }
|
||||
@@ -1783,14 +1707,6 @@ body.in-haven #btn-new { display: none; }
|
||||
.sp-sub, .sp-extra { font-size: var(--c-fs-meta); }
|
||||
|
||||
/* Formula One: the next race on one flat step, no gradient */
|
||||
.f1-next { background: var(--d-fill); border-radius: var(--c-r); }
|
||||
.f1-race { font-weight: 600; font-size: var(--c-fs-mid); }
|
||||
.f1-place, .f1-top, .f1-sessions { font-size: var(--c-fs-meta); }
|
||||
.f1-name span { font-size: 12px; }
|
||||
.f1-name b { font-size: var(--c-fs-body); font-weight: 600; }
|
||||
.f1-tabs button { font-size: var(--c-fs-meta); min-height: 30px; }
|
||||
.f1-tabs button[aria-selected="true"] { box-shadow: none; }
|
||||
.f1-pos { font-weight: 500; }
|
||||
|
||||
/* calendar */
|
||||
.cal-sum { font-size: var(--c-fs-body); }
|
||||
@@ -1928,3 +1844,119 @@ body:not([style*="--night-dim: 0."]):not([style*="--night-dim: 1"]) #night-dim {
|
||||
.climate .round { width: 38px; height: 38px; }
|
||||
}
|
||||
.tm-time { font-size: clamp(20px, min(14cqh, 15cqw), 44px); }
|
||||
|
||||
/* vacuum: the robot beside its dock, the last clean, maintenance */
|
||||
.vac { display: flex; flex-direction: column; height: 100%; gap: 12px; min-height: 0; }
|
||||
.vac-head { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
|
||||
.vac-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.vac-title .name { font-size: var(--c-fs-title); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vac-status { font-size: clamp(22px, 8cqw, 30px); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.vac.state-error .vac-status { color: var(--crit); }
|
||||
.vac-batt { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; padding-top: 2px; }
|
||||
.vac-batt-v { font-size: var(--c-fs-mid); line-height: 1; }
|
||||
.vac-batt-v small { font-size: .6em; color: var(--d-text-2); margin-left: 1px; }
|
||||
.vac-meter { display: block; width: 64px; height: 5px; border-radius: 3px; background: var(--d-fill); overflow: hidden; }
|
||||
.vac-meter i { display: block; height: 100%; border-radius: 3px; background: var(--d-text); }
|
||||
.charging .vac-meter i { background: var(--ok); }
|
||||
.vac-meter.low i { background: var(--crit); }
|
||||
.vac-art { flex: 1; min-height: 64px; display: grid; place-items: center; }
|
||||
.vac-map { width: 100%; height: 100%; object-fit: contain; border-radius: var(--c-r); background: var(--d-fill); }
|
||||
.vac-scene { width: 100%; height: 100%; max-height: 132px; overflow: visible; }
|
||||
.vs-dock { fill: color-mix(in srgb, var(--d-text) 12%, var(--w-bg, var(--d-tile))); }
|
||||
.vs-plate { fill: color-mix(in srgb, var(--d-text) 30%, transparent); }
|
||||
.vs-led { fill: color-mix(in srgb, var(--d-text) 30%, transparent); }
|
||||
.vs-led.on { fill: var(--ok); }
|
||||
.vs-trail { fill: none; stroke: var(--d-accent); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 1 8; opacity: .8; }
|
||||
.vs-robot { transition: transform .8s cubic-bezier(.3, .7, .3, 1); }
|
||||
.vs-body { fill: color-mix(in srgb, var(--d-text) 7%, var(--w-bg, var(--d-tile))); stroke: color-mix(in srgb, var(--d-text) 26%, transparent); stroke-width: 2; }
|
||||
.vs-bumper { fill: none; stroke: color-mix(in srgb, var(--d-text) 45%, transparent); stroke-width: 3; stroke-linecap: round; }
|
||||
.vs-lidar { fill: color-mix(in srgb, var(--d-text) 20%, var(--w-bg, var(--d-tile))); stroke: color-mix(in srgb, var(--d-text) 35%, transparent); stroke-width: 1.5; }
|
||||
.vs-brush line { stroke: color-mix(in srgb, var(--d-text) 40%, transparent); stroke-width: 1.5; stroke-linecap: round; }
|
||||
.vs-robot.busy .vs-brush { animation: vs-spin .5s linear infinite; transform-box: fill-box; transform-origin: center; }
|
||||
.vs-robot.busy .vs-lidar { stroke: var(--d-accent); }
|
||||
.vs-robot.err .vs-body { stroke: var(--crit); }
|
||||
@keyframes vs-spin { to { rotate: 360deg; } }
|
||||
@media (prefers-reduced-motion: reduce) { .vs-robot.busy .vs-brush { animation: none; } .vs-robot { transition: none; } }
|
||||
.vac-facts { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||
.vac-fact { display: flex; flex-direction: column; gap: 1px; }
|
||||
.vac-fv { font-size: var(--c-fs-mid); line-height: 1.1; }
|
||||
.vac-fl { font-size: var(--c-fs-meta); color: var(--d-text-2); }
|
||||
.vac-maint { display: flex; flex-direction: column; gap: 6px; }
|
||||
.vac-mrow { display: grid; grid-template-columns: minmax(0, 7em) minmax(0, 1fr) 3em; align-items: center; gap: 10px; font-size: var(--c-fs-meta); }
|
||||
.vac-ml { color: var(--d-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vac-mbar { height: 4px; border-radius: 2px; background: var(--d-fill); overflow: hidden; }
|
||||
.vac-mbar i { display: block; height: 100%; background: color-mix(in srgb, var(--d-text) 55%, transparent); }
|
||||
.vac-mrow.low .vac-mbar i { background: var(--warn); }
|
||||
.vac-mrow.low .vac-mv { color: var(--warn); }
|
||||
.vac-mv { text-align: right; }
|
||||
.vac-fan { flex: none; }
|
||||
.vac-actions { display: flex; gap: 8px; flex: none; }
|
||||
.vac-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: var(--c-ctl); padding: 0 12px; border-radius: var(--c-r); background: var(--d-fill); font-size: var(--c-fs-body); font-weight: 600; }
|
||||
.vac-btn .i { font-size: 18px; flex: none; }
|
||||
.vac-btn.primary { background: var(--d-text); color: var(--d-tile); }
|
||||
.vac-btn:disabled { opacity: .4; cursor: default; }
|
||||
@media (hover: hover) { .vac-btn:not(.primary):not(:disabled):hover { background: color-mix(in srgb, var(--d-text) 14%, transparent); } }
|
||||
/* the more room, the more it shows */
|
||||
@container (max-width: 330px) { .vac-btn span { display: none; } }
|
||||
@container (max-height: 460px) { .vac-maint { display: none; } }
|
||||
@container (max-height: 380px) { .vac-facts { display: none; } }
|
||||
@container (max-height: 320px) { .vac-fan { display: none; } }
|
||||
@container (max-height: 240px) { .vac-art { display: none; } }
|
||||
@media (max-width: 640px) { .vac-btn span { display: none; } }
|
||||
|
||||
/* Formula One: the official look (carbon black, F1 red, white), or the dashboard's theme */
|
||||
.f1 { display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; --f1-red: #e10600; --f1-ink: var(--d-text); --f1-mute: var(--d-text-2); --f1-step: var(--d-fill); }
|
||||
.f1.f1-official { margin: calc(-1 * var(--c-pad)); padding: var(--c-pad); height: calc(100% + 2 * var(--c-pad)); border-radius: inherit;
|
||||
background: #15151e; color: #fff; --f1-ink: #fff; --f1-mute: #b5b5c0; --f1-step: #26262f; --d-separator: #2c2c36; }
|
||||
.f1-brand { display: flex; align-items: center; gap: 10px; min-height: 22px; }
|
||||
.f1-logo { height: 20px; width: auto; max-width: 96px; object-fit: contain; }
|
||||
.f1-mark { width: 30px; height: 8px; border-radius: 2px; background: var(--f1-red); transform: skewX(-24deg); }
|
||||
.f1-round { font-size: var(--c-fs-meta); color: var(--f1-mute); margin-left: auto; }
|
||||
.f1-next { display: flex; flex-direction: column; gap: 4px; }
|
||||
.f1-race { font-size: clamp(20px, 7.5cqw, 30px); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; }
|
||||
.f1-place { font-size: var(--c-fs-meta); color: var(--f1-mute); }
|
||||
.f1-clock { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.f1-cd { display: flex; flex-direction: column; align-items: center; min-width: 52px; padding: 6px 8px; border-radius: 6px; background: var(--f1-step); }
|
||||
.f1-cd b { font-size: var(--c-fs-mid); font-weight: 700; line-height: 1.1; }
|
||||
.f1-cd small { font-size: 12px; color: var(--f1-mute); }
|
||||
.f1-sessions { display: flex; flex-direction: column; margin-top: 8px; }
|
||||
.f1-s { display: grid; grid-template-columns: 4.6em minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 28px; padding: 0 8px; font-size: var(--c-fs-meta); border-left: 3px solid transparent; }
|
||||
.f1-sd { color: var(--f1-mute); }
|
||||
.f1-sn { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.f1-s.past { opacity: .45; }
|
||||
.f1-s.next { border-left-color: var(--f1-red); background: var(--f1-step); font-weight: 700; }
|
||||
.f1-tabs { display: flex; gap: 16px; border-bottom: 1px solid var(--d-separator); flex: none; }
|
||||
.f1-tabs button { padding: 8px 0; font-size: var(--c-fs-meta); font-weight: 600; color: var(--f1-mute); border-bottom: 3px solid transparent; margin-bottom: -1px; }
|
||||
.f1-tabs button[aria-selected="true"] { color: var(--f1-ink); border-bottom-color: var(--f1-red); }
|
||||
.f1-list { display: flex; flex-direction: column; overflow-y: auto; min-height: 0; }
|
||||
.f1-row { display: grid; grid-template-columns: 22px 4px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; border-bottom: 1px solid var(--d-separator); }
|
||||
.f1-row:last-child { border-bottom: 0; }
|
||||
.f1-pos { font-weight: 700; text-align: right; }
|
||||
.f1-stripe { display: block; width: 4px; height: 22px; border-radius: 1px; background: var(--team); }
|
||||
.f1-name { display: flex; flex-direction: column; min-width: 0; }
|
||||
.f1-name b { font-size: var(--c-fs-body); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.f1-code { font-weight: 800; }
|
||||
.f1-name > span { font-size: 12px; color: var(--f1-mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.f1-val { font-size: var(--c-fs-body); font-weight: 700; text-align: right; }
|
||||
.f1-val small { font-size: 12px; font-weight: 400; color: var(--f1-mute); margin-left: 3px; }
|
||||
@container (max-height: 520px) { .f1-sessions { display: none; } }
|
||||
@container (max-height: 380px) { .f1-clock { display: none; } }
|
||||
|
||||
/* a person's text size, chosen by an admin */
|
||||
body.text-large #grid { --c-fs-title: 17px; --c-fs-body: 17px; --c-fs-name: 18px; --c-fs-meta: 15px; --c-fs-mid: 25px; --c-ctl: 48px; --c-row: 52px; }
|
||||
body.text-xl #grid { --c-fs-title: 19px; --c-fs-body: 19px; --c-fs-name: 21px; --c-fs-meta: 17px; --c-fs-mid: 28px; --c-ctl: 54px; --c-row: 58px; }
|
||||
.p-look { margin: 12px 0 0 52px; display: flex; flex-wrap: wrap; gap: 8px 20px; }
|
||||
.p-look .field { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; color: var(--text-2); }
|
||||
.p-look .input { width: auto; min-width: 120px; }
|
||||
@media (max-width: 760px) { .p-look { margin-left: 0; } }
|
||||
|
||||
/* guests */
|
||||
body.guest .guest-hide { display: none; }
|
||||
.inv-form { display: grid; grid-template-columns: 1.2fr 1fr 1fr auto; gap: 10px; align-items: end; margin-top: 12px; }
|
||||
.inv-form .field { margin: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-2); }
|
||||
.inv-host { margin: 14px 0 4px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-2); max-width: 360px; }
|
||||
.inv-fresh { margin-top: 16px; max-width: 420px; }
|
||||
.inv-fresh .share-qr { margin-left: 0; }
|
||||
.inv-row.ended { opacity: .6; }
|
||||
.adm-panel.notice { background: color-mix(in srgb, var(--warn) 10%, var(--surface)); }
|
||||
@media (max-width: 760px) { .inv-form { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -897,6 +897,24 @@
|
||||
// ---------------------------------------------------------------- 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).
|
||||
/** A robot vacuum seen from above beside its dock, placed and drawn by its state. */
|
||||
function vacScene(state, charging) {
|
||||
const docked = state === "docked", cleaning = state === "cleaning", returning = state === "returning", error = state === "error";
|
||||
const x = docked ? 196 : returning ? 150 : cleaning ? 92 : 124;
|
||||
const trail = cleaning || returning ? `<path class="vs-trail" d="M224 62 C 196 104, 150 18, ${x + 30} 60"/>` : "";
|
||||
return `<svg class="vac-scene" viewBox="0 0 300 120" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${esc(st(state))}">
|
||||
<rect class="vs-dock" x="232" y="18" width="52" height="84" rx="10"/>
|
||||
<rect class="vs-plate" x="226" y="44" width="10" height="32" rx="3"/>
|
||||
<circle class="vs-led ${charging ? "on" : ""}" cx="266" cy="34" r="4"/>
|
||||
${trail}
|
||||
<g class="vs-robot ${cleaning ? "busy" : ""} ${error ? "err" : ""}" transform="translate(${x} 60)">
|
||||
<circle class="vs-body" r="36"/>
|
||||
<path class="vs-bumper" d="M -14 -32.4 A 35 35 0 0 0 -14 32.4"/>
|
||||
<circle class="vs-lidar" cx="12" r="11"/>
|
||||
<g class="vs-brush" transform="translate(-21 -21)"><line x1="0" y1="-6" x2="0" y2="6"/><line x1="-5.2" y1="3" x2="5.2" y2="-3"/><line x1="-5.2" y1="-3" x2="5.2" y2="3"/></g>
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
W.vacuum = {
|
||||
label: "Vacuum", icon: "vacuum", size: [4, 5],
|
||||
fields: [
|
||||
@@ -920,31 +938,32 @@
|
||||
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 : "";
|
||||
// the robot's own status only when it adds something (an error, a paused job), not "Returning home" twice
|
||||
const detail = a.status && !["cleaning", "returning", "docked"].includes(s.state) && !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 : [];
|
||||
|
||||
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("");
|
||||
// The robot's own sensors: life left in percent goes under Maintenance, the rest with the last clean.
|
||||
const S = (x) => ha.states[x];
|
||||
const prefix = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "i");
|
||||
const label = (x) => cap(friendly(S(x)).replace(prefix, "").replace(/\s+(left|life|remaining)$/i, ""));
|
||||
const extra = (c.entities || []).filter((x) => S(x));
|
||||
const maint = extra.filter((x) => S(x).attributes.unit_of_measurement === "%" && isFinite(Number(S(x).state)));
|
||||
const facts = [
|
||||
c.area && S(c.area) ? [t("Cleaned"), fmtState(S(c.area))] : null,
|
||||
c.duration && S(c.duration) ? [t("Time"), fmtState(S(c.duration))] : null,
|
||||
...extra.filter((x) => !maint.includes(x)).map((x) => [label(x), fmtState(S(x))]),
|
||||
].filter(Boolean);
|
||||
|
||||
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>`;
|
||||
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" : ""} aria-label="${esc(label)}">${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-hero-text"><span class="vac-headline">${esc(headline)}</span>
|
||||
${hasBatt ? `<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span>
|
||||
<span class="wt num">${charging ? t("Battery {0}%, charging", Math.round(batt)) : t("Battery {0}%", Math.round(batt))}</span>` : ""}</div>`}
|
||||
</div>
|
||||
${stats ? `<div class="vac-stats">${stats}</div>` : ""}
|
||||
return `<div class="vac state-${esc(s.state)} ${charging ? "charging" : ""}">
|
||||
<div class="vac-head"><div class="vac-title"><span class="name">${esc(name)}</span><span class="vac-status">${esc(headline)}${detail ? ` · ${esc(detail)}` : ""}</span></div>
|
||||
${hasBatt ? `<div class="vac-batt"><span class="vac-batt-v num">${Math.round(batt)}<small>%</small></span>
|
||||
<span class="vac-meter ${batt < 20 ? "low" : ""}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(batt)}" aria-label="${t("Battery")}"><i style="width:${Math.round(batt)}%"></i></span></div>` : ""}</div>
|
||||
<div class="vac-art">${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")}">` : vacScene(s.state, charging)}</div>
|
||||
${facts.length ? `<div class="vac-facts" aria-label="${t("Last clean")}">${facts.map(([l, v]) => `<div class="vac-fact"><span class="vac-fv num">${esc(v)}</span><span class="vac-fl">${esc(l)}</span></div>`).join("")}</div>` : ""}
|
||||
${maint.length ? `<div class="vac-maint">${maint.map((x) => { const v = Math.max(0, Math.min(100, Number(S(x).state))); return `<div class="vac-mrow ${v < 15 ? "low" : ""}"><span class="vac-ml">${esc(label(x))}</span><span class="vac-mbar"><i style="width:${v}%"></i></span><span class="vac-mv num">${Math.round(v)}%</span></div>`; }).join("")}</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)}
|
||||
@@ -1549,6 +1568,12 @@
|
||||
const f1List = (s, ...keys) => { const a = s?.attributes || {}; for (const k of keys) if (Array.isArray(a[k])) return a[k]; return []; };
|
||||
const f1Driver = (r) => { const d = r.Driver || r.driver || {}; return typeof d === "string" ? d : [d.givenName, d.familyName].filter(Boolean).join(" ") || d.code || ""; };
|
||||
const f1Team = (r) => { const c = r.Constructor || (r.Constructors || [])[0] || r.constructor_name || r.team; return typeof c === "string" ? c : c?.name || ""; };
|
||||
const f1Code = (r) => { const d = r.Driver || r.driver || {}; return (typeof d === "object" && d.code) || f1Driver(r).split(" ").pop().slice(0, 3).toUpperCase(); };
|
||||
/** The teams' own colours, as on the timing tower. */
|
||||
const F1_TEAMS = [["red bull", "#3671c6"], ["mclaren", "#ff8000"], ["ferrari", "#e80020"], ["mercedes", "#27f4d2"], ["aston", "#229971"],
|
||||
["alpine", "#0093cc"], ["williams", "#64c4ff"], ["racing bulls", "#6692ff"], ["rb f1", "#6692ff"], ["visa", "#6692ff"], ["sauber", "#52e252"],
|
||||
["kick", "#52e252"], ["audi", "#bb0a30"], ["haas", "#b6babd"], ["cadillac", "#c0a062"]];
|
||||
const f1Colour = (team) => (F1_TEAMS.find(([k]) => String(team).toLowerCase().includes(k)) || [, "#8e8e93"])[1];
|
||||
|
||||
W.f1 = {
|
||||
label: "Formula One", icon: "flag", size: [4, 5],
|
||||
@@ -1559,6 +1584,8 @@
|
||||
{ k: "results", t: "entity", l: "Last race results", domains: ["sensor"] },
|
||||
{ k: "show", t: "seg", l: "Show first", o: ["drivers", "teams", "results"], d: "drivers" },
|
||||
{ k: "top", t: "number", l: "Positions to show", d: 5 },
|
||||
{ k: "look", t: "seg", l: "Look", o: ["official", "theme"], d: "official" },
|
||||
{ k: "logo", t: "text", l: "Logo image (put the official logo in Home Assistant's www folder)", ph: "/local/f1-logo.svg" },
|
||||
],
|
||||
autofill: {
|
||||
from: "next",
|
||||
@@ -1579,7 +1606,8 @@
|
||||
const m = Math.round((raceAt - Date.now()) / 60000), dd = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
|
||||
countdown = dd ? t("in {0} d {1} h", dd, hh) : t("in {0} h {1} min", hh, m % 60);
|
||||
}
|
||||
const when = (d) => `${d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" })} ${d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })}`;
|
||||
const day = (d) => d.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric" });
|
||||
const time = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
|
||||
const sessions = F1_SESSIONS.map(([k, l]) => [l, iso(k)]).filter(([, v]) => v && !isNaN(new Date(v)));
|
||||
const nextSession = sessions.find(([, v]) => new Date(v) > Date.now());
|
||||
const place = [a.circuit_name || a.Circuit?.circuitName, a.circuit_locality || a.Circuit?.Location?.locality, a.circuit_country || a.Circuit?.Location?.country].filter(Boolean).join(" · ");
|
||||
@@ -1592,17 +1620,31 @@
|
||||
const list = cur[0] === "drivers" ? f1List(s, "driver_standings", "standings", "DriverStandings")
|
||||
: cur[0] === "teams" ? f1List(s, "constructor_standings", "standings", "ConstructorStandings") : f1List(s, "results", "Results", "race_results");
|
||||
rows = list.slice(0, top).map((r) => {
|
||||
const name = cur[0] === "teams" ? f1Team(r) : f1Driver(r);
|
||||
const sub = cur[0] === "teams" ? (r.wins != null ? t("{0} wins", r.wins) : "") : f1Team(r);
|
||||
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""} ${t("pts")}`;
|
||||
return `<div class="f1-row"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><span class="f1-name"><b>${esc(name)}</b><span>${esc(sub)}</span></span><span class="f1-val num">${esc(val)}</span></div>`;
|
||||
const team = f1Team(r), isTeam = cur[0] === "teams";
|
||||
const name = isTeam ? team : f1Driver(r);
|
||||
const sub = isTeam ? (r.wins != null ? t("{0} wins", r.wins) : "") : team;
|
||||
const val = cur[0] === "results" ? (r.Time?.time || r.time || r.status || "") : `${r.points ?? ""}`;
|
||||
return `<div class="f1-row" style="--team:${f1Colour(team)}"><span class="f1-pos num">${esc(r.position ?? r.positionText ?? "")}</span><i class="f1-stripe" aria-hidden="true"></i>
|
||||
<span class="f1-name">${isTeam ? `<b>${esc(name)}</b>` : `<b><span class="f1-code">${esc(f1Code(r))}</span> ${esc(name)}</b>`}<span>${esc(sub)}</span></span>
|
||||
<span class="f1-val num">${esc(val)}${cur[0] === "results" ? "" : `<small>${t("pts")}</small>`}</span></div>`;
|
||||
}).join("");
|
||||
}
|
||||
// The countdown as the timing screens show it: days, hours, minutes.
|
||||
let clock = "";
|
||||
if (raceAt && raceAt > Date.now()) {
|
||||
const m = Math.round((raceAt - Date.now()) / 60000);
|
||||
clock = [[Math.floor(m / 1440), t("days")], [Math.floor((m % 1440) / 60), t("hrs")], [m % 60, t("mins")]]
|
||||
.map(([v, l]) => `<span class="f1-cd"><b class="num">${String(v).padStart(2, "0")}</b><small>${esc(l)}</small></span>`).join("");
|
||||
}
|
||||
const raceName = a.race_name || a.raceName || (next && !/^\d{4}-/.test(next.state) ? next.state : "");
|
||||
return `<div class="f1">
|
||||
${next ? `<div class="f1-next"><div class="f1-top">${Icons.svg("flag")}<span>${esc(a.season ? `${a.season} · ${t("Round {0}", a.round)}` : t("Next race"))}</span>${countdown ? `<b>${esc(countdown)}</b>` : ""}</div>
|
||||
const official = c.look !== "theme";
|
||||
return `<div class="f1 ${official ? "f1-official" : ""}">
|
||||
<div class="f1-brand">${c.logo ? `<img class="f1-logo" src="${esc(c.logo)}" alt="Formula 1">` : `<span class="f1-mark" aria-hidden="true"></span>`}
|
||||
<span class="f1-round">${esc(a.season ? t("Round {0} · {1}", a.round, a.season) : t("Next race"))}</span></div>
|
||||
${next ? `<div class="f1-next">
|
||||
<div class="f1-race">${esc(raceName || t("Next race"))}</div>${place ? `<div class="f1-place">${esc(place)}</div>` : ""}
|
||||
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => `<div class="${nextSession && nextSession[1] === v ? "next" : ""} ${new Date(v) < Date.now() ? "past" : ""}"><span>${esc(t(l))}</span><span class="num">${esc(when(new Date(v)))}</span></div>`).join("")}</div>` : ""}</div>` : ""}
|
||||
${clock ? `<div class="f1-clock" aria-label="${esc(countdown)}">${clock}</div>` : ""}
|
||||
${sessions.length ? `<div class="f1-sessions">${sessions.map(([l, v]) => { const d = new Date(v); return `<div class="f1-s ${nextSession && nextSession[1] === v ? "next" : ""} ${d < Date.now() ? "past" : ""}"><span class="f1-sd num">${esc(day(d))}</span><span class="f1-sn">${esc(t(l))}</span><span class="f1-st num">${esc(time(d))}</span></div>`; }).join("")}</div>` : ""}</div>` : ""}
|
||||
${tabs.length ? `<div class="f1-tabs" role="tablist">${tabs.map(([k, l]) => `<button class="wbtn" role="tab" data-act="card-tab" data-v="${k}" aria-selected="${cur[0] === k}">${esc(t(l))}</button>`).join("")}</div>
|
||||
<div class="f1-list">${rows || `<div class="empty">${t("No standings yet.")}</div>`}</div>` : ""}</div>`;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user