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; }
|
||||
|
||||
Reference in New Issue
Block a user