Dashboard Studio 2.2.1: sports match centre, Import JSON back, no double entities
- Sports card: crests in team colours, position and record, countdown, win-probability bar, live clock and last play, venue - Import JSON back in the builder's menu - Fixed: picking an entity with the keyboard added it twice; existing duplicates are removed when dashboards load Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -220,6 +220,12 @@
|
||||
|
||||
// Bring dashboards saved by older versions up to the current shape.
|
||||
function migrate(d) {
|
||||
// Before 2.2.1, picking an entity with the keyboard could add it twice: keep each entity once per list.
|
||||
const dedupe = (ws) => (ws || []).forEach((w) => {
|
||||
for (const [k, v] of Object.entries(w.cfg || {})) if (Array.isArray(v) && v.every((x) => typeof x === "string")) w.cfg[k] = [...new Set(v)];
|
||||
dedupe(w.cfg?.children);
|
||||
});
|
||||
(d.tabs || []).forEach((tb) => dedupe(tb.widgets));
|
||||
const t = d.theme || {};
|
||||
if (!t.preset) {
|
||||
t.preset = "Custom";
|
||||
@@ -1548,7 +1554,7 @@
|
||||
}
|
||||
if (path === "guest") {
|
||||
const g = current.guest;
|
||||
if (idx === "new") { if (!isChange || !v) return; mutate(() => { (g[k] ||= []).push(v); }, null, { rerender: false }); }
|
||||
if (idx === "new") { if (!isChange || !v || (g[k] || []).includes(v)) return; mutate(() => { (g[k] ||= []).push(v); }, null, { rerender: false }); }
|
||||
else mutate(() => { if (idx != null) g[k][Number(idx)] = v; else g[k] = v; }, `guest|${k}|${idx ?? ""}`, { rerender: false });
|
||||
checkGuest(true);
|
||||
if (isChange || t.tagName === "BUTTON") renderInspector();
|
||||
@@ -1572,6 +1578,7 @@
|
||||
|
||||
if (idx === "new") {
|
||||
if (!isChange || !v) return;
|
||||
if ((w.cfg[k] || []).includes(v)) { t.value = ""; return; } // already in the list
|
||||
mutate(() => { (w.cfg[k] ||= []).push(v); }, null);
|
||||
renderInspector();
|
||||
$(`#inspector [data-k="${k}"][data-idx="new"]`)?.focus();
|
||||
@@ -1628,6 +1635,8 @@
|
||||
|
||||
function onInspectorInput(e) {
|
||||
const t = e.target;
|
||||
// A field the inspector just replaced (picking an entity redraws it) can still report its change: ignore it.
|
||||
if (!t.isConnected) return;
|
||||
if (t.matches?.("[data-haptics]")) { if (e.type === "change") { lsSet("ds.haptics", t.checked ? "1" : "0"); haptic(); } return; }
|
||||
if (t.matches?.("[data-shortcut]")) {
|
||||
if (e.type !== "change") return;
|
||||
@@ -3343,7 +3352,7 @@
|
||||
return `<header class="adm-head"><div><h2>${tr("Dashboards")}</h2><p class="muted">${tr("Build a dashboard, then choose under People who sees it.")}</p></div>
|
||||
<div class="adm-actions"><button class="btn primary" data-a="new">${Icons.svg("plus")}${tr("New dashboard")}</button>
|
||||
<button class="btn" data-a="starter">${Icons.svg("sparkles")}${tr("From my devices")}</button>
|
||||
<button class="btn" data-a="import">${Icons.svg("plus")}${tr("Import file")}</button>
|
||||
<button class="btn" data-a="import">${Icons.svg("plus")}${tr("Import JSON…")}</button>
|
||||
<button class="btn" data-a="lovelace">${Icons.svg("import")}${tr("Import from Home Assistant")}</button></div></header>
|
||||
<div class="adm-cards">${cards}</div>`;
|
||||
},
|
||||
|
||||
@@ -773,6 +773,9 @@
|
||||
"Moss": "Mos",
|
||||
"Sakura": "Sakura",
|
||||
"Concrete": "Beton",
|
||||
"Draw": "Gelijk",
|
||||
"in {0} min": "over {0} min",
|
||||
"Chance to win: {0} {1}%, draw {2}%, {3} {4}%": "Winstkans: {0} {1}%, gelijk {2}%, {3} {4}%",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<button role="menuitem" data-menu="undo-history" data-icon="undo" data-i18n>Undo history…</button>
|
||||
<button role="menuitem" data-menu="share" data-icon="share" data-i18n>Share…</button>
|
||||
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
|
||||
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1152,22 +1152,6 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
@media (prefers-reduced-motion: reduce) { .gt.moving .gt-ring { animation: none; } }
|
||||
|
||||
/* sports */
|
||||
.sp { display: flex; flex-direction: column; gap: 8px; height: 100%; justify-content: center; min-height: 0; }
|
||||
.sp-league { font-size: 12px; font-weight: 600; color: var(--d-text-2); text-align: center; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.sp-match { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; }
|
||||
.sp-team { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; text-align: center; }
|
||||
.sp-team img { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; object-fit: contain; }
|
||||
.sp-badge { width: clamp(36px, 16cqmin, 64px); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; background: var(--d-fill); font-weight: 700; font-size: 13px; }
|
||||
.sp-team > span:last-child { font-size: 13px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sp-team.win > span:last-child { color: var(--ok); }
|
||||
.sp-mid { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.sp-score { font-size: clamp(26px, 11cqmin, 40px); font-weight: 700; letter-spacing: -0.02em; }
|
||||
.sp-score i { font-style: normal; color: var(--d-text-2); margin: 0 6px; }
|
||||
.sp-time { font-size: clamp(22px, 9cqmin, 32px); font-weight: 700; }
|
||||
.sp-sub { font-size: 12px; color: var(--d-text-2); white-space: nowrap; }
|
||||
.sp-live { color: var(--crit); }
|
||||
.sp-live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--crit); margin-right: 4px; vertical-align: 1px; animation: tz-pulse 1.4s infinite; }
|
||||
.sp-extra { font-size: 12px; color: var(--d-text-2); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Formula One */
|
||||
|
||||
@@ -1701,10 +1685,6 @@ body.in-haven #btn-new { display: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .gt.moving .gt-big { animation: none; } }
|
||||
|
||||
/* sports: sentence case, crests without a disc */
|
||||
.sp-league { text-transform: none; letter-spacing: 0; font-size: var(--c-fs-meta); }
|
||||
.sp-badge { background: transparent; box-shadow: inset 0 0 0 1.5px var(--d-separator); font-weight: 600; }
|
||||
.sp-team > span:last-child { font-size: var(--c-fs-body); }
|
||||
.sp-sub, .sp-extra { font-size: var(--c-fs-meta); }
|
||||
|
||||
/* Formula One: the next race on one flat step, no gradient */
|
||||
|
||||
@@ -1960,3 +1940,35 @@ body.guest .guest-hide { display: none; }
|
||||
.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; } }
|
||||
|
||||
/* sports: the match as a match centre shows it: crests in the teams' colours, the time or score in the middle */
|
||||
.sp { display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0; justify-content: space-between; }
|
||||
.sp-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: var(--c-fs-meta); color: var(--d-text-2); }
|
||||
.sp-league { font-weight: 600; color: var(--d-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sp-tv { flex: none; }
|
||||
.sp-match { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 10px; }
|
||||
.sp-side { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; text-align: center; }
|
||||
.sp-logo, .sp-crest { width: clamp(40px, 15cqmin, 64px); height: clamp(40px, 15cqmin, 64px); object-fit: contain; }
|
||||
.sp-crest { height: auto; aspect-ratio: 40 / 46; }
|
||||
.sp-name { font-size: var(--c-fs-body); font-weight: 600; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sp-meta { font-size: var(--c-fs-meta); color: var(--d-text-2); }
|
||||
.sp.done .sp-side:not(.win) { opacity: .6; }
|
||||
.sp-mid { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.sp-time, .sp-score { font-size: clamp(28px, 12cqmin, 46px); font-weight: 400; letter-spacing: -0.03em; line-height: 1.05; }
|
||||
.sp-score i { font-style: normal; color: var(--d-text-2); margin: 0 6px; }
|
||||
.sp-sub { font-size: var(--c-fs-meta); color: var(--d-text-2); white-space: nowrap; }
|
||||
.sp-until { font-size: var(--c-fs-meta); font-weight: 600; color: var(--d-accent); white-space: nowrap; }
|
||||
.sp-live { color: var(--crit); }
|
||||
.sp-live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--crit); margin-right: 5px; vertical-align: 1px; animation: tz-pulse 1.4s infinite; }
|
||||
@media (prefers-reduced-motion: reduce) { .sp-live::before { animation: none; } }
|
||||
.sp-odds { display: flex; flex-direction: column; gap: 4px; }
|
||||
.sp-bar { display: flex; gap: 2px; height: 6px; border-radius: 3px; overflow: hidden; }
|
||||
.sp-bar i { display: block; height: 100%; background: color-mix(in srgb, var(--d-text) 60%, transparent); }
|
||||
.sp-bar i.d { background: var(--d-fill); }
|
||||
.sp-odds-l { display: flex; justify-content: space-between; font-size: var(--c-fs-meta); color: var(--d-text-2); }
|
||||
.sp-place, .sp-play { display: flex; align-items: center; gap: 6px; font-size: var(--c-fs-meta); color: var(--d-text-2); min-width: 0; }
|
||||
.sp-place .i { font-size: 14px; flex: none; }
|
||||
.sp-place span, .sp-play { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sp-play { color: var(--d-text); }
|
||||
@container (max-height: 230px) { .sp-odds, .sp-place, .sp-play { display: none; } }
|
||||
@container (max-height: 170px) { .sp-meta, .sp-head { display: none; } }
|
||||
|
||||
@@ -1547,18 +1547,48 @@
|
||||
if (!s) return missing(c.entity, "a TeamTracker sensor");
|
||||
const a = s.attributes, state = String(s.state).toUpperCase();
|
||||
if (state === "NOT_FOUND" || state === "BYE" || !a.opponent_name) return `<div class="sp"><div class="sp-league">${esc(a.league || friendly(s))}</div><div class="empty">${state === "BYE" ? t("No match this round.") : t("No match planned.")}</div></div>`;
|
||||
const team = { name: a.team_name, short: a.team_abbr, logo: a.team_logo, score: a.team_score, win: a.team_winner },
|
||||
opp = { name: a.opponent_name, short: a.opponent_abbr, logo: a.opponent_logo, score: a.opponent_score, win: a.opponent_winner };
|
||||
// TeamTracker gives each side its colours, record, league position and, often, its chance to win.
|
||||
const hues = (v) => (Array.isArray(v) ? v : String(v || "").split(",")).map((x) => String(x).trim()).map((x) => (x.startsWith("#") ? x : `#${x}`)).filter((x) => /^#[0-9a-f]{6}$/i.test(x));
|
||||
const side = (p) => ({ name: a[`${p}_name`], short: a[`${p}_abbr`], logo: a[`${p}_logo`], score: a[`${p}_score`], win: a[`${p}_winner`],
|
||||
colors: hues(a[`${p}_colors`]), record: a[`${p}_record`], rank: a[`${p}_rank`], prob: Number(a[`${p}_win_probability`]) });
|
||||
const team = side("team"), opp = side("opponent");
|
||||
const [home, away] = a.team_homeaway === "away" ? [opp, team] : [team, opp];
|
||||
const side = (x) => `<div class="sp-team ${x.win ? "win" : ""}">${x.logo ? `<img src="${esc(x.logo)}" alt="">` : `<span class="sp-badge">${esc((x.short || x.name || "?").slice(0, 3))}</span>`}<span>${esc(x.name || x.short || "")}</span></div>`;
|
||||
const light = (hex) => { const n = parseInt(hex.slice(1), 16); return 0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255) > 160; };
|
||||
const crest = (x) => {
|
||||
if (x.logo) return `<img class="sp-logo" src="${esc(x.logo)}" alt="">`;
|
||||
const c1 = x.colors[0] || "#8e8e93", c2 = x.colors[1] && x.colors[1].toLowerCase() !== c1.toLowerCase() ? x.colors[1] : light(c1) ? "#1c1c1e" : "#ffffff";
|
||||
return `<svg class="sp-crest" viewBox="0 0 40 46" aria-hidden="true"><path d="M20 2 L37 8 V22 C37 33 29 41 20 44 C11 41 3 33 3 22 V8 Z" fill="${c1}" stroke="${c2}" stroke-width="2.5"/>
|
||||
<text x="20" y="27" text-anchor="middle" font-size="10.5" font-weight="800" fill="${light(c1) ? "#1c1c1e" : "#ffffff"}">${esc((x.short || x.name || "?").slice(0, 3).toUpperCase())}</text></svg>`;
|
||||
};
|
||||
const meta = (x) => [x.rank ? `#${x.rank}` : "", x.record].filter(Boolean).join(" · ");
|
||||
const sideHTML = (x) => `<div class="sp-side ${x.win ? "win" : ""}">${crest(x)}<span class="sp-name">${esc(x.name || x.short || "")}</span>${meta(x) ? `<span class="sp-meta num">${esc(meta(x))}</span>` : ""}</div>`;
|
||||
const kick = a.date ? new Date(a.date) : null;
|
||||
let mid;
|
||||
if (state === "PRE") mid = `<span class="sp-time num">${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}</span><span class="sp-sub">${esc(kick ? dayLabel(kick) : "")}${a.kickoff_in ? ` · ${esc(a.kickoff_in)}` : ""}</span>`;
|
||||
else mid = `<span class="sp-score num">${esc(home.score ?? 0)}<i>–</i>${esc(away.score ?? 0)}</span><span class="sp-sub">${state === "IN" ? `<b class="sp-live">${t("Live")}</b> ${esc(a.clock || "")}` : t("Full time")}</span>`;
|
||||
const extra = c.venue !== false ? [a.venue, a.tv_network].filter(Boolean).join(" · ") : "";
|
||||
return `<div class="sp ${state === "IN" ? "live" : ""}"><div class="sp-league">${esc(a.league || a.sport || "")}${a.event_name && state !== "PRE" ? "" : ""}</div>
|
||||
<div class="sp-match">${side(home)}<div class="sp-mid">${mid}</div>${side(away)}</div>
|
||||
${extra ? `<div class="sp-extra">${esc(extra)}</div>` : ""}${state === "IN" && a.last_play ? `<div class="sp-extra">${esc(a.last_play)}</div>` : ""}</div>`;
|
||||
if (state === "PRE") {
|
||||
let until = "";
|
||||
if (kick && kick > Date.now()) {
|
||||
const m = Math.round((kick - Date.now()) / 60000), d = Math.floor(m / 1440);
|
||||
until = d ? t("in {0} d {1} h", d, Math.floor((m % 1440) / 60)) : m >= 60 ? t("in {0} h {1} min", Math.floor(m / 60), m % 60) : t("in {0} min", m);
|
||||
}
|
||||
mid = `<span class="sp-time num">${kick ? esc(kick.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" })) : "–"}</span>
|
||||
<span class="sp-sub">${esc(kick ? dayLabel(kick) : "")}</span>${until ? `<span class="sp-until">${esc(until)}</span>` : ""}`;
|
||||
} else mid = `<span class="sp-score num">${esc(home.score ?? 0)}<i>–</i>${esc(away.score ?? 0)}</span>
|
||||
<span class="sp-sub">${state === "IN" ? `<b class="sp-live">${t("Live")}</b> <span class="num">${esc(a.clock || "")}</span>` : t("Full time")}</span>`;
|
||||
// The chance of each result, as one bar in the teams' colours.
|
||||
let odds = "";
|
||||
if (isFinite(home.prob) && isFinite(away.prob) && home.prob + away.prob > 0) {
|
||||
const draw = Math.max(0, 1 - home.prob - away.prob), pc = (v) => Math.round(v * 100);
|
||||
const seg = (v, col, cls) => (v > 0.005 ? `<i class="${cls}" style="flex-basis:${(v * 100).toFixed(1)}%;${col ? `background:${col}` : ""}"></i>` : "");
|
||||
odds = `<div class="sp-odds" aria-label="${esc(t("Chance to win: {0} {1}%, draw {2}%, {3} {4}%", home.name, pc(home.prob), pc(draw), away.name, pc(away.prob)))}">
|
||||
<div class="sp-bar">${seg(home.prob, home.colors[0], "h")}${draw > 0.02 ? seg(draw, "", "d") : ""}${seg(away.prob, away.colors[0], "a")}</div>
|
||||
<div class="sp-odds-l num"><span>${pc(home.prob)}%</span>${draw > 0.02 ? `<span>${esc(t("Draw"))} ${pc(draw)}%</span>` : ""}<span>${pc(away.prob)}%</span></div></div>`;
|
||||
}
|
||||
const place = c.venue !== false ? [a.venue, a.location].filter(Boolean).join(", ") : "";
|
||||
return `<div class="sp ${state === "IN" ? "live" : ""} ${state === "POST" ? "done" : ""}">
|
||||
<div class="sp-head"><span class="sp-league">${esc([a.league || a.sport, a.event_name && a.event_name !== a.league ? a.event_name : ""].filter(Boolean).join(" · "))}</span>${c.venue !== false && a.tv_network ? `<span class="sp-tv">${esc(a.tv_network)}</span>` : ""}</div>
|
||||
<div class="sp-match">${sideHTML(home)}<div class="sp-mid">${mid}</div>${sideHTML(away)}</div>
|
||||
${odds}
|
||||
${state === "IN" && a.last_play ? `<div class="sp-play">${esc(a.last_play)}</div>` : place ? `<div class="sp-place">${Icons.svg("pin")}<span>${esc(place)}</span></div>` : ""}</div>`;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user