Dashboard Studio 1.7.0: scroll and swipe cards, more themes, per-device dashboards

- Container cards: horizontal scroll (snap, arrows, keeps position) and
  swipe (dots, optional autoplay that survives redraws and respects
  reduced motion), with add/edit/reorder of the cards inside
- Nine new themes, contrast-checked, including High contrast; bright
  accents get dark text
- Choose which dashboard opens per device (optionally in kiosk mode)
  and per Home Assistant user, via /api/whoami and the ingress user
  headers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 23:21:24 +01:00
parent fa3e58b747
commit 75ab27db10
10 changed files with 377 additions and 24 deletions
+20 -1
View File
@@ -14,7 +14,7 @@ Keyboard shortcuts in edit mode: arrow keys move, Shift+arrows resize, ⌘D or C
## Look and feel ## Look and feel
- **Theme:** *System* follows your device's light or dark appearance. Light, Dark, Nord, Dusk and Forest are fixed themes. Changing any color switches the theme to *Custom*. - **Theme:** *System* follows your device's light or dark appearance. The other 14 themes are fixed: Light, Dark, Nord, Dusk, Forest, Graphite, Indigo, Rose, Ocean, Sunrise, Lavender, Mint, Sand, and High contrast for maximum readability. Text meets accessibility contrast levels in every theme. Changing any color switches the theme to *Custom*.
- **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**. - **Tile style:** *Cards* puts every widget on a tile. *Outlined* draws a thin border instead. *Plain* removes the cards completely, so widgets sit straight on the background. You can also turn off the background for a single widget under **Style → No background**.
- **Lights that are on** glow: the tile turns warm, or takes on the bulb's color for color lights. In Plain style, the light's icon and brightness bar glow instead. - **Lights that are on** glow: the tile turns warm, or takes on the bulb's color for color lights. In Plain style, the light's icon and brightness bar glow instead.
- **Icons:** widgets pick an icon automatically. To change one, type an icon name (the field suggests them) or paste an emoji. - **Icons:** widgets pick an icon automatically. To change one, type an icon name (the field suggests them) or paste an emoji.
@@ -49,6 +49,15 @@ Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and s
Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details. Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details.
### Horizontal scroll and swipe cards
These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it.
- **Horizontal scroll** puts its cards in a row you can scroll sideways, by touch, trackpad or the arrow buttons that appear on hover. Set the width of the cards and an optional title. The row keeps its scroll position when something inside it changes.
- **Swipe cards** shows one card at a time: swipe, or tap the dots underneath. It can move to the next card automatically every few seconds. Touching it pauses that for 20 seconds, and it stops completely when the system's "reduce motion" setting is on.
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off.
### Media players ### Media players
One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher. One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher.
@@ -121,6 +130,16 @@ Dashboard Studio is available in English and Dutch (Nederlands). By default it f
Use **+** for a new dashboard. The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install. Use **+** for a new dashboard. The **⋯** menu (in edit mode) can rename, duplicate, export, import or delete a dashboard. Exported JSON files can be imported on another Home Assistant install.
## Which dashboard opens where
Use **⋯ → Who sees which dashboard…** in edit mode to choose the dashboard that opens first:
- **This device:** give the device a name, for example *Kitchen tablet*, and pick its dashboard. Tick **Kiosk** to have it start in kiosk mode.
- **Other devices** you have named appear in the list too, so you can change them from anywhere.
- **People:** pick a dashboard per Home Assistant user. People appear once they've opened Dashboard Studio, or when they have a person entity.
A device's choice comes before a person's. Without either, the last dashboard used on that device opens. A link with `#view=…` always wins. You can still switch dashboards at any time.
## Version and updates ## Version and updates
The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted. The version you're running is shown at the bottom of the dashboard settings, under **About** (deselect all widgets in edit mode). After Home Assistant installs an update, screens that are open reload themselves within a few minutes, which is handy for wall tablets. While you're editing, you get a message instead, so nothing you're working on is interrupted.
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "1.6.1" version: "1.7.0"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+36
View File
@@ -107,6 +107,40 @@ async def index(request):
return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store"}) return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store"})
USERS_FILE = DATA_DIR / "users.json"
def who(request):
"""The Home Assistant user, from the headers the Supervisor's ingress proxy adds."""
if MOCK:
return {"id": os.environ.get("MOCK_USER_ID", "mock-user-alex"), "name": "alex", "display": "Alex"}
h = request.headers
uid = h.get("X-Remote-User-Id")
return {"id": uid, "name": h.get("X-Remote-User-Name", ""), "display": h.get("X-Remote-User-Display-Name", "")} if uid else None
def load_users():
try:
return json.loads(USERS_FILE.read_text())
except (OSError, json.JSONDecodeError):
return {}
async def whoami(request):
me = who(request)
if me: # remember who has used the app, so dashboards can be assigned to them
users = load_users()
if users.get(me["id"], {}).get("display") != me["display"] or me["id"] not in users:
users[me["id"]] = {"name": me["name"], "display": me["display"]}
DATA_DIR.mkdir(parents=True, exist_ok=True)
USERS_FILE.write_text(json.dumps(users, indent=2))
return web.json_response(me or {}, headers={"Cache-Control": "no-store"})
async def users(request):
return web.json_response([{"id": k, **v} for k, v in load_users().items()], headers={"Cache-Control": "no-store"})
async def version(request): async def version(request):
return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"}) return web.json_response({"version": VERSION}, headers={"Cache-Control": "no-store"})
@@ -214,6 +248,8 @@ def make_app():
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024) app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
app.router.add_get("/", index) app.router.add_get("/", index)
app.router.add_get("/api/version", version) app.router.add_get("/api/version", version)
app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/users", users)
app.router.add_get("/api/dashboards", get_dashboards) app.router.add_get("/api/dashboards", get_dashboards)
app.router.add_put("/api/dashboards", put_dashboards) app.router.add_put("/api/dashboards", put_dashboards)
app.router.add_get("/api/camera/{entity_id}", camera) app.router.add_get("/api/camera/{entity_id}", camera)
+2 -1
View File
@@ -74,7 +74,8 @@ class MockHA:
entity("script.good_morning", "off", friendly_name="Good Morning"), entity("script.good_morning", "off", friendly_name="Good Morning"),
entity("weather.home", "partlycloudy", friendly_name="Home", temperature=13, humidity=71, wind_speed=14), entity("weather.home", "partlycloudy", friendly_name="Home", temperature=13, humidity=71, wind_speed=14),
entity("camera.front_yard", "idle", friendly_name="Front Yard"), entity("camera.front_yard", "idle", friendly_name="Front Yard"),
entity("person.alex", "home", friendly_name="Alex"), entity("person.alex", "home", friendly_name="Alex", user_id="mock-user-alex"),
entity("person.sam", "not_home", friendly_name="Sam", user_id="mock-user-sam"),
entity("input_boolean.guest_mode", "off", friendly_name="Guest Mode"), entity("input_boolean.guest_mode", "off", friendly_name="Guest Mode"),
entity("calendar.family", "off", friendly_name="Family"), entity("calendar.family", "off", friendly_name="Family"),
entity("calendar.work", "on", friendly_name="Work"), entity("calendar.work", "on", friendly_name="Work"),
+167 -18
View File
@@ -19,6 +19,7 @@
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } }; const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } }; const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } };
const prefersDark = () => matchMedia("(prefers-color-scheme: dark)").matches; const prefersDark = () => matchMedia("(prefers-color-scheme: dark)").matches;
const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
// ------------------------------------------------------------ HA connection // ------------------------------------------------------------ HA connection
class HA { class HA {
@@ -131,14 +132,24 @@
// ------------------------------------------------------------ themes // ------------------------------------------------------------ themes
// "System" keeps the adaptive light/dark tokens from style.css. // "System" keeps the adaptive light/dark tokens from style.css.
const COLOR_KEYS = ["bg", "tile", "text", "text2", "accent", "separator", "fill", "lit"]; const COLOR_KEYS = ["bg", "tile", "text", "text2", "accent", "onAccent", "separator", "fill", "lit"];
const PRESETS = { const PRESETS = {
System: { swatch: ["#f2f2f7", "#1c1c1e"] }, System: { swatch: ["#f2f2f7", "#1c1c1e"] },
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 }, 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 }, 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 }, 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 },
Forest: { bg: "#0e1a14", tile: "#17271e", text: "#e6f2ea", text2: "#a9c2b2", accent: "#5fd38d", separator: "rgba(230,242,234,0.12)", fill: "rgba(230,242,234,0.1)", lit: "#f1e6b8", shadow: false, blur: 0 }, 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 },
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 },
"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 },
}; };
const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true }; const DEFAULT_THEME = { preset: "System", surface: "cards", radius: 20, bgImage: "", blur: 0, shadow: true };
@@ -170,6 +181,7 @@
const cfg = {}; const cfg = {};
for (const f of def.fields) if ("d" in f) cfg[f.k] = typeof f.d === "string" && f.t !== "seg" ? tr(f.d) : f.d; for (const f of def.fields) if ("d" in f) cfg[f.k] = typeof f.d === "string" && f.t !== "seg" ? tr(f.d) : f.d;
if (def.fields.some((f) => f.t === "entities")) cfg.entities = []; if (def.fields.some((f) => f.t === "entities")) cfg.entities = [];
if (def.container) cfg.children = [];
return { id: uid(), type, x, y, w: def.size[0], h: def.size[1], cfg, style: { transparent: !!def.transparent, bg: "", color: "", accent: "" } }; return { id: uid(), type, x, y, w: def.size[0], h: def.size[1], cfg, style: { transparent: !!def.transparent, bg: "", color: "", accent: "" } };
} }
@@ -233,13 +245,29 @@
} }
// ------------------------------------------------------------ app state // ------------------------------------------------------------ app state
let store = { version: 1, dashboards: [] }; let store = { version: 1, dashboards: [], assign: { devices: {}, users: {} } };
// 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
let current = null; let current = null;
let editMode = lsGet("ds.edit") === "1"; let editMode = lsGet("ds.edit") === "1";
let selectedId = null; let selectedId = null;
let activeTabId = null; let activeTabId = null;
/** The tab whose widgets are on screen. */ /** The tab whose widgets are on screen. */
const tab = () => current.tabs.find((t) => t.id === activeTabId) || current.tabs[0]; const tab = () => current.tabs.find((t) => t.id === activeTabId) || current.tabs[0];
/** Cards inside a container card (scroll, swipe) live in its cfg.children. */
let childIndex = null;
function findWidget(id, list = tab().widgets) {
for (const w of list) {
if (w.id === id) return w;
const inner = w.cfg?.children && findWidget(id, w.cfg.children);
if (inner) return inner;
}
return null;
}
const selectedWidget = () => tab().widgets.find((x) => x.id === selectedId);
/** What the inspector edits: a card inside the selected container, or the selected widget. */
const editTarget = () => { const w = selectedWidget(); return (childIndex != null && w?.cfg.children?.[childIndex]) || w; };
const undoStack = [], redoStack = []; const undoStack = [], redoStack = [];
let lastMutKey = null, lastMutAt = 0; let lastMutKey = null, lastMutAt = 0;
const caches = {}; // per-widget runtime cache (graph history) const caches = {}; // per-widget runtime cache (graph history)
@@ -299,7 +327,7 @@
} }
// ------------------------------------------------------------ rendering // ------------------------------------------------------------ rendering
const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" }; const VAR_FOR = { bg: "--d-bg", tile: "--d-tile", text: "--d-text", text2: "--d-text-2", accent: "--d-accent", onAccent: "--d-on-accent", separator: "--d-separator", fill: "--d-fill", lit: "--d-lit" };
function applyTheme() { function applyTheme() {
const t = current.theme, s = stageEl.style; const t = current.theme, s = stageEl.style;
@@ -312,6 +340,8 @@
const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)"; const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)";
if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`); if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`);
s.setProperty("--d-radius", `${t.radius ?? 20}px`); s.setProperty("--d-radius", `${t.radius ?? 20}px`);
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`); s.setProperty("--d-blur", `${t.blur || 0}px`);
if (t.preset === "System") s.removeProperty("--d-shadow"); if (t.preset === "System") s.removeProperty("--d-shadow");
else s.setProperty("--d-shadow", t.shadow ? "0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.06)" : "none"); else s.setProperty("--d-shadow", t.shadow ? "0 1px 2px rgba(0,0,0,.04), 0 4px 16px rgba(0,0,0,.06)" : "none");
@@ -473,9 +503,17 @@
if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`); if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`);
return r.json(); return r.json();
} }
const ctxFor = (w) => ({ ha, w, api, cache: (caches[w.id] ||= {}) }); const ctxFor = (w) => ({
ha, w, api, cache: (caches[w.id] ||= {}),
isVisible: (child) => editMode || isVisible(child),
// Containers draw placeholders (.w.child); this fills each with its card.
renderChildren: (el) => el.querySelectorAll(".w.child[data-id]").forEach((ph) => {
const ch = (w.cfg.children || []).find((c) => c.id === ph.dataset.id);
if (ch) renderWidget(ch, ph, { child: true });
}),
});
function renderWidget(w, el = gridEl.querySelector(`.w[data-id="${w.id}"]`)) { function renderWidget(w, el = gridEl.querySelector(`.w[data-id="${w.id}"]`), { child = false } = {}) {
if (!el) return; if (!el) return;
const def = Widgets[w.type]; const def = Widgets[w.type];
if (!def) { el.innerHTML = `<div class="w-body"><div class="empty">${tr("Unknown widget “{0}”.", esc(w.type))}</div></div>`; return; } if (!def) { el.innerHTML = `<div class="w-body"><div class="empty">${tr("Unknown widget “{0}”.", esc(w.type))}</div></div>`; return; }
@@ -492,11 +530,11 @@
} }
let html; let html;
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; } try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
const hiddenNow = editMode && !isVisible(w); const hiddenNow = editMode && !child && !isVisible(w);
el.classList.toggle("cond-hidden", hiddenNow); el.classList.toggle("cond-hidden", hiddenNow);
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` + el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
(hiddenNow ? `<div class="w-hidden">${Icons.svg("eye-off")}${tr("Hidden now")}</div>` : "") + (hiddenNow ? `<div class="w-hidden">${Icons.svg("eye-off")}${tr("Hidden now")}</div>` : "") +
(editMode ? `<div class="w-tools"><button data-tool="dup" aria-label="${tr("Duplicate")}" title="${tr("Duplicate")}">${Icons.svg("copy")}</button><button data-tool="del" class="del" aria-label="${tr("Delete")}" title="${tr("Delete")}">${Icons.svg("trash")}</button></div><div class="w-resize" aria-hidden="true"></div>` : ""); (editMode && !child ? `<div class="w-tools"><button data-tool="dup" aria-label="${tr("Duplicate")}" title="${tr("Duplicate")}">${Icons.svg("copy")}</button><button data-tool="del" class="del" aria-label="${tr("Delete")}" title="${tr("Delete")}">${Icons.svg("trash")}</button></div><div class="w-resize" aria-hidden="true"></div>` : "");
if (editMode) el.querySelectorAll(".w-body button, .w-body input, .w-body iframe").forEach((n) => n.setAttribute("tabindex", "-1")); if (editMode) el.querySelectorAll(".w-body button, .w-body input, .w-body iframe").forEach((n) => n.setAttribute("tabindex", "-1"));
if (def.after) Promise.resolve(def.after(el, w.cfg, ctx)).catch(console.error); if (def.after) Promise.resolve(def.after(el, w.cfg, ctx)).catch(console.error);
} }
@@ -504,6 +542,7 @@
function entitiesOf(w) { function entitiesOf(w) {
const def = Widgets[w.type]; const def = Widgets[w.type];
if (!def) return []; if (!def) return [];
if (def.container) return (w.cfg.children || []).flatMap(entitiesOf);
if (def.entities) return def.entities(w.cfg, ha); if (def.entities) return def.entities(w.cfg, ha);
return w.cfg.entity ? [w.cfg.entity] : []; return w.cfg.entity ? [w.cfg.entity] : [];
} }
@@ -623,10 +662,21 @@
function renderInspector() { function renderInspector() {
const el = $("#inspector"); const el = $("#inspector");
const w = tab().widgets.find((x) => x.id === selectedId); const w = tab().widgets.find((x) => x.id === selectedId);
if (w && childIndex != null && w.cfg.children?.[childIndex]) {
// Editing one card inside a container: its own fields and style, with a way back.
const ch = w.cfg.children[childIndex], cdef = Widgets[ch.type];
el.innerHTML = `${datalists()}<button class="btn back" data-child-back>${Icons.svg("chevron", "back")}${esc(tr(Widgets[w.type].label))}</button>
<div class="panel-title">${Icons.svg(cdef.icon)} ${esc(tr(cdef.label))}</div>
${cdef.fields.filter((f) => !f.when || f.when(ch.cfg)).map((f) => fieldHTML(f, ch.cfg[f.k], "cfg")).join("")}
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, ch.style?.[f.k], "style")).join("")}</div>
<div class="insp-actions"><button class="btn danger" data-child-op="del" data-i="${childIndex}">${Icons.svg("trash")}${tr("Remove from container")}</button></div>`;
return;
}
if (w) { if (w) {
const def = Widgets[w.type]; const def = Widgets[w.type];
el.innerHTML = `${datalists()}<div class="panel-title">${Icons.svg(def.icon)} ${esc(tr(def.label))}</div> el.innerHTML = `${datalists()}<div class="panel-title">${Icons.svg(def.icon)} ${esc(tr(def.label))}</div>
${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")} ${def.fields.filter((f) => !f.when || f.when(w.cfg)).map((f) => fieldHTML(f, w.cfg[f.k], "cfg")).join("")}
${def.container ? childrenHTML(w) : ""}
<div class="section"><div class="section-title">${tr("Size and position")}</div> <div class="section"><div class="section-title">${tr("Size and position")}</div>
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div> <div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""} ${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
@@ -706,6 +756,40 @@
} }
} }
/** The list of cards inside a container, with add / edit / reorder / remove. */
function childrenHTML(w) {
const kids = w.cfg.children || [];
const summary = (c) => c.cfg.name || c.cfg.title || c.cfg.text || c.cfg.label || c.cfg.entity || (c.cfg.entities || [])[0] || "";
const types = Object.entries(Widgets).filter(([, d]) => !d.container);
return `<div class="section"><div class="section-title">${tr("Cards")}</div>
${kids.length ? "" : `<p class="muted small" style="margin:0 2px 10px">${tr("Add the cards this container should hold.")}</p>`}
<div class="child-list">${kids.map((c, i) => `<div class="child-item">
<button class="child-open" data-child-edit="${i}">${Icons.svg(Widgets[c.type]?.icon || "tile")}<span class="grow"><span>${esc(tr(Widgets[c.type]?.label || c.type))}</span><span class="muted small">${esc(summary(c))}</span></span>${Icons.svg("chevron", "fwd")}</button>
<button class="btn icon" data-child-op="up" data-i="${i}" aria-label="${tr("Move up")}" ${i === 0 ? "disabled" : ""}>${Icons.svg("chevron", "flip")}</button>
<button class="btn icon" data-child-op="down" data-i="${i}" aria-label="${tr("Move down")}" ${i === kids.length - 1 ? "disabled" : ""}>${Icons.svg("chevron")}</button>
</div>`).join("")}</div>
<div class="row" style="margin-top:8px"><select class="input" id="child-type" aria-label="${tr("Card type")}">${types.map(([k, d]) => `<option value="${k}">${esc(tr(d.label))}</option>`).join("")}</select>
<button class="btn" data-child-op="add" style="flex:none">${Icons.svg("plus")}${tr("Add")}</button></div></div>`;
}
function childOp(op, i) {
const w = selectedWidget();
const kids = (w.cfg.children ||= []);
if (op === "add") {
const type = $("#child-type").value, c = makeWidget(type);
mutate(() => kids.push({ id: c.id, type, cfg: c.cfg, style: { transparent: false, bg: "", color: "", accent: "" } }));
childIndex = kids.length - 1; // go straight to setting it up
} else if (op === "up" || op === "down") {
const j = op === "up" ? i - 1 : i + 1;
if (j < 0 || j >= kids.length) return;
mutate(() => { [kids[i], kids[j]] = [kids[j], kids[i]]; });
} else if (op === "del") {
mutate(() => kids.splice(i, 1));
childIndex = null;
}
renderInspector();
}
function visibilityHTML(w) { function visibilityHTML(w) {
const vis = w.visibility || { mode: "all", rules: [] }; const vis = w.visibility || { mode: "all", rules: [] };
const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"]; const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"];
@@ -772,8 +856,8 @@
location.reload(); location.reload();
return; return;
} }
const w = tab().widgets.find((x) => x.id === selectedId); const w = editTarget();
const key = `${selectedId}|${path}|${k}|${idx ?? ""}`; const key = `${w?.id}|${path}|${k}|${idx ?? ""}`;
if (idx === "new") { if (idx === "new") {
if (!isChange || !v) return; if (!isChange || !v) return;
@@ -807,7 +891,7 @@
else if (path === "dash") { current.name = v; renderDashSelect(); } else if (path === "dash") { current.name = v; renderDashSelect(); }
}, key, { rerender: ["pos", "theme", "grid"].includes(path) }); }, key, { rerender: ["pos", "theme", "grid"].includes(path) });
if (path === "cfg" || path === "style") renderWidget(w); if (path === "cfg" || path === "style") renderWidget(selectedWidget());
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t; const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON")) renderInspector(); if (isChange && (fieldType === "entity" || fieldType === "icon" || idx != null || t.tagName === "BUTTON")) renderInspector();
if (path === "theme" && (t.tagName === "BUTTON" || (isChange && COLOR_KEYS.includes(k)))) renderInspector(); if (path === "theme" && (t.tagName === "BUTTON" || (isChange && COLOR_KEYS.includes(k)))) renderInspector();
@@ -870,6 +954,7 @@
const src = tab().widgets.find((w) => w.id === id); const src = tab().widgets.find((w) => w.id === id);
if (!src) return; if (!src) return;
const copy = { ...clone(src), id: uid() }; const copy = { ...clone(src), id: uid() };
(copy.cfg.children || []).forEach((ch) => (ch.id = uid()));
Object.assign(copy, findSpot(copy.w, copy.h)); Object.assign(copy, findSpot(copy.w, copy.h));
mutate(() => tab().widgets.push(copy)); mutate(() => tab().widgets.push(copy));
select(copy.id); select(copy.id);
@@ -883,6 +968,7 @@
} }
function select(id) { function select(id) {
if (id !== selectedId) childIndex = null;
selectedId = id; selectedId = id;
gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", el.dataset.id === id)); gridEl.querySelectorAll(".w").forEach((el) => el.classList.toggle("selected", el.dataset.id === id));
renderInspector(); renderInspector();
@@ -977,14 +1063,22 @@
ha.callService("climate", "set_temperature", { temperature: t }, eid); ha.callService("climate", "set_temperature", { temperature: t }, eid);
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid); } else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
else if (act === "mp-select") { else if (act === "mp-select") {
const w = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id); const box = a.closest(".w"), w = findWidget(box.dataset.id);
caches[w.id].sel = a.dataset.v; caches[w.id].sel = a.dataset.v;
renderWidget(w); renderWidget(w, box, { child: box.classList.contains("child") });
gridEl.querySelector(`.w[data-id="${w.id}"] [data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus(); box.querySelector(`[data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus();
}
else if (act === "hs-prev" || act === "hs-next") {
const track = a.closest(".hs").querySelector(".hs-track");
track.scrollBy({ left: (act === "hs-next" ? 1 : -1) * track.clientWidth * 0.8, behavior: reducedMotion() ? "auto" : "smooth" });
}
else if (act === "sw-go") {
const track = a.closest(".sw").querySelector(".sw-track");
track.scrollTo({ left: Number(a.dataset.v) * track.clientWidth, behavior: reducedMotion() ? "auto" : "smooth" });
} }
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); } else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
else if (act === "button") { else if (act === "button") {
const c = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id).cfg; const c = findWidget(a.closest(".w").dataset.id).cfg;
let data = {}; let data = {};
if (c.data) { try { data = JSON.parse(c.data); } catch { toast(tr("The button’s action data isn’t valid JSON. Fix it in the inspector."), true); return; } } if (c.data) { try { data = JSON.parse(c.data); } catch { toast(tr("The button’s action data isn’t valid JSON. Fix it in the inspector."), true); return; } }
if (c.service && c.service.includes(".")) { const [d, s] = c.service.split("."); ha.callService(d, s, data, c.entity || undefined); } if (c.service && c.service.includes(".")) { const [d, s] = c.service.split("."); ha.callService(d, s, data, c.entity || undefined); }
@@ -1064,6 +1158,50 @@
}); });
} }
/** 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);
}
function toast(msg, err) { function toast(msg, err) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "toast" + (err ? " err" : ""); el.className = "toast" + (err ? " err" : "");
@@ -1219,6 +1357,8 @@
if (act === "rename") { if (act === "rename") {
const n = await askText(tr("Rename dashboard"), current.name, tr("Rename")); const n = await askText(tr("Rename dashboard"), current.name, tr("Rename"));
if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); } if (n) { mutate(() => { current.name = n; }, null, { rerender: false }); renderAll(); }
} else if (act === "assign") {
assignDialog();
} else if (act === "starter") { } else if (act === "starter") {
if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; } if (!ha.connected) { toast(tr("Not connected to Home Assistant. Retrying…"), true); return; }
const d = starterDashboard(); const d = starterDashboard();
@@ -1294,6 +1434,10 @@
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false }); mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
renderWidget(w); renderInspector(); return; renderWidget(w); renderInspector(); return;
} }
const childEdit = e.target.closest("[data-child-edit]"), childBtn = e.target.closest("[data-child-op]");
if (childEdit) { childIndex = Number(childEdit.dataset.childEdit); renderInspector(); return; }
if (childBtn) { childOp(childBtn.dataset.childOp, Number(childBtn.dataset.i)); return; }
if (e.target.closest("[data-child-back]")) { childIndex = null; renderInspector(); return; }
const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]"); const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]");
if (ruleAdd || ruleDel) { if (ruleAdd || ruleDel) {
const w = tab().widgets.find((x) => x.id === selectedId); const w = tab().widgets.find((x) => x.id === selectedId);
@@ -1416,6 +1560,7 @@
const r = await fetch(new URL("api/dashboards", BASE)); const r = await fetch(new URL("api/dashboards", BASE));
store = await r.json(); store = await r.json();
store.dashboards = (store.dashboards || []).map(migrate); 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); } } catch { toast(tr("Couldn’t load your dashboards. Check that the app is running."), true); }
const firstReady = new Promise((res) => { const firstReady = new Promise((res) => {
@@ -1427,12 +1572,16 @@
if (!store.dashboards?.length) { if (!store.dashboards?.length) {
await firstReady; await firstReady;
store = { version: 1, dashboards: [starterDashboard()] }; store = { version: 1, dashboards: [starterDashboard()], assign: { devices: {}, users: {} } };
save(); save();
} }
me = await api("api/whoami").catch(() => null);
const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1]; const hashId = (location.hash.match(/view=([\w-]+)/) || [])[1];
setCurrent(hashId || lsGet("ds.current")); // Which dashboard to open: a link wins, then this device's choice, then this person's, then the last one used here.
if (location.hash.includes("kiosk")) enterKiosk({ fullscreen: false }); const dev = store.assign.devices[DEVICE_ID], forUser = me?.id && store.assign.users[me.id];
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") || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
} }
boot(); boot();
+13
View File
@@ -245,6 +245,19 @@
"About": "Over", "Version {0}": "Versie {0}", "Source code and changes": "Broncode en wijzigingen", "About": "Over", "Version {0}": "Versie {0}", "Source code and changes": "Broncode en wijzigingen",
"Dashboard Studio was updated to {0}. Reload the page to use it.": "Dashboard Studio is bijgewerkt naar {0}. Herlaad de pagina om de nieuwe versie te gebruiken.", "Dashboard Studio was updated to {0}. Reload the page to use it.": "Dashboard Studio is bijgewerkt naar {0}. Herlaad de pagina om de nieuwe versie te gebruiken.",
"Dashboard Studio was updated to {0}. Reloading…": "Dashboard Studio is bijgewerkt naar {0}. Opnieuw laden…", "Dashboard Studio was updated to {0}. Reloading…": "Dashboard Studio is bijgewerkt naar {0}. Opnieuw laden…",
"Graphite": "Grafiet", "Indigo": "Indigo", "Rose": "Roze", "Ocean": "Oceaan", "Sunrise": "Zonsopgang", "Lavender": "Lavendel", "Mint": "Munt", "Sand": "Zand", "High contrast": "Hoog contrast",
"Horizontal scroll": "Horizontaal scrollen", "Swipe cards": "Veegkaarten", "Card width (px)": "Kaartbreedte (px)",
"Scroll left": "Scroll naar links", "Scroll right": "Scroll naar rechts", "Next card every (seconds)": "Volgende kaart elke (seconden)",
"Show page dots": "Toon paginapuntjes", "Card {0} of {1}": "Kaart {0} van {1}", "Cards": "Kaarten",
"None of these cards are showing right now.": "Geen van deze kaarten is nu zichtbaar.", "Add cards in the inspector.": "Voeg kaarten toe in de inspector.",
"Add the cards this container should hold.": "Voeg de kaarten toe die hierin moeten komen.", "Card type": "Soort kaart", "Add": "Toevoegen",
"Remove from container": "Verwijder uit deze kaart",
"Who sees which dashboard…": "Wie ziet welk dashboard…", "Who sees which dashboard": "Wie ziet welk dashboard",
"Choose the dashboard that opens first. A device’s choice comes before a person’s; otherwise the last dashboard used on that device opens.": "Kies welk dashboard als eerste opent. De keuze voor een apparaat gaat voor die van een persoon; anders opent het dashboard dat op dat apparaat het laatst is gebruikt.",
"This device": "Dit apparaat", "Other devices": "Andere apparaten", "People": "Personen", "Last used": "Laatst gebruikt",
"Name this device, e.g. Kitchen tablet": "Geef dit apparaat een naam, bijv. Keukentablet", "Unnamed device": "Apparaat zonder naam",
"Device name": "Apparaatnaam", "Forget this device": "Vergeet dit apparaat", "you": "jij", "Dashboard for {0}": "Dashboard voor {0}",
"People appear here once they have opened Dashboard Studio, or when they have a person entity.": "Personen verschijnen hier zodra ze Dashboard Studio hebben geopend, of als ze een persoonsentiteit hebben.",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+2
View File
@@ -45,6 +45,8 @@
dishwasher: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 8h16M8 5.5h.01M11 5.5h.01M8 12v5M12 12v5M16 12v5", dishwasher: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 8h16M8 5.5h.01M11 5.5h.01M8 12v5M12 12v5M16 12v5",
eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
"eye-off": "M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.1M6.6 6.6A17.6 17.6 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2", "eye-off": "M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.1M6.6 6.6A17.6 17.6 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2",
hscroll: "M3 6h5v12H3zM10 6h5v12h-5zM17 6h4M17 18h4M21 6v12",
swipe: "M6 4h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM9 21h.01M12 21h.01M15 21h.01",
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01", calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2", clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
chart: "M3 3v18h18M7 15l4-4 3 3 6-6", chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
+1
View File
@@ -36,6 +36,7 @@
<button role="menuitem" data-menu="rename" data-icon="edit" data-i18n>Rename…</button> <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="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="starter" data-icon="sparkles" data-i18n>New dashboard from my devices</button>
<button role="menuitem" data-menu="assign" data-icon="person" data-i18n>Who sees which dashboard…</button>
<hr> <hr>
<button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button> <button role="menuitem" data-menu="export" data-icon="exit" data-i18n>Export as JSON</button>
<button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button> <button role="menuitem" data-menu="import" data-icon="plus" data-i18n>Import JSON…</button>
+51 -3
View File
@@ -210,6 +210,14 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
.modal-card p { margin: 6px 0 0; color: var(--text-2); } .modal-card p { margin: 6px 0 0; color: var(--text-2); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; }
.modal-actions .btn { min-width: 96px; } .modal-actions .btn { min-width: 96px; }
.modal-card.wide { width: 560px; }
.assign-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.assign-row .input { flex: 1; min-width: 0; }
.assign-row select.input { flex: 1.2; }
.assign-name { flex: 1; min-width: 0; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.assign-kiosk { display: flex; align-items: center; gap: 6px; font-size: 13px; white-space: nowrap; }
.assign-kiosk input { width: 18px; height: 18px; accent-color: var(--accent); margin: 0; }
@media (max-width: 560px) { .assign-row[data-dev] { flex-wrap: wrap; } .assign-row[data-dev] .input { flex-basis: 100%; } }
.attr-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 14px; } .attr-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 14px; }
.attr-table td { border-top: 1px solid var(--separator); padding: 7px 4px; vertical-align: top; word-break: break-word; } .attr-table td { border-top: 1px solid var(--separator); padding: 7px 4px; vertical-align: top; word-break: break-word; }
.attr-table td:first-child { color: var(--text-2); width: 40%; } .attr-table td:first-child { color: var(--text-2); width: 40%; }
@@ -264,7 +272,7 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
.w.lit-lamp .badge { background: rgba(255, 255, 255, 0.55); color: var(--lit-text); } .w.lit-lamp .badge { background: rgba(255, 255, 255, 0.55); color: var(--lit-text); }
.surface-plain .w.lit-lamp, .surface-outline .w.lit-lamp { background: transparent; color: var(--w-color, var(--d-text)); --d-text-2: var(--text-2); --d-fill: var(--fill); } .surface-plain .w.lit-lamp, .surface-outline .w.lit-lamp { background: transparent; color: var(--w-color, var(--d-text)); --d-text-2: var(--text-2); --d-fill: var(--fill); }
.surface-plain .w.lit-lamp .badge, .surface-outline .w.lit-lamp .badge { background: color-mix(in srgb, var(--lamp, var(--d-lit)) 45%, var(--d-lit)); color: var(--lit-text); } .surface-plain .w.lit-lamp .badge, .surface-outline .w.lit-lamp .badge { background: color-mix(in srgb, var(--lamp, var(--d-lit)) 45%, var(--d-lit)); color: var(--lit-text); }
.w.lit-on .badge { background: var(--accent-w); color: #fff; } .w.lit-on .badge { background: var(--accent-w); color: var(--d-on-accent, #fff); }
/* edit mode */ /* edit mode */
body.editing #grid { background-image: var(--cell-bg); background-repeat: repeat; min-height: calc(100% - 16px); } body.editing #grid { background-image: var(--cell-bg); background-repeat: repeat; min-height: calc(100% - 16px); }
@@ -383,7 +391,7 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
[aria-checked="true"] > .switch { background: var(--ok); } [aria-checked="true"] > .switch { background: var(--ok); }
[aria-checked="true"] > .switch::after { transform: translateX(18px); } [aria-checked="true"] > .switch::after { transform: translateX(18px); }
.btn-widget { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; height: 100%; width: 100%; gap: 8px; } .btn-widget { display: flex; flex-direction: column; align-items: flex-start; justify-content: space-between; height: 100%; width: 100%; gap: 8px; }
.btn-widget:active .badge { background: var(--accent-w); color: #fff; } .btn-widget:active .badge { background: var(--accent-w); color: var(--d-on-accent, #fff); }
.text-w { white-space: pre-wrap; overflow: auto; height: 100%; line-height: 1.45; } .text-w { white-space: pre-wrap; overflow: auto; height: 100%; line-height: 1.45; }
.frame { width: 100%; height: 100%; border: 0; display: block; } .frame { width: 100%; height: 100%; border: 0; display: block; }
.img-w { width: 100%; height: 100%; display: block; } .img-w { width: 100%; height: 100%; display: block; }
@@ -495,12 +503,52 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
/* appliance */ /* appliance */
.appl { display: flex; flex-direction: column; height: 100%; gap: 8px; justify-content: space-between; } .appl { display: flex; flex-direction: column; height: 100%; gap: 8px; justify-content: space-between; }
.appl-status { margin-left: auto; font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; } .appl-status { margin-left: auto; font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
.appl.phase-running .appl-status { background: var(--d-accent); color: #fff; } .appl.phase-running .appl-status { background: var(--d-accent); color: var(--d-on-accent, #fff); }
.theme-outline .w:not(.transparent) { border-color: var(--d-separator); }
.appl.phase-done .appl-status { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); } .appl.phase-done .appl-status { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.appl-big { font-size: 22px; font-weight: 700; } .appl-big { font-size: 22px; font-weight: 700; }
.appl-big .wt { font-size: 14px; font-weight: 400; } .appl-big .wt { font-size: 14px; font-weight: 400; }
.appl .car-fill { background: var(--d-accent); } .appl .car-fill { background: var(--d-accent); }
/* containers: horizontal scroll and swipe */
.w.child { position: relative; flex: none; height: 100%; }
.hs { position: relative; height: 100%; display: flex; flex-direction: column; gap: 6px; }
.hs-title { flex: none; padding: 0 4px; }
.hs-track {
flex: 1; min-height: 0; display: flex; gap: var(--d-gap, 14px); overflow-x: auto; overflow-y: hidden;
scroll-snap-type: x mandatory; scroll-padding: 0 2px; scrollbar-width: none; overscroll-behavior-x: contain;
}
.hs-track::-webkit-scrollbar { display: none; }
.hs-track > .w.child { width: var(--item-w, 260px); scroll-snap-align: start; }
.hs-track:focus-visible { box-shadow: var(--focus); border-radius: var(--d-radius, 20px); }
.hs-nav {
position: absolute; top: 50%; z-index: 4; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
background: var(--glass); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); color: var(--text);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); font-size: 18px; opacity: 0; transition: opacity .15s;
}
.hs-nav.prev { left: 6px; } .hs-nav.next { right: 6px; }
.hs:hover .hs-nav, .hs-nav:focus-visible { opacity: 1; }
.hs.at-start .hs-nav.prev, .hs.at-end .hs-nav.next { opacity: 0; pointer-events: none; }
@media (hover: none) { .hs-nav { display: none; } }
.i.back { transform: rotate(90deg); } .i.fwd { transform: rotate(-90deg); }
.sw { height: 100%; display: flex; flex-direction: column; gap: 8px; }
.sw-track { flex: 1; min-height: 0; display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; overscroll-behavior-x: contain; border-radius: var(--d-radius, 20px); }
.sw-track::-webkit-scrollbar { display: none; }
.sw-track > .w.child { width: 100%; scroll-snap-align: center; scroll-snap-stop: always; }
.sw-dots { flex: none; display: flex; justify-content: center; gap: 2px; }
.sw-dot { width: 22px; height: 22px; display: grid; place-items: center; }
.sw-dot::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--d-text-2); opacity: .45; transition: width .2s, opacity .2s; }
.sw-dot[aria-selected="true"]::after { width: 18px; border-radius: 4px; opacity: 1; background: var(--d-text); }
.btn.back { margin: 0 0 10px; padding-left: 8px; }
.child-list { display: flex; flex-direction: column; gap: 6px; }
.child-item { display: flex; gap: 4px; align-items: center; }
.child-open { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 0; border-radius: 10px; background: var(--fill); cursor: pointer; text-align: left; }
.child-open:hover { background: var(--fill-strong); }
.child-open .grow { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.child-open .grow span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.child-open .i { font-size: 18px; flex: none; }
.child-item .btn.icon { width: 30px; min-width: 30px; flex: none; }
/* photos */ /* photos */
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; } .photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; } .ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
+84
View File
@@ -831,6 +831,90 @@
}, },
}; };
// ---------------------------------------------------------------- containers
// Scroll and swipe cards hold other cards in cfg.children. They draw placeholders;
// ctx.renderChildren fills them using the normal widget renderer.
const slots = (c, ctx) => (c.children || []).filter((ch) => ctx.isVisible(ch));
const slot = (ch) => `<div class="w child" data-id="${esc(ch.id)}"></div>`;
const noKids = (c) => `<div class="pad"><div class="empty">${(c.children || []).length ? t("None of these cards are showing right now.") : t("Add cards in the inspector.")}</div></div>`;
W.hscroll = {
label: "Horizontal scroll", icon: "hscroll", size: [12, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "title", t: "text", l: "Title", d: "" },
{ k: "itemWidth", t: "number", l: "Card width (px)", d: 260 },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
return `<div class="hs">${c.title ? `<div class="wt hs-title">${esc(c.title)}</div>` : ""}
<div class="hs-track" style="--item-w:${Math.max(120, Number(c.itemWidth) || 260)}px" tabindex="0" role="group" aria-label="${esc(c.title || t("Horizontal scroll"))}">${kids.map(slot).join("")}</div>
<button class="hs-nav prev wbtn" data-act="hs-prev" aria-label="${t("Scroll left")}">${Icons.svg("chevron", "back")}</button>
<button class="hs-nav next wbtn" data-act="hs-next" aria-label="${t("Scroll right")}">${Icons.svg("chevron", "fwd")}</button></div>`;
},
after(el, c, ctx) {
ctx.renderChildren(el);
const track = el.querySelector(".hs-track"), box = el.querySelector(".hs");
if (!track) return;
// Keep the scroll position when a card inside updates and everything is redrawn.
track.scrollLeft = ctx.cache.scroll || 0;
const edges = () => {
box.classList.toggle("at-start", track.scrollLeft < 4);
box.classList.toggle("at-end", track.scrollLeft + track.clientWidth > track.scrollWidth - 4);
};
track.addEventListener("scroll", () => { ctx.cache.scroll = track.scrollLeft; edges(); }, { passive: true });
edges();
},
};
W.swipe = {
label: "Swipe cards", icon: "swipe", size: [4, 3], container: true, transparent: true, flush: true,
fields: [
{ k: "autoplay", t: "number", l: "Next card every (seconds)", d: 0, ph: "0 = off" },
{ k: "dots", t: "check", l: "Show page dots", d: true },
],
render(c, ctx) {
const kids = slots(c, ctx);
if (!kids.length) return noKids(c);
const dots = c.dots !== false && kids.length > 1
? `<div class="sw-dots" role="tablist" aria-label="${t("Cards")}">${kids.map((_, i) => `<button class="sw-dot wbtn" role="tab" data-act="sw-go" data-v="${i}" aria-label="${t("Card {0} of {1}", i + 1, kids.length)}" aria-selected="false"></button>`).join("")}</div>` : "";
return `<div class="sw ${dots ? "with-dots" : ""}"><div class="sw-track">${kids.map(slot).join("")}</div>${dots}</div>`;
},
after(el, c, ctx) {
ctx.renderChildren(el);
const track = el.querySelector(".sw-track");
if (!track) return;
const cache = ctx.cache, n = track.children.length;
const page = () => Math.round(track.scrollLeft / Math.max(1, track.clientWidth));
const mark = () => el.querySelectorAll(".sw-dot").forEach((d, i) => d.setAttribute("aria-selected", String(i === page())));
track.scrollLeft = (Math.min(cache.page || 0, n - 1)) * track.clientWidth;
mark();
// Only settled positions count (scrollend), so an autoplay step in flight isn't overwritten.
track.addEventListener("scroll", mark, { passive: true });
const settle = () => { cache.page = page(); mark(); };
if ("onscrollend" in window) track.addEventListener("scrollend", settle);
else { let t0; track.addEventListener("scroll", () => { clearTimeout(t0); t0 = setTimeout(settle, 150); }, { passive: true }); }
// One timer per card that survives redraws (cards inside update often); it always
// works on the latest track, which each redraw records in the cache.
cache.track = track;
track.addEventListener("pointerdown", () => { cache.pausedUntil = Date.now() + 20000; }, { passive: true });
const every = Math.max(3, Number(c.autoplay) || 0) * 1000;
if (!(Number(c.autoplay) > 0) || n < 2) { clearInterval(cache.timer); cache.timer = null; return; }
if (cache.timer && cache.every === every) return;
clearInterval(cache.timer);
cache.every = every;
cache.timer = setInterval(() => {
const tr = cache.track;
if (!tr?.isConnected || Date.now() < (cache.pausedUntil || 0) || document.body.classList.contains("editing")) return;
const count = tr.children.length, next = ((cache.page || 0) + 1) % count;
cache.page = next; // a redraw mid-animation then lands on the new card, not the old one
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
tr.scrollTo({ left: next * tr.clientWidth, behavior: reduce ? "auto" : "smooth" });
}, every);
},
};
window.Widgets = W; window.Widgets = W;
window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap }; window.HAFmt = { esc, domainOf, isOn, friendly, fmtState, icon, relTime, clockHTML, TOGGLE_DOMAINS, cap };
})(); })();