Dashboard Studio 2.0.0: Admin, Builder and Dashboard screens, roles, Haven

- Home Assistant administrators are admins: Dashboard, Builder and Admin screens
- Everyone else only sees the dashboards an admin chose, always in kiosk mode;
  the server enforces it (saving, people, devices, history are admin-only, and
  the websocket only allows reading states and controlling devices)
- Admin screen: dashboards, people and what each sees (with preview), devices,
  messages to the screens, the Haven app, activity, about
- Haven: new Android app (haven/) that only shows dashboards; replaces the
  Dashboard Studio Android app and signs in through Home Assistant's page
- Screens pick up dashboard and access changes within a minute
- Version history records who made each change; panel visible to all users

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 23:20:55 +01:00
parent 297fb2a7a0
commit 3d958d0411
37 changed files with 1096 additions and 248 deletions
+432 -79
View File
@@ -246,9 +246,14 @@
let installPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
// The Android app (android/ in the repository) shows this page in its own window and adds window.DSApp.
const IN_APP = !!window.DSApp;
const APK_ID = "nl.melvanveen.dashboardstudio";
// Haven, the Android app (haven/ in the repository), shows this page in its own window and adds window.HavenApp.
// It only ever shows dashboards: no builder and no admin screen, for admins too.
const IN_APP = !!window.HavenApp;
const HAVEN_ID = "nl.melvanveen.haven";
// The public download in the repository, so a phone can get Haven before it is signed in to Home Assistant.
const HAVEN_DOWNLOAD = `${REPO_URL}/raw/branch/main/dashboard_studio/web/haven.apk`;
const havenLink = () => new URL("./", BASE).href;
const havenIntent = () => `intent://open?url=${encodeURIComponent(havenLink())}#Intent;scheme=haven;package=${HAVEN_ID};end`;
async function installDialog() {
let inFrame = false;
try { inFrame = window.top !== window; } catch { inFrame = true; }
@@ -260,17 +265,17 @@
const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
if (IN_APP) {
body = p(tr("This is the Dashboard Studio app, connected to {0}.", location.host));
body = p(tr("This is Haven, connected to {0}.", location.host));
actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
} else if (android) {
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: the Android app always works.
body = p(tr("Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download the app and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without the app."), "muted small") : "");
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: Haven always works.
body = p(tr("Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download Haven and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in Haven. Or open Haven and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without Haven."), "muted small") : "");
actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "")
+ `<a class="btn" href="${new URL("android.apk", BASE).href}" download="Dashboard Studio.apk">${tr("Download the app")}</a>`
+ `<a class="btn primary" target="_top" href="intent://open?url=${encodeURIComponent(link)}#Intent;scheme=dashboardstudio;package=${APK_ID};end">${tr("Open in the app")}</a>`;
+ `<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${tr("Download Haven")}</a>`
+ `<a class="btn primary" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</a>`;
} else if (ios) {
// Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant.
body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead."));
@@ -283,18 +288,34 @@
body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen."));
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
}
if (!IN_APP && !android && isAdmin()) body += p(tr("For Android phones there is Haven: see Admin, Haven app."), "muted small");
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
<div class="modal-actions">${actions}</div>`,
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
if (choice === "tab") window.open(link, "_blank", "noopener");
if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; }
if (choice === "reset") window.DSApp.reset();
if (choice === "reset") window.HavenApp.reset();
}
/** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so
* after a while requests would fail. Renew it the way Home Assistant's own page does, with the
* login that page saved in this browser. Inside Home Assistant this isn't needed. */
function wsSession(token) {
return new Promise((resolve) => {
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/websocket`);
const done = (v) => { clearTimeout(t); try { ws.close(); } catch { /* closed */ } resolve(v); };
const t = setTimeout(() => done(null), 10000);
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === "auth_required") ws.send(JSON.stringify({ type: "auth", access_token: token }));
else if (m.type === "auth_ok") ws.send(JSON.stringify({ id: 1, type: "supervisor/api", endpoint: "/ingress/session", method: "post" }));
else if (m.id === 1) done(m.success ? m.result?.session : null);
else if (m.type === "auth_invalid") done(null);
};
ws.onerror = () => done(null);
});
}
async function keepIngressSession() {
let top = true;
try { top = window.top === window; } catch { top = false; }
@@ -311,8 +332,8 @@
localStorage.setItem("hassTokens", JSON.stringify(tokens));
}
const r = await fetch("/api/hassio/ingress/session", { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}` } });
if (!r.ok) return;
const session = (await r.json())?.data?.session;
// Only administrators may use that address; Home Assistant's websocket gives everyone a session.
const session = r.ok ? (await r.json())?.data?.session : await wsSession(tokens.access_token);
if (session) document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
};
try { await renew(); } catch { /* offline: the cached page carries on */ }
@@ -647,6 +668,16 @@
// Each browser gets its own id, so a wall tablet can be told which dashboard to open.
const DEVICE_ID = lsGet("ds.device") || (() => { const id = uid() + uid(); lsSet("ds.device", id); return id; })();
let me = null; // the signed-in Home Assistant user, from the server
// Admins (Home Assistant's administrators) get three screens: Dashboard, Builder and Admin. Everyone else
// only sees the dashboards an admin chose for them, always in kiosk mode. The server enforces the same.
let role = lsGet("ds.role") === "basic" ? "basic" : "admin";
const isAdmin = () => role === "admin";
let mode = "dashboard"; // dashboard, builder or admin
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). */
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;
let editMode = lsGet("ds.edit") === "1";
let selectedId = null;
@@ -681,6 +712,7 @@
// ------------------------------------------------------------ persistence
let saveTimer = null, savePending = false;
function scheduleSave() {
if (!isAdmin()) return; // the server refuses it anyway
savePending = true;
$("#save-status").textContent = tr("Saving…");
clearTimeout(saveTimer);
@@ -801,7 +833,7 @@
s.setProperty("--d-radius", `${t.radius ?? 20}px`);
const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar);
window.DSApp?.setBarColor(bar);
window.HavenApp?.setBarColor(bar);
s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`);
stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System");
s.setProperty("--d-blur", `${t.blur || 0}px`);
@@ -1051,7 +1083,7 @@
}
function renderDashSelect() {
$("#dash-select").innerHTML = store.dashboards.map((d) => `<option value="${d.id}" ${d.id === current?.id ? "selected" : ""}>${esc(d.name)}</option>`).join("");
$("#dash-select").innerHTML = store.dashboards.filter((d) => !previewAs || previewAs.ids.includes(d.id)).map((d) => `<option value="${d.id}" ${d.id === current?.id ? "selected" : ""}>${esc(d.name)}</option>`).join("");
}
// ------------------------------------------------------------ suggestions
@@ -1129,7 +1161,7 @@
function renderAll() {
document.body.classList.toggle("editing", editMode);
$("#btn-edit").textContent = editMode ? tr("Done") : tr("Edit");
renderModeSeg();
renderDashSelect();
renderGrid();
if (editMode) renderInspector();
@@ -2372,50 +2404,6 @@
});
}
/** Who sees which dashboard: this device, other known devices, and people. */
async function assignDialog() {
const users = await api("api/users").catch(() => []);
const people = new Map();
for (const s of Object.values(ha.states)) if (s.entity_id.startsWith("person.") && s.attributes.user_id) people.set(s.attributes.user_id, friendly(s));
for (const u of users) if (!people.has(u.id)) people.set(u.id, u.display || u.name || u.id);
const a = store.assign;
const opts = (val) => `<option value="">${tr("Last used")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const devRow = (id, d, mine) => `<div class="assign-row" data-dev="${id}">
<input class="input" data-a="name" value="${esc(d?.name || "")}" placeholder="${mine ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<select class="input" data-a="dashboard" aria-label="${tr("Dashboard")}">${opts(d?.dashboard)}</select>
<label class="assign-kiosk"><input type="checkbox" data-a="kiosk" ${d?.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${mine ? "" : `<button class="btn icon" data-x="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
const others = Object.entries(a.devices).filter(([id]) => id !== DEVICE_ID);
const html = `<h3>${tr("Who sees which dashboard")}</h3>
<p>${tr("Choose the dashboard that opens first. A device’s choice comes before a person’s; otherwise the last dashboard used on that device opens.")}</p>
<div class="section-title" style="margin-top:16px">${tr("This device")}</div>${devRow(DEVICE_ID, a.devices[DEVICE_ID], true)}
${others.length ? `<div class="section-title" style="margin-top:16px">${tr("Other devices")}</div>${others.map(([id, d]) => devRow(id, d, false)).join("")}` : ""}
<div class="section-title" style="margin-top:16px">${tr("People")}</div>
${people.size ? [...people].map(([id, name]) => `<div class="assign-row" data-user="${esc(id)}"><span class="assign-name">${esc(name)}${me?.id === id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</span>
<select class="input" data-a="dashboard" aria-label="${esc(tr("Dashboard for {0}", name))}">${opts(a.users[id])}</select></div>`).join("") : `<p class="muted small">${tr("People appear here once they have opened Dashboard Studio, or when they have a person entity.")}</p>`}
<div class="modal-actions"><button class="btn primary" data-x="done">${tr("Done")}</button></div>`;
const card = $("#modal .modal-card");
const done = openModal(html, (e, close) => {
const x = e.target.closest("[data-x]")?.dataset.x;
if (x === "forget") { delete a.devices[e.target.closest("[data-dev]").dataset.dev]; e.target.closest(".assign-row").remove(); scheduleSave(); }
if (x === "done") close();
});
card.classList.add("wide");
const update = (e) => {
const f = e.target, key = f.dataset.a, row = f.closest(".assign-row");
if (!key || !row) return;
const v = f.type === "checkbox" ? f.checked : f.value;
if (row.dataset.user) { if (v) a.users[row.dataset.user] = v; else delete a.users[row.dataset.user]; }
else { const d = (a.devices[row.dataset.dev] ||= {}); d[key] = v; }
scheduleSave();
};
card.addEventListener("change", update);
card.addEventListener("input", (e) => { if (e.target.dataset.a === "name") update(e); });
await done;
card.classList.remove("wide");
card.removeEventListener("change", update);
}
/** The changelog in a dialog. A small Markdown subset: ## headings, - bullets, **bold** and *italic*. */
async function whatsNew() {
let md = "";
@@ -2481,7 +2469,7 @@
async function requestWakeLock() {
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; }
window.DSApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
window.HavenApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
}
const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " ");
@@ -2496,17 +2484,18 @@
history.replaceState(null, "", `#kiosk&view=${current.id}`);
renderGrid();
poke();
toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS));
if (!lockedKiosk) toast(tr("Kiosk mode is on. To leave, tap the {0} corner {1} times.", tr(cornerName()), CORNER_TAPS));
}
function exitKiosk() {
if (lockedKiosk) return;
kiosk = false;
document.body.classList.remove("kiosk", "idle");
hideSaver();
closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null;
window.DSApp?.keepAwake(false);
window.HavenApp?.keepAwake(false);
if (document.fullscreenElement) document.exitFullscreen?.();
history.replaceState(null, "", location.pathname);
renderGrid();
@@ -2523,13 +2512,20 @@
async function kioskPrompt() {
if (promptOpen || modalClose) return;
promptOpen = true;
const timer = setTimeout(() => modalClose?.(null), PROMPT_MS);
const choice = await openModal(`<h3>${tr("Leave kiosk mode?")}</h3><p>${tr("This closes by itself in a few seconds.")}</p>
const timer = setTimeout(() => modalClose?.(null), PROMPT_MS * (lockedKiosk ? 2 : 1));
// 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("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); });
clearTimeout(timer);
promptOpen = false;
if (choice === "exit") exitKiosk();
if (choice?.startsWith("dash:")) { setCurrent(choice.slice(5)); lsSet("ds.current", current.id); }
else if (choice === "exit") exitKiosk();
else if (choice === "haven") window.HavenApp.reset();
else if (choice === "full") toggleFullscreen(false);
else if (choice === "guest") guestOn ? endGuest() : setGuestManual(true);
}
@@ -2938,10 +2934,60 @@
renderAll();
checkGuest(true);
checkAway();
if (mode === "admin") renderAdmin();
}
// ------------------------------------------------------------ screens
function renderModeSeg() {
const seg = $("#mode-seg");
seg.hidden = !isAdmin() || IN_APP || !!previewAs;
seg.querySelectorAll("[data-mode]").forEach((b) => { const on = b.dataset.mode === mode; b.classList.toggle("on", on); b.setAttribute("aria-selected", String(on)); });
}
function syncMode() {
document.body.classList.toggle("admin-mode", mode === "admin");
$("#admin").hidden = mode !== "admin";
if (!previewAs) lsSet("ds.mode", mode);
}
function setMode(m) {
if (!isAdmin() || IN_APP || previewAs) m = "dashboard";
if (m === "admin") {
if (kiosk) exitKiosk();
if (editMode) { editMode = false; openSheet(null); selectedId = null; multi.clear(); }
mode = "admin";
syncMode();
renderAll();
renderAdmin();
return;
}
mode = m;
setEdit(m === "builder");
}
/** Dashboards from before 2.0 had "opens first" per person in assign.users; that's now part of access. */
function migrateAccess() {
store.access ||= { everyone: { all: true }, users: {} };
store.access.users ||= {};
const old = store.assign?.users || {};
if (!Object.keys(old).length) return;
for (const [u, id] of Object.entries(old)) store.access.users[u] ||= { all: true, home: id };
store.assign.users = {};
scheduleSave();
}
/** People who aren't admins, with no dashboard chosen for them yet. */
function showEmpty() {
const el = $("#empty-note");
el.querySelector(".en-ico").innerHTML = Icons.svg("layout");
el.querySelector(".en-text").textContent = tr("An admin hasn’t chosen a dashboard for you yet. Ask someone who manages Home Assistant to pick one for you on the Admin screen in Dashboard Studio.");
el.hidden = false;
document.body.classList.add("empty");
}
function setEdit(on) {
editMode = on;
if (on) mode = "builder";
else if (mode === "builder") mode = "dashboard";
syncMode();
if (!on) openSheet(null);
else renderPalette(); // the suggestions count needs the states
lsSet("ds.edit", on ? "1" : "0");
@@ -2985,10 +3031,10 @@
if (open) $("#menu button")?.focus();
}
$("#btn-edit").onclick = () => setEdit(!editMode);
$("#mode-seg").onclick = (e) => { const b = e.target.closest("[data-mode]"); if (b) setMode(b.dataset.mode); };
$("#btn-kiosk").onclick = () => enterKiosk();
$("#btn-app").onclick = () => installDialog();
$("#btn-app").hidden = IN_APP || standalone();
$("#btn-app").hidden = standalone() && !IN_APP; // in Haven it holds Change address
$("#btn-undo").onclick = undo;
$("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value);
@@ -3009,11 +3055,14 @@
const act = e.target.closest("[data-menu]")?.dataset.menu;
if (!act) return;
setMenu(false);
dashboardAction(act);
});
/** What can be done with the current dashboard, from the builder's ⋯ menu or a card on the Admin screen. */
async function dashboardAction(act) {
if (act === "rename") {
const n = await askText(tr("Rename dashboard"), current.name, tr("Rename"));
if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); }
} else if (act === "assign") {
assignDialog();
} else if (act === "history") {
historyDialog();
} else if (act === "undo-history") {
@@ -3051,7 +3100,7 @@
store.dashboards = store.dashboards.filter((d) => d.id !== current.id);
scheduleSave(); setCurrent(store.dashboards[0].id);
}
});
}
$("#import-file").onchange = async (e) => {
const file = e.target.files[0];
@@ -3227,6 +3276,273 @@
setInterval(() => { applyNight(); checkGuest(); }, 60000);
setInterval(() => { if (!editMode && current) tab().widgets.filter((w) => w.type === "graph" || w.type === "calendar" || w.cfg.showChanged).forEach((w) => renderWidget(w)); }, 60000);
// ------------------------------------------------------------ admin screen
// 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"],
];
let adminSection = ADMIN_SECTIONS.some(([k]) => k === lsGet("ds.admin")) ? lsGet("ds.admin") : "dashboards";
let peopleList = null, devicesSeen = 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) {
if (!peopleList || force) peopleList = await api("api/people").catch(() => []);
return peopleList;
}
/** Who may see a dashboard, in a few words. */
function seenByText(d) {
const basics = (peopleList || []).filter((p) => !p.admin);
const who = basics.filter((p) => visibleFor(p.id).includes(d.id));
if (!basics.length) return tr("Admins");
if (who.length === basics.length) return tr("Everyone");
if (!who.length) return tr("Admins only");
return who.map((p) => p.name).join(", ");
}
const ADMIN = {
async dashboards() {
await loadPeople();
const cards = store.dashboards.map((d) => {
const widgets = d.tabs.reduce((n, t) => n + t.widgets.length, 0);
const bg = /^#|gradient/.test(d.theme?.bg || "") && d.theme.preset !== "System" ? d.theme.bg : "var(--fill)";
return `<article class="adm-card" data-dash="${d.id}">
<div class="ac-top"><div class="ac-swatch" style="background:${esc(bg)}">${Icons.svg(d.mobile ? "phone" : "layout")}</div>
<div class="grow"><h3>${esc(d.name)}</h3><div class="muted small">${d.tabs.length === 1 ? tr("1 tab") : tr("{0} tabs", d.tabs.length)} · ${widgets === 1 ? tr("1 widget") : tr("{0} widgets", widgets)}${d.mobile ? ` · ${tr("Phone")}` : ""}</div></div></div>
<div class="ac-who">${Icons.svg("people")}<span>${esc(seenByText(d))}</span></div>
<div class="ac-actions"><button class="btn" data-a="open">${tr("Open")}</button><button class="btn primary" data-a="edit">${Icons.svg("edit")}${tr("Build")}</button>
<button class="btn icon" data-a="more" aria-label="${esc(tr("More for {0}", d.name))}">${Icons.svg("more")}</button></div>
</article>`;
}).join("");
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="lovelace">${Icons.svg("import")}${tr("Import from Home Assistant")}</button></div></header>
<div class="adm-cards">${cards}</div>`;
},
async people() {
const list = await loadPeople(true);
const acc = store.access;
const dashChecks = (key, entry) => store.dashboards.map((d) => `<label class="chip-check"><input type="checkbox" data-k="dash" data-who="${key}" value="${d.id}" ${entry.all || (entry.dashboards || []).includes(d.id) ? "checked" : ""} ${entry.all ? "disabled" : ""}><span>${esc(d.name)}</span></label>`).join("");
const homeSel = (key, entry) => `<select class="input" data-k="home" data-who="${key}" aria-label="${tr("Opens first")}"><option value="">${tr("The first one")}</option>${store.dashboards.filter((d) => entry.all || (entry.dashboards || []).includes(d.id)).map((d) => `<option value="${d.id}" ${entry.home === d.id ? "selected" : ""}>${esc(d.name)}</option>`).join("")}</select>`;
const accessBlock = (key, entry, own) => `<div class="p-access">
<div class="seg seg-sm" role="radiogroup" aria-label="${tr("Sees")}">
${key !== "everyone" ? `<button data-k="mode" data-who="${key}" data-v="same" aria-pressed="${!own}">${tr("Like everyone else")}</button>` : ""}
<button data-k="mode" data-who="${key}" data-v="all" aria-pressed="${(own || key === "everyone") && !!entry.all}">${tr("All dashboards")}</button>
<button data-k="mode" data-who="${key}" data-v="some" aria-pressed="${(own || key === "everyone") && !entry.all}">${tr("Only these")}</button></div>
${(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 rows = list.map((p) => {
const own = acc.users[p.id];
const entry = own || acc.everyone || { all: true };
return `<article class="person" data-user="${esc(p.id)}">
<div class="p-head"><span class="adm-avatar ${p.admin ? "admin" : ""}">${initials(p.name)}</span>
<div class="grow"><div class="p-name">${esc(p.name)} ${p.admin ? `<span class="p-badge">${tr("Admin")}</span>` : ""}${me?.id === p.id ? ` <span class="muted small">(${tr("you")})</span>` : ""}</div>
<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)}
</article>`;
}).join("");
return `<header class="adm-head"><div><h2>${tr("People")}</h2>
<p class="muted">${tr("Home Assistant’s administrators are admins here: they see every dashboard and can build. Everyone else only sees the dashboards chosen here, full screen in kiosk mode, on any device.")}</p></div></header>
<article class="person everyone"><div class="p-head"><span class="adm-avatar">${Icons.svg("people")}</span>
<div class="grow"><div class="p-name">${tr("Everyone else")}</div><div class="muted small">${tr("People without their own choice below, and anyone added to Home Assistant later.")}</div></div></div>
${accessBlock("everyone", acc.everyone || { all: true }, true)}</article>
${rows || `<p class="muted">${tr("Couldn’t read Home Assistant’s people.")}</p>`}
<p class="muted small">${tr("To add someone, create a person with a login in Home Assistant (Settings, People). Turn off Administrator there to make them a basic user.")}</p>`;
},
async devices() {
[devicesSeen] = await Promise.all([api("api/devices").catch(() => ({})), loadPeople()]);
const a = store.assign.devices;
const ids = [...new Set([DEVICE_ID, ...Object.keys(devicesSeen), ...Object.keys(a)])]
.sort((x, y) => (x === DEVICE_ID ? -1 : y === DEVICE_ID ? 1 : (devicesSeen[y]?.seen || 0) - (devicesSeen[x]?.seen || 0)));
const opts = (val) => `<option value="">${tr("The person’s choice")}</option>` + store.dashboards.map((d) => `<option value="${d.id}" ${d.id === val ? "selected" : ""}>${esc(d.name)}</option>`).join("");
const rows = ids.map((id) => {
const seen = devicesSeen[id] || {}, set = a[id] || {};
const icon = { phone: "phone", tablet: "tv", computer: "laptop" }[seen.kind] || "laptop";
const what = [seen.display || (id === DEVICE_ID ? me?.display : ""), seen.app === "haven" ? "Haven" : seen.app ? tr("Browser") : "", seen.seen ? ago(seen.seen) : ""].filter(Boolean).join(" · ");
return `<div class="dev-row" data-dev="${id}"><span class="adm-avatar">${Icons.svg(icon)}</span>
<div class="grow"><input class="input" data-k="name" value="${esc(set.name || "")}" placeholder="${id === DEVICE_ID ? tr("Name this device, e.g. Kitchen tablet") : tr("Unnamed device")}" aria-label="${tr("Device name")}">
<div class="muted small">${id === DEVICE_ID ? `<b>${tr("This device")}</b> · ` : ""}${esc(what)}</div></div>
<select class="input" data-k="dashboard" aria-label="${tr("Dashboard")}">${opts(set.dashboard)}</select>
<label class="assign-kiosk" title="${tr("Admins on this device start in kiosk mode too")}"><input type="checkbox" data-k="kiosk" ${set.kiosk ? "checked" : ""}> ${tr("Kiosk")}</label>
${id === DEVICE_ID ? `<span class="dev-gap"></span>` : `<button class="btn icon" data-a="forget" aria-label="${tr("Forget this device")}">${Icons.svg("trash")}</button>`}</div>`;
}).join("");
const named = ids.filter((id) => a[id]?.name);
return `<header class="adm-head"><div><h2>${tr("Devices")}</h2>
<p class="muted">${tr("Every phone, tablet and computer that opened Dashboard Studio. Give a wall tablet its own dashboard: that comes before the person’s choice.")}</p></div></header>
<div class="adm-list">${rows}</div>
<section class="adm-panel"><h3>${Icons.svg("megaphone")}${tr("Send a message to the screens")}</h3>
<p class="muted small">${tr("It shows full screen on dashboards in kiosk mode, like an announcement from an automation.")}</p>
<div class="an-form"><input class="input" id="an-title" placeholder="${tr("Title, e.g. Dinner")}" aria-label="${tr("Title")}">
<input class="input" id="an-msg" placeholder="${tr("Message, e.g. Dinner is ready!")}" aria-label="${tr("Message")}">
<select class="input" id="an-to" aria-label="${tr("To")}"><option value="">${tr("All screens")}</option>${named.map((id) => `<option value="${id}">${esc(a[id].name)}</option>`).join("")}</select>
<button class="btn primary" data-a="announce">${tr("Send")}</button></div></section>`;
},
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>
<p class="muted">${tr("Haven is the Android app for the people in your home. It only shows dashboards: no builder and no settings. Everyone sees what you chose for them under People.")}</p></div></header>
<div class="haven-grid">
<section class="adm-panel haven-step"><div class="hs-num">1</div><h3>${tr("Install Haven on the phone")}</h3>
<p class="muted small">${tr("Scan this code with the phone’s camera, or download it on the phone itself. Android asks once to allow apps from this source.")}</p>
<div class="share-qr">${window.QR.svg(HAVEN_DOWNLOAD, { label: tr("QR code to download Haven") })}</div>
<a class="btn" href="${new URL("haven.apk", BASE).href}" download="Haven.apk">${Icons.svg("arrow-down")}${tr("Download Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">2</div><h3>${tr("Connect to Home Assistant")}</h3>
<p class="muted small">${tr("Open Haven and enter this address. It is the address you use for Home Assistant; on other networks use its outside address, for example Home Assistant Cloud.")}</p>
<div class="share-row"><input class="input" id="haven-addr" readonly value="${esc(addr)}" aria-label="${tr("Address")}"><button class="btn" data-a="copy-addr">${Icons.svg("copy")}${tr("Copy")}</button></div>
<p class="muted small">${tr("On the phone itself, Open in Haven fills it in.")}</p>
<a class="btn" target="_top" href="${havenIntent()}">${tr("Open in Haven")}</a></section>
<section class="adm-panel haven-step"><div class="hs-num">3</div><h3>${tr("Sign in")}</h3>
<p class="muted small">${tr("Everyone signs in with their own Home Assistant login, once. Haven then opens straight on their dashboard and stays signed in.")}</p></section>
</div>
<p class="muted small">${tr("Haven needs Android 7 or newer. On iPhone, use the Home Assistant app instead.")}</p>`;
},
async activity() {
const rows = await api("api/activity").catch(() => []);
const when = (at) => new Date(at * 1000).toLocaleString(LOCALE, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
return `<header class="adm-head"><div><h2>${tr("Activity")}</h2><p class="muted">${tr("Who changed which dashboard. Restore an earlier version from the dashboard’s version history.")}</p></div></header>
<div class="adm-list">${rows.map((r) => `<div class="act-row" ${r.deleted ? "" : `data-dash="${r.dashboard}"`}>
<span class="adm-avatar">${r.by ? initials(r.by) : Icons.svg("edit")}</span>
<div class="grow"><div>${r.by ? tr("{0} changed {1}", `<b>${esc(r.by)}</b>`, `<b>${esc(r.name)}</b>`) : tr("{0} was changed", `<b>${esc(r.name)}</b>`)}${r.deleted ? ` <span class="muted small">(${tr("deleted")})</span>` : ""}</div>
<div class="muted small">${esc(when(r.at))} · ${esc(ago(r.at))}</div></div>
${r.deleted ? "" : `<button class="btn" data-a="history">${tr("Versions")}</button>`}</div>`).join("") || `<p class="muted">${tr("No changes yet.")}</p>`}</div>`;
},
async about() {
return `<header class="adm-head"><div><h2>${tr("About")}</h2></div></header>
<section class="adm-panel"><h3>Dashboard Studio ${esc(APP_VERSION)}</h3>
<p class="muted">${tr("You’re signed in as {0}, a Home Assistant administrator, so you can use the Dashboard, Builder and Admin screens.", esc(me?.display || me?.name || ""))}</p>
<div class="modal-actions" style="justify-content:flex-start"><button class="btn" data-a="whatsnew">${Icons.svg("sparkles")}${tr("What’s new")}</button>
<a class="btn" href="${REPO_URL}" target="_blank" rel="noopener">${Icons.svg("globe")}${tr("Source code")}</a></div></section>`;
},
};
async function renderAdmin(section = adminSection) {
adminSection = section;
lsSet("ds.admin", section);
$("#admin .adm-nav").innerHTML = ADMIN_SECTIONS.map(([k, icon, label]) => `<button role="tab" data-sec="${k}" aria-selected="${k === section}" class="${k === section ? "on" : ""}">${Icons.svg(icon)}<span>${tr(label)}</span></button>`).join("");
const body = $("#admin .adm-body");
const html = await ADMIN[section]();
if (adminSection !== section || mode !== "admin") return; // moved on while it loaded
const y = body.scrollTop;
body.innerHTML = html;
body.scrollTop = y;
}
/** Change what a person (or "everyone") may see. */
function setAccess(who, fn) {
const acc = store.access;
if (who === "everyone") acc.everyone = fn({ all: true, ...(acc.everyone || {}) });
else { const v = fn(acc.users[who] ? { ...acc.users[who] } : { ...(acc.everyone || { all: true }) }); if (v) acc.users[who] = v; else delete acc.users[who]; }
scheduleSave();
renderAdmin("people");
}
/** Admin → People → Preview: the dashboards this person sees, as they see them. */
function startPreview(p) {
const ids = visibleFor(p.id);
if (!ids.length) { toast(tr("{0} can’t see any dashboard yet.", p.name), true); return; }
const home = accessFor(p.id).home;
previewAs = { ...p, ids };
mode = "dashboard";
syncMode();
setCurrent(ids.includes(home) ? home : ids[0]);
setEdit(false);
const bar = $("#preview-bar");
bar.querySelector(".pv-text").textContent = tr("Previewing as {0}: {1} dashboards. On their devices it’s full screen, in kiosk mode.", p.name, ids.length);
bar.hidden = false;
document.body.classList.add("previewing");
}
$("#pv-stop").onclick = () => {
previewAs = null;
$("#preview-bar").hidden = true;
document.body.classList.remove("previewing");
setMode("admin");
renderAdmin("people");
};
$("#admin").addEventListener("click", async (e) => {
const sec = e.target.closest("[data-sec]");
if (sec) { renderAdmin(sec.dataset.sec); $("#admin .adm-body").scrollTop = 0; return; }
const k = e.target.closest("button[data-k]");
if (k?.dataset.k === "mode") {
const v = k.dataset.v;
setAccess(k.dataset.who, (cur) => (v === "same" ? null : v === "all" ? { all: true, home: cur.home } : { all: false, dashboards: cur.all ? store.dashboards.map((d) => d.id) : cur.dashboards || [], home: cur.home }));
return;
}
const b = e.target.closest("[data-a]");
if (!b) return;
const act = b.dataset.a, card = b.closest("[data-dash]"), dash = card?.dataset.dash;
if (act === "open" || act === "edit") { setCurrent(dash); setMode(act === "edit" ? "builder" : "dashboard"); return; }
if (act === "more") {
const d = store.dashboards.find((x) => x.id === dash);
const pick = await openModal(`<h3>${esc(d.name)}</h3><div class="act-list">
${[["rename", "edit", "Rename…"], ["duplicate", "copy", "Duplicate"], ["history", "history", "Version history…"], ["share", "share", "Share…"], ["export", "exit", "Export as JSON"], ["delete", "trash", "Delete dashboard…"]]
.map(([a, i, l]) => `<button class="btn ${a === "delete" ? "danger" : ""}" data-x="${a}">${Icons.svg(i)}${tr(l)}</button>`).join("")}</div>
<div class="modal-actions"><button class="btn" data-x="close">${tr("Close")}</button></div>`,
(ev, close) => { const x = ev.target.closest("[data-x]")?.dataset.x; if (x) close(x === "close" ? null : x); });
if (!pick) return;
setCurrent(dash);
await dashboardAction(pick);
if (mode === "admin") renderAdmin();
return;
}
if (act === "new") { $("#btn-new").click(); return; }
if (["starter", "import", "lovelace"].includes(act)) { await dashboardAction(act); if (act === "starter") renderAdmin(); return; }
if (act === "preview") { const p = peopleList.find((x) => x.id === b.closest("[data-user]").dataset.user); if (p) startPreview(p); return; }
if (act === "forget") {
const id = b.closest("[data-dev]").dataset.dev;
delete store.assign.devices[id];
scheduleSave();
await fetch(new URL(`api/devices/${encodeURIComponent(id)}`, BASE), { method: "DELETE" }).catch(() => {});
renderAdmin("devices");
return;
}
if (act === "announce") {
const message = $("#an-msg").value.trim();
if (!message) { $("#an-msg").focus(); return; }
const to = $("#an-to").value;
await ha.send({ type: "fire_event", event_type: "dashboard_studio_announce", event_data: { title: $("#an-title").value.trim() || undefined, message, ...(to ? { device: to } : {}) } })
.then(() => { toast(tr("Sent.")); $("#an-msg").value = ""; })
.catch((err) => toast(tr("Couldn’t send it: {0}", err.message), true));
return;
}
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();
});
$("#admin").addEventListener("change", (e) => {
const f = e.target, k = f.dataset.k;
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;
if (!d.name && !d.dashboard && !d.kiosk) delete store.assign.devices[id];
scheduleSave();
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 }));
} else if (k === "home") setAccess(f.dataset.who, (cur) => ({ ...cur, home: f.value || undefined }));
});
// device names save while typing
$("#admin").addEventListener("input", (e) => {
const f = e.target;
if (f.dataset.k !== "name" || !f.closest("[data-dev]")) return;
const id = f.closest("[data-dev]").dataset.dev;
(store.assign.devices[id] ||= {}).name = f.value;
scheduleSave();
});
// ------------------------------------------------------------ boot
function paintChromeIcons() {
$("#btn-new").innerHTML = Icons.svg("plus");
@@ -3237,7 +3553,7 @@
$("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk"));
$("#btn-app").innerHTML = Icons.svg("phone");
$("#phone-menu").innerHTML = Icons.svg("more");
document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
document.querySelectorAll("#menu [data-icon], #mode-seg [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
}
ha.onStatus = (ok) => {
@@ -3266,6 +3582,26 @@
setTimeout(() => location.reload(), 2500);
}
setInterval(checkVersion, 5 * 60 * 1000);
// 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]);
async function refreshStore() {
if (editMode || mode === "admin" || savePending || previewAs || modalClose) return;
let next;
try { next = await api(`api/dashboards?device=${DEVICE_ID}`); } catch { return; }
const key = keyOf(next);
if (key === storeKey) return;
storeKey = key;
if (!$("#empty-note").hidden || (!isAdmin() && !next.dashboards?.length)) { location.reload(); return; }
next.dashboards = next.dashboards.map(migrate);
next.assign = { devices: {}, users: {}, ...(next.assign || {}) };
store = next;
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);
}
setInterval(refreshStore, 60 * 1000);
ha.onChange = onStateChange;
ha.onRegistry = () => { renderGrid(); if (editMode) { renderInspector(); renderPalette(); } };
@@ -3276,18 +3612,27 @@
}
async function boot() {
document.body.classList.toggle("in-haven", IN_APP);
translateStatic();
paintChromeIcons();
renderPalette();
await keepIngressSession();
// 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(() => {});
me = await api("api/whoami").catch(() => null);
if (me) { role = me.admin ? "admin" : "basic"; lsSet("ds.role", role); }
try {
const r = await fetch(new URL("api/dashboards", BASE));
const r = await fetch(new URL(`api/dashboards?device=${DEVICE_ID}`, BASE));
store = await r.json();
if (store.role) role = store.role;
store.dashboards = (store.dashboards || []).map(migrate);
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" },
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) => {
const prev = ha.onStatus;
@@ -3296,19 +3641,27 @@
});
ha.connect();
if (isAdmin()) migrateAccess();
storeKey = keyOf(store);
if (!store.dashboards?.length) {
if (!isAdmin()) { showEmpty(); return; }
await firstReady;
store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } };
store = { ...store, version: 1, dashboards: [starterDashboard()] };
save();
}
me = await api("api/whoami").catch(() => null);
const shared = sharedParams();
const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1] || shared.get("ds");
// Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here.
const dev = store.assign.devices[DEVICE_ID], forUser = me?.id && store.assign.users[me.id];
const dev = store.assign.devices[DEVICE_ID], home = isAdmin() ? store.access?.users?.[me?.id]?.home : store.access?.me?.home;
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
// The screen it was on last time; people who aren't admins, and Haven, only get the dashboard.
const lastMode = lsGet("ds.mode") || (lsGet("ds.edit") === "1" ? "builder" : "dashboard");
mode = isAdmin() && !IN_APP ? lastMode : "dashboard";
editMode = mode === "builder";
setCurrent([hashId, dev?.dashboard, home, lsGet("ds.current")].find(exists));
if (!isAdmin()) { lockedKiosk = true; enterKiosk({ fullscreen: false }); }
else if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
else setMode(mode);
const action = shared.get("ds_action");
if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// After an update, point at what changed (once per device).
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#245268"/><stop offset="1" stop-color="#0d1d29"/></linearGradient>
<linearGradient id="door" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffe2a0"/><stop offset="1" stop-color="#f4a531"/></linearGradient>
<clipPath id="house"><path d="M136 240 L256 140 L376 240 V388 a22 22 0 0 1 -22 22 H158 a22 22 0 0 1 -22 -22 Z"/></clipPath>
<filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="16"/></filter>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<g id="art">
<path d="M136 240 L256 140 L376 240 V388 a22 22 0 0 1 -22 22 H158 a22 22 0 0 1 -22 -22 Z" fill="#f4eee4"/>
<path d="M100 262 L256 130 L412 262" fill="none" stroke="#f4eee4" stroke-width="30" stroke-linecap="round" stroke-linejoin="round"/>
<g clip-path="url(#house)"><ellipse cx="256" cy="352" rx="62" ry="76" fill="#ffb547" opacity=".55" filter="url(#blur)"/></g>
<path d="M214 410 V322 a42 42 0 0 1 84 0 V410 Z" fill="url(#door)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
+79
View File
@@ -625,6 +625,85 @@
"Open in the app": "Openen in de app",
"On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead.": "Op iPhone en iPad kunnen apps op het beginscherm de inlog van Home Assistant niet gebruiken, dus daar kan Dashboard Studio niet los draaien. Open het in plaats daarvan via de zijbalk in de Home Assistant-app.",
"Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again.": "Browsers installeren alleen apps vanaf een beveiligd adres (https), en Home Assistant is hier via http geopend. Open Home Assistant via het https-adres, bijvoorbeeld Home Assistant Cloud, en probeer het opnieuw.",
"This is Haven, connected to {0}.": "Dit is Haven, verbonden met {0}.",
"Get Haven, the Android app for your dashboards. It opens full screen with its own icon and keeps you signed in.": "Gebruik Haven, de Android-app voor je dashboards. Hij opent schermvullend met een eigen pictogram en houdt je ingelogd.",
"Download Haven and open the file to install it. Android asks once to allow apps from this source.": "Download Haven en open het bestand om het te installeren. Android vraagt één keer of apps uit deze bron zijn toegestaan.",
"Come back here and tap Open in Haven. Or open Haven and enter the address of Home Assistant.": "Kom hier terug en tik op Openen in Haven. Of open Haven en vul het adres van Home Assistant in.",
"Or install it from the browser, without Haven.": "Of installeer het vanuit de browser, zonder Haven.",
"Download Haven": "Haven downloaden",
"Open in Haven": "Openen in Haven",
"For Android phones there is Haven: see Admin, Haven app.": "Voor Android-telefoons is er Haven: zie Beheer, Haven-app.",
"Dashboards": "Dashboards",
"This is the only dashboard you can see. An admin can add more for you.": "Dit is het enige dashboard dat je kunt zien. Een beheerder kan er meer voor je toevoegen.",
"An admin hasn’t chosen a dashboard for you yet. Ask someone who manages Home Assistant to pick one for you on the Admin screen in Dashboard Studio.": "Een beheerder heeft nog geen dashboard voor je gekozen. Vraag iemand die Home Assistant beheert om er een voor je te kiezen op het beheerscherm van Dashboard Studio.",
"never": "nooit",
"Admins": "Beheerders",
"Everyone": "Iedereen",
"Admins only": "Alleen beheerders",
"1 tab": "1 tabblad",
"1 widget": "1 widget",
"Build": "Bouwen",
"More for {0}": "Meer voor {0}",
"Build a dashboard, then choose under People who sees it.": "Bouw een dashboard en kies daarna onder Personen wie het ziet.",
"Import file": "Bestand importeren",
"Opens first": "Opent eerst",
"The first one": "Het eerste",
"Sees": "Ziet",
"Like everyone else": "Zoals iedereen",
"All dashboards": "Alle dashboards",
"Only these": "Alleen deze",
"Admin": "Beheer",
"Home Assistant administrator · sees and builds everything": "Beheerder in Home Assistant · ziet en bouwt alles",
"Sees {0} · always in kiosk mode": "Ziet {0} · altijd in kioskmodus",
"all dashboards": "alle dashboards",
"{0} of {1} dashboards": "{0} van {1} dashboards",
"last opened {0}": "laatst geopend {0}",
"Preview": "Voorbeeld",
"Home Assistant’s administrators are admins here: they see every dashboard and can build. Everyone else only sees the dashboards chosen here, full screen in kiosk mode, on any device.": "De beheerders van Home Assistant zijn hier beheerder: ze zien elk dashboard en kunnen bouwen. Alle anderen zien alleen de dashboards die je hier kiest, schermvullend in kioskmodus, op elk apparaat.",
"Everyone else": "Alle anderen",
"People without their own choice below, and anyone added to Home Assistant later.": "Mensen zonder eigen keuze hieronder, en iedereen die later aan Home Assistant wordt toegevoegd.",
"Couldn’t read Home Assistant’s people.": "De mensen in Home Assistant konden niet worden gelezen.",
"To add someone, create a person with a login in Home Assistant (Settings, People). Turn off Administrator there to make them a basic user.": "Om iemand toe te voegen maak je in Home Assistant een persoon met een account (Instellingen, Personen). Zet Beheerder daar uit om er een gewone gebruiker van te maken.",
"The person’s choice": "Keuze van de persoon",
"Browser": "Browser",
"Admins on this device start in kiosk mode too": "Beheerders starten op dit apparaat ook in kioskmodus",
"Every phone, tablet and computer that opened Dashboard Studio. Give a wall tablet its own dashboard: that comes before the person’s choice.": "Elke telefoon, tablet en computer die Dashboard Studio heeft geopend. Geef een wandtablet een eigen dashboard: dat gaat voor de keuze van de persoon.",
"Send a message to the screens": "Een bericht naar de schermen sturen",
"It shows full screen on dashboards in kiosk mode, like an announcement from an automation.": "Het verschijnt schermvullend op dashboards in kioskmodus, net als een aankondiging uit een automatisering.",
"Title, e.g. Dinner": "Titel, bijv. Eten",
"Message, e.g. Dinner is ready!": "Bericht, bijv. Het eten is klaar!",
"Message": "Bericht",
"To": "Aan",
"Send": "Versturen",
"Haven app": "Haven-app",
"Haven is the Android app for the people in your home. It only shows dashboards: no builder and no settings. Everyone sees what you chose for them under People.": "Haven is de Android-app voor de mensen in je huis. Het toont alleen dashboards: geen bouwer en geen instellingen. Iedereen ziet wat je onder Personen voor ze hebt gekozen.",
"Install Haven on the phone": "Haven op de telefoon installeren",
"Scan this code with the phone’s camera, or download it on the phone itself. Android asks once to allow apps from this source.": "Scan deze code met de camera van de telefoon, of download het op de telefoon zelf. Android vraagt één keer of apps uit deze bron zijn toegestaan.",
"QR code to download Haven": "QR-code om Haven te downloaden",
"Connect to Home Assistant": "Verbinden met Home Assistant",
"Open Haven and enter this address. It is the address you use for Home Assistant; on other networks use its outside address, for example Home Assistant Cloud.": "Open Haven en vul dit adres in. Het is het adres waarmee je Home Assistant opent; gebruik buitenshuis het externe adres, bijvoorbeeld Home Assistant Cloud.",
"Address": "Adres",
"On the phone itself, Open in Haven fills it in.": "Op de telefoon zelf vult Openen in Haven het in.",
"Sign in": "Inloggen",
"Everyone signs in with their own Home Assistant login, once. Haven then opens straight on their dashboard and stays signed in.": "Iedereen logt één keer in met zijn eigen Home Assistant-account. Haven opent daarna meteen op het eigen dashboard en blijft ingelogd.",
"Haven needs Android 7 or newer. On iPhone, use the Home Assistant app instead.": "Haven werkt op Android 7 of nieuwer. Gebruik op een iPhone de Home Assistant-app.",
"Activity": "Activiteit",
"Who changed which dashboard. Restore an earlier version from the dashboard’s version history.": "Wie welk dashboard heeft gewijzigd. Zet een eerdere versie terug via de versiegeschiedenis van het dashboard.",
"{0} changed {1}": "{0} wijzigde {1}",
"{0} was changed": "{0} is gewijzigd",
"deleted": "verwijderd",
"Versions": "Versies",
"No changes yet.": "Nog geen wijzigingen.",
"You’re signed in as {0}, a Home Assistant administrator, so you can use the Dashboard, Builder and Admin screens.": "Je bent ingelogd als {0}, beheerder in Home Assistant, dus je kunt de schermen Dashboard, Bouwer en Beheer gebruiken.",
"{0} can’t see any dashboard yet.": "{0} kan nog geen enkel dashboard zien.",
"Previewing as {0}: {1} dashboards. On their devices it’s full screen, in kiosk mode.": "Voorbeeld als {0}: {1} dashboards. Op hun apparaten is het schermvullend, in kioskmodus.",
"Sent.": "Verstuurd.",
"Couldn’t send it: {0}": "Versturen lukte niet: {0}",
"Copied.": "Gekopieerd.",
"Builder": "Bouwer",
"Stop preview": "Voorbeeld stoppen",
"Nothing to show yet": "Nog niets te zien",
"Screen": "Scherm",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
+13 -10
View File
@@ -40,24 +40,19 @@
<button id="btn-guest" class="btn view-only" hidden><span data-i18n>Guest mode</span></button>
<button id="btn-app" class="btn icon view-only" aria-label="Install as an app" title="Install as an app"></button>
<button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button>
<button id="btn-edit" class="btn primary" data-i18n>Edit</button>
<div id="mode-seg" class="mode-seg" role="tablist" aria-label="Screen" hidden>
<button role="tab" data-mode="dashboard" data-icon="layout"><span data-i18n>Dashboard</span></button>
<button role="tab" data-mode="builder" data-icon="edit"><span data-i18n>Builder</span></button>
<button role="tab" data-mode="admin" data-icon="shield"><span data-i18n>Admin</span></button>
</div>
</div>
<div id="menu" class="menu" role="menu" hidden>
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button>
<button role="menuitem" data-menu="duplicate" data-icon="copy" data-i18n>Duplicate</button>
<button role="menuitem" data-menu="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
<button role="menuitem" data-menu="suggest" data-icon="search" data-i18n>Devices not on a dashboard…</button>
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
<button role="menuitem" data-menu="history" data-icon="history" data-i18n>Version history…</button>
<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="install" data-icon="phone" data-i18n>Install as an app…</button>
<hr>
<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>
<button role="menuitem" data-menu="lovelace" data-icon="import" data-i18n>Import from Home Assistant…</button>
<hr>
<button role="menuitem" data-menu="delete" data-icon="trash" class="danger" data-i18n>Delete dashboard…</button>
</div>
</header>
@@ -77,6 +72,12 @@
<aside id="inspector" class="edit-only" aria-label="Inspector"></aside>
</div>
<!-- Admin: dashboards, people, devices, the Haven app, activity -->
<section id="admin" hidden aria-label="Admin">
<nav class="adm-nav" role="tablist" aria-label="Admin"></nav>
<div class="adm-body"></div>
</section>
</div>
<div id="edit-bar" class="edit-only"><button class="btn" id="eb-add"><span data-i18n>Add</span></button><button class="btn primary" id="eb-insp"></button></div>
@@ -84,6 +85,8 @@
<div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div>
<div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div>
<div id="night-dim" aria-hidden="true"></div>
<div id="preview-bar" hidden role="status"><span class="pv-text"></span><button class="btn" id="pv-stop"><span data-i18n>Stop preview</span></button></div>
<div id="empty-note" hidden role="status"><div class="en-card"><div class="en-ico"></div><h2 data-i18n>Nothing to show yet</h2><p class="en-text"></p></div></div>
<button id="phone-menu" class="btn icon" aria-label="Menu" title="Menu"></button>
<div id="offline-note" hidden role="status"></div>
<div id="ptr" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg></div>
+108
View File
@@ -1418,3 +1418,111 @@ body.kiosk.idle, body.kiosk.idle * { cursor: none !important; }
.hist-row { display: flex; align-items: center; gap: 12px; padding: 8px 4px; border-bottom: 1px solid var(--separator); }
.hist-row:last-child { border-bottom: 0; }
.hist-when { font-weight: 500; }
/* ============================================================ screens: Dashboard, Builder, Admin */
.mode-seg { display: flex; gap: 2px; padding: 2px; border-radius: 11px; background: var(--fill); }
.mode-seg[hidden] { display: none; }
.mode-seg button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; border-radius: 9px; min-height: 32px; padding: 0 12px; cursor: pointer; font-weight: 500; color: var(--text-2); }
.mode-seg button .i { font-size: 17px; }
.mode-seg button:hover { color: var(--text); }
.mode-seg button.on { background: var(--surface); color: var(--text); font-weight: 650; box-shadow: 0 1px 3px rgba(0, 0, 0, .14); }
.mode-seg button[data-mode="builder"].on { background: var(--accent); color: var(--on-accent); }
@media (max-width: 760px) { .mode-seg button span { display: none; } .mode-seg button { padding: 0 9px; } }
body.admin-mode #workspace, body.admin-mode #topbar .picker, body.admin-mode #btn-new, body.admin-mode #topbar .view-only, body.admin-mode #topbar .edit-only { display: none !important; }
body.admin-mode #topbar::before { content: "Dashboard Studio"; font-weight: 650; font-size: 17px; padding-left: 8px; }
/* ============================================================ admin */
#admin { position: absolute; inset: 0; display: flex; padding-top: 72px; background: var(--canvas); }
#admin[hidden] { display: none; }
.adm-nav { flex: none; width: 220px; padding: 14px 10px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--separator); background: var(--surface); overflow-y: auto; }
.adm-nav button { display: flex; align-items: center; gap: 10px; border: 0; background: transparent; border-radius: 10px; padding: 9px 12px; min-height: 40px; cursor: pointer; font-size: 15px; text-align: left; color: var(--text); }
.adm-nav button .i { font-size: 19px; color: var(--text-2); }
.adm-nav button:hover { background: var(--fill); }
.adm-nav button.on { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); font-weight: 650; }
.adm-nav button.on .i { color: var(--accent); }
.adm-body { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 28px 60px; }
.adm-body > * { max-width: 1040px; margin-left: auto; margin-right: auto; }
.adm-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; }
.adm-head h2 { font-size: 28px; font-weight: 700; margin: 0 0 4px; letter-spacing: -.01em; }
.adm-head p { margin: 0; max-width: 640px; }
.adm-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.adm-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.adm-card { background: var(--surface); border-radius: 16px; padding: 16px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow); border: 1px solid var(--separator); }
.ac-top { display: flex; gap: 12px; align-items: center; }
.ac-top h3 { margin: 0 0 2px; font-size: 17px; overflow-wrap: anywhere; }
.ac-swatch { width: 52px; height: 52px; flex: none; border-radius: 12px; display: grid; place-items: center; color: #fff; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .08); }
.ac-swatch .i { font-size: 22px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35)); }
.ac-who { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); }
.ac-who .i { font-size: 16px; flex: none; }
.ac-actions { display: flex; gap: 8px; margin-top: auto; }
.ac-actions .btn:not(.icon) { flex: 1; }
.adm-list { display: flex; flex-direction: column; gap: 10px; }
#admin .grow { flex: 1; min-width: 0; }
.person { background: var(--surface); border-radius: 16px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid var(--separator); }
.person.everyone { border-style: dashed; }
.p-head { display: flex; align-items: center; gap: 12px; }
.p-name { font-weight: 650; font-size: 16px; }
.p-badge { display: inline-block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 2px 7px; border-radius: 6px; background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); vertical-align: 2px; margin-left: 4px; }
.adm-avatar { width: 40px; height: 40px; flex: none; border-radius: 50%; display: grid; place-items: center; background: var(--fill); font-weight: 700; color: var(--text-2); }
.adm-avatar.admin { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
.adm-avatar .i { font-size: 19px; }
.p-access { margin: 12px 0 0 52px; display: flex; flex-direction: column; gap: 10px; }
.p-access .seg { max-width: 460px; }
.p-access .field { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 13px; color: var(--text-2); }
.p-access .field .input { max-width: 240px; }
.adm-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip-check { position: relative; }
.chip-check input { position: absolute; opacity: 0; pointer-events: none; }
.chip-check span { display: inline-flex; align-items: center; min-height: 32px; padding: 0 13px; border-radius: 99px; background: var(--fill); font-size: 13px; font-weight: 500; cursor: pointer; }
.chip-check input:checked + span { background: var(--accent); color: var(--on-accent); }
.chip-check input:checked + span::before { content: "✓"; margin-right: 6px; font-weight: 700; }
.chip-check input:disabled + span { opacity: .55; cursor: default; }
.chip-check input:focus-visible + span { box-shadow: var(--focus); }
.dev-row, .act-row { display: flex; align-items: center; gap: 12px; background: var(--surface); border-radius: 14px; padding: 10px 14px; border: 1px solid var(--separator); }
.dev-row .grow .input { max-width: 320px; font-weight: 600; }
.dev-row select.input { width: 200px; flex: none; }
.dev-gap { width: 36px; flex: none; }
.adm-panel { background: var(--surface); border-radius: 16px; padding: 18px; border: 1px solid var(--separator); margin-top: 18px; }
.adm-panel h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-size: 17px; }
.adm-panel h3 .i { color: var(--accent); }
.an-form { display: grid; grid-template-columns: 1fr 2fr 180px auto; gap: 8px; margin-top: 12px; }
.haven-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
.haven-grid .adm-panel { margin-top: 0; display: flex; flex-direction: column; gap: 6px; }
.haven-step .btn { align-self: flex-start; }
.hs-num { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; font-weight: 700; }
.act-list { display: flex; flex-direction: column; gap: 6px; }
.act-list .btn { justify-content: flex-start; }
.kp-dashes { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; margin: 6px 0 4px; }
.kp-dashes .btn { min-height: 56px; justify-content: flex-start; font-size: 16px; font-weight: 600; }
@media (max-width: 760px) {
#admin { flex-direction: column; }
.adm-nav { width: auto; flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--separator); padding: 8px; }
.adm-nav button { flex: none; padding: 8px 12px; }
.adm-body { padding: 18px 14px 60px; }
.adm-head h2 { font-size: 24px; }
.p-access { margin-left: 0; }
.dev-row { flex-wrap: wrap; }
.dev-row .grow { flex-basis: calc(100% - 52px); }
.dev-row select.input { flex: 1; width: auto; }
.an-form { grid-template-columns: 1fr; }
}
/* preview as someone else, and nothing to show */
#preview-bar { position: fixed; z-index: 70; left: 50%; bottom: calc(16px + env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; align-items: center; gap: 12px;
padding: 8px 8px 8px 16px; border-radius: 14px; background: rgba(28, 28, 30, .9); color: #fff; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0, 0, 0, .3); max-width: calc(100% - 24px); font-size: 14px; }
#preview-bar[hidden] { display: none; }
#preview-bar .btn { background: rgba(255, 255, 255, .16); color: #fff; flex: none; }
body.previewing #btn-new { display: none; }
#empty-note { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: var(--canvas); }
#empty-note[hidden] { display: none; }
.en-card { max-width: 420px; text-align: center; }
.en-ico { width: 72px; height: 72px; margin: 0 auto 16px; border-radius: 20px; display: grid; place-items: center; background: var(--fill); }
.en-ico .i { font-size: 34px; color: var(--text-2); }
.en-card h2 { margin: 0 0 8px; }
.en-card p { color: var(--text-2); margin: 0; }
body.empty #app { display: none; }
body.in-haven #btn-new { display: none; }
.haven-head { justify-content: flex-start; align-items: center; }
.haven-head img { width: 64px; height: 64px; border-radius: 16px; flex: none; box-shadow: 0 6px 18px rgba(0, 0, 0, .2); }
.haven-head > div { flex: 1; min-width: 240px; }
+3 -1
View File
@@ -21,7 +21,9 @@ self.addEventListener("fetch", (e) => {
return res;
}
if (!page) return res;
throw new Error(String(res.status));
// Signed out of Home Assistant: the last good copy renews the session itself. Without one, pass
// the refusal on, so Haven knows to sign in again.
return (await cache.match(new URL("./", self.registration.scope).href)) || res;
} catch (err) {
const hit = page ? await cache.match(new URL("./", self.registration.scope).href) : await cache.match(req);
if (hit) return hit;